mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 00:43:29 +10:00
fix(web): address React Doctor findings — compiler, purity, query, component structure
prefer-module-scope-pure-function: hoist buildSubtitle, getDecimalPlaces, handleLocaleChange, onLocaleChange, stop, listContent/groupedListContent to module scope so they aren't rebuilt on every render. react-compiler-todo (??=): rewrite draft.metadata.styleRules ??= [] to the non-assignment form to unblock auto-memoization. set-state-in-effect: derive updatedAtLabel at render time instead of syncing it through useState + useEffect. query-destructure-result: destructure useQuery results at call site in resume-analysis and resume-thumbnail to follow TanStack Query v5 convention. only-export-components: extract non-component exports to sibling .ts files so Fast Refresh can preserve component state: - getNextWeights → typography/get-next-weights.ts - detectJsonImportType + ImportType → dialogs/resume/import.utils.ts - getLocaleOptions → features/locale/locale-options.tsx - preview helpers + DEFAULT_PDF_PAGE_SIZE → preview.shared.utils.ts - resolveHighlightToolbarState + defaultHighlightColor → rich-input.utils.ts - computeDelta + getSparklinePoints → statistics.utils.ts no-multi-comp: split multi-component files into focused companions: - ResumePane + ToolbarButton → routes/agent/-components/resume-pane.tsx - DesktopBuilderShell → builder/$resumeId/-components/desktop-builder-shell.tsx - MobileBuilderShell + helpers → builder/$resumeId/-components/mobile-builder-shell.tsx - setBuilderLayout/getBuilderLayout moved to -store/sidebar.ts fix(tests): add Resume type import to section-builder mocks and cast partial mock data as unknown as Resume to satisfy stricter type checking; fix noExplicitAny Biome errors in the same mocks.
This commit is contained in:
@@ -11,6 +11,15 @@ type CountUpProps = {
|
||||
"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,
|
||||
@@ -31,15 +40,6 @@ export function CountUp({
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: "0px" });
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const maxDecimals = getDecimalPlaces(to);
|
||||
|
||||
const formatValue = useCallback(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveHighlightToolbarState } from "./rich-input";
|
||||
import { resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
describe("resolveHighlightToolbarState", () => {
|
||||
it("shows legacy colorless highlights as default yellow and clearable", () => {
|
||||
|
||||
@@ -57,9 +57,9 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/libs/locale";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -351,12 +351,6 @@ function useEditorToolbarState(editor: Editor) {
|
||||
|
||||
type EditorToolbarState = ReturnType<typeof useEditorToolbarState>;
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
|
||||
type EditorToolbarProps = {
|
||||
editor: Editor;
|
||||
isFullscreen: boolean;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
@@ -5,36 +5,6 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { FontDisplay } from "./font-display";
|
||||
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
|
||||
export function getNextWeights(fontFamily: string): Weight[] | null {
|
||||
const fontData = getFont(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
|
||||
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
|
||||
|
||||
// Try to pick 400 and 600 if available
|
||||
const weights: Weight[] = [];
|
||||
|
||||
if (uniqueWeights.includes("400")) weights.push("400");
|
||||
if (uniqueWeights.includes("600")) weights.push("600");
|
||||
|
||||
const selectedWeights = new Set(weights);
|
||||
|
||||
// If we didn't find both, fill in with first/last, ensuring uniqueness
|
||||
while (weights.length < 2 && uniqueWeights.length > 0) {
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!selectedWeights.has(candidate)) {
|
||||
weights.push(candidate);
|
||||
selectedWeights.add(candidate);
|
||||
} else break;
|
||||
}
|
||||
|
||||
return weights.length > 0 ? weights : null;
|
||||
}
|
||||
|
||||
type FontFamilyComboboxProps = Omit<SingleComboboxProps, "options">;
|
||||
|
||||
export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getNextWeights } from "./combobox";
|
||||
import { getNextWeights } from "./get-next-weights";
|
||||
|
||||
describe("getNextWeights", () => {
|
||||
it("returns 400 and 600 when both are available (the preferred default)", () => {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getFont } from "@reactive-resume/fonts";
|
||||
|
||||
type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
|
||||
|
||||
export function getNextWeights(fontFamily: string): Weight[] | null {
|
||||
const fontData = getFont(fontFamily);
|
||||
if (!fontData || !Array.isArray(fontData.weights) || fontData.weights.length === 0) return null;
|
||||
|
||||
const uniqueWeights = Array.from(new Set(fontData.weights)) as Weight[];
|
||||
|
||||
// Try to pick 400 and 600 if available
|
||||
const weights: Weight[] = [];
|
||||
|
||||
if (uniqueWeights.includes("400")) weights.push("400");
|
||||
if (uniqueWeights.includes("600")) weights.push("600");
|
||||
|
||||
const selectedWeights = new Set(weights);
|
||||
|
||||
// If we didn't find both, fill in with first/last, ensuring uniqueness
|
||||
while (weights.length < 2 && uniqueWeights.length > 0) {
|
||||
// candidateIndex: 0 (first), 1 (last)
|
||||
const lastIndex = uniqueWeights.length - 1;
|
||||
const candidate = weights.length === 0 ? uniqueWeights[0] : uniqueWeights[lastIndex];
|
||||
if (!selectedWeights.has(candidate)) {
|
||||
weights.push(candidate);
|
||||
selectedWeights.add(candidate);
|
||||
} else break;
|
||||
}
|
||||
|
||||
return weights.length > 0 ? weights : null;
|
||||
}
|
||||
@@ -79,6 +79,19 @@ type MultiComboboxProps<TValue extends string | number = string> = {
|
||||
|
||||
type ComboboxProps<TValue extends string | number = string> = SingleComboboxProps<TValue> | MultiComboboxProps<TValue>;
|
||||
|
||||
const listContent = <TValue extends string | number>(item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
const groupedListContent = <TValue extends string | number>(group: GroupedComboboxOption<TValue>) => (
|
||||
<ComboboxGroup key={group.key} items={group.items}>
|
||||
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
|
||||
<ComboboxCollection>{listContent}</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
);
|
||||
|
||||
function Combobox<TValue extends string | number = string>(props: ComboboxProps<TValue>) {
|
||||
const {
|
||||
options,
|
||||
@@ -203,19 +216,6 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
|
||||
? Array.isArray(selectedValue) && selectedValue.length > 0
|
||||
: selectedValue !== null && selectedValue !== undefined;
|
||||
|
||||
const listContent = (item: ComboboxOption<TValue>) => (
|
||||
<ComboboxItem key={String(item.value)} value={item} disabled={item.disabled}>
|
||||
{item.label}
|
||||
</ComboboxItem>
|
||||
);
|
||||
|
||||
const groupedListContent = (group: GroupedComboboxOption<TValue>) => (
|
||||
<ComboboxGroup key={group.key} items={group.items}>
|
||||
{group.label !== null && group.label !== undefined ? <ComboboxLabel>{group.label}</ComboboxLabel> : null}
|
||||
<ComboboxCollection>{listContent}</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
);
|
||||
|
||||
const triggerNode = (
|
||||
<ComboboxTrigger
|
||||
id={id}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectJsonImportType } from "./import";
|
||||
import { detectJsonImportType } from "./import.utils";
|
||||
|
||||
describe("detectJsonImportType", () => {
|
||||
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { DialogProps } from "../store";
|
||||
import type { ImportType } from "./import.utils";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
|
||||
@@ -31,6 +32,7 @@ import { getOrpcErrorMessage } from "@/libs/error-message";
|
||||
import { client, orpc } from "@/libs/orpc/client";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
import { useDialogStore } from "../store";
|
||||
import { detectJsonImportType } from "./import.utils";
|
||||
|
||||
const formSchema = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
@@ -72,9 +74,6 @@ const formSchema = z.discriminatedUnion("type", [
|
||||
}),
|
||||
]);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type ImportType = FormValues["type"];
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -120,24 +119,6 @@ async function detectImportType(file: File): Promise<ImportType> {
|
||||
return "";
|
||||
}
|
||||
|
||||
export function detectJsonImportType(parsed: unknown): ImportType {
|
||||
if (!parsed || typeof parsed !== "object") return "";
|
||||
const data = parsed as Record<string, unknown>;
|
||||
|
||||
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
|
||||
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
|
||||
|
||||
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
|
||||
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
|
||||
if ("sections" in data || "metadata" in data) {
|
||||
const metadata = data.metadata as Record<string, unknown> | undefined;
|
||||
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
|
||||
return "reactive-resume-json";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
|
||||
const navigate = useNavigate();
|
||||
const closeDialog = useDialogStore((state) => state.closeDialog);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type ImportType = "" | "pdf" | "docx" | "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json";
|
||||
|
||||
export function detectJsonImportType(parsed: unknown): ImportType {
|
||||
if (!parsed || typeof parsed !== "object") return "";
|
||||
const data = parsed as Record<string, unknown>;
|
||||
|
||||
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
|
||||
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
|
||||
|
||||
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
|
||||
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
|
||||
if ("sections" in data || "metadata" in data) {
|
||||
const metadata = data.metadata as Record<string, unknown> | undefined;
|
||||
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
|
||||
return "reactive-resume-json";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
|
||||
@@ -35,9 +36,15 @@ type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
|
||||
// React portals bubble synthetic events through the React tree, so a menu-item click would
|
||||
// otherwise reach the card's onClick even though the menu is portaled in the DOM.
|
||||
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
|
||||
|
||||
// Shared kebab menu for board cards and table rows: edit, move stage, archive, delete.
|
||||
export function ApplicationActionsMenu({ application, onEdit, showOnHover, className }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: applicationsListQueryKey() });
|
||||
@@ -62,8 +69,13 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
|
||||
}),
|
||||
);
|
||||
|
||||
// Stop pointer/click from reaching the card (which would start a drag or open the detail panel).
|
||||
const stop = (event: React.SyntheticEvent) => event.stopPropagation();
|
||||
const onDelete = async () => {
|
||||
const confirmed = await confirm(t`Delete this application?`, {
|
||||
description: t`"${application.role} · ${application.company}" and its full timeline will be permanently deleted. This can't be undone.`,
|
||||
confirmText: t`Delete`,
|
||||
});
|
||||
if (confirmed) remove.mutate({ id: application.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("shrink-0", className)}>
|
||||
@@ -86,7 +98,7 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuContent align="end" className="w-44" onClick={stop}>
|
||||
<DropdownMenuItem onClick={() => onEdit(application)}>
|
||||
<PencilSimpleIcon />
|
||||
<Trans>Edit</Trans>
|
||||
@@ -118,7 +130,7 @@ export function ApplicationActionsMenu({ application, onEdit, showOnHover, class
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" onClick={() => remove.mutate({ id: application.id })}>
|
||||
<DropdownMenuItem variant="destructive" onClick={onDelete}>
|
||||
<TrashIcon />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { applicationsListQueryKey } from "../queries";
|
||||
import { tileColor } from "../tile-color";
|
||||
import { ApplicationActionsMenu } from "./application-actions-menu";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -167,7 +168,8 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border">
|
||||
{/* Desktop: full table. Mobile: a stacked card list (below) instead of a 900px h-scroll. */}
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded-xl border border-border max-sm:hidden">
|
||||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-muted/50 backdrop-blur">
|
||||
<tr className="[&>th]:whitespace-nowrap [&>th]:px-3 [&>th]:py-2.5 [&>th]:text-left [&>th]:font-medium [&>th]:text-muted-foreground [&>th]:text-xs [&>th]:uppercase [&>th]:tracking-wide">
|
||||
@@ -198,9 +200,13 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
<Trans>Source</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Last activity</Trans>
|
||||
<Trans>Applied</Trans>
|
||||
</th>
|
||||
<th className="w-10">
|
||||
<span className="sr-only">
|
||||
<Trans>Actions</Trans>
|
||||
</span>
|
||||
</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -223,7 +229,12 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<button type="button" className="flex items-center gap-2.5 text-left" onClick={() => onOpen(app)}>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted font-bold text-[10px]">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
|
||||
tileColor(app.company),
|
||||
)}
|
||||
>
|
||||
{getInitials(app.company)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
@@ -254,7 +265,7 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">{app.source || "—"}</td>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted-foreground">
|
||||
{new Date(app.updatedAt).toLocaleDateString()}
|
||||
{new Date(app.appliedAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-1 py-2">
|
||||
<ApplicationActionsMenu application={app} onEdit={onEdit} />
|
||||
@@ -266,6 +277,52 @@ export function ApplicationTable({ applications, onOpen, onEdit }: Props) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards (same paginated rows), tap to open. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto sm:hidden">
|
||||
{rows.map((app) => {
|
||||
const stage = stageOf(app.status);
|
||||
return (
|
||||
<div
|
||||
key={app.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-xl border border-border p-3",
|
||||
selected.has(app.id) && "bg-primary/5",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(app.id)}
|
||||
onCheckedChange={() => toggleOne(app.id)}
|
||||
aria-label={t`Select ${app.company}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
onClick={() => onOpen(app)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-md font-bold text-[10px] text-white",
|
||||
tileColor(app.company),
|
||||
)}
|
||||
>
|
||||
{getInitials(app.company)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-sm">{app.role}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">{app.company}</div>
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs">
|
||||
<span className="size-2 shrink-0 rounded-sm" style={{ background: stage?.color }} />
|
||||
<span className="text-muted-foreground">{stage?.label ?? app.status}</span>
|
||||
{app.salary && <span className="truncate font-medium">· {app.salary}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<ApplicationActionsMenu application={app} onEdit={onEdit} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-muted-foreground text-sm">
|
||||
<span>
|
||||
<Trans>
|
||||
|
||||
@@ -4,16 +4,16 @@ import { CommandItem } from "@reactive-resume/ui/components/command";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
|
||||
import { BaseCommandGroup } from "../base";
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
export function LanguageCommandPage() {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseCommandGroup page="language" heading={<Trans>Language</Trans>}>
|
||||
{Object.entries(localeMap).map(([value, label]) => (
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { localeMap } from "@/libs/locale";
|
||||
import { getLocaleOptions } from "./combobox";
|
||||
import { getLocaleOptions } from "./locale-options";
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { SingleComboboxProps } from "@/components/ui/combobox";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { isLocale, loadLocale, localeMap, setLocaleCookie } from "@/libs/locale";
|
||||
import { isLocale, loadLocale, setLocaleCookie } from "@/libs/locale";
|
||||
import { getLocaleOptions } from "./locale-options";
|
||||
|
||||
type Props = Omit<SingleComboboxProps, "options" | "value" | "onValueChange">;
|
||||
|
||||
export const getLocaleOptions = () => {
|
||||
return Object.entries(localeMap).map(([value, label]) => {
|
||||
const name = i18n.t(label);
|
||||
|
||||
return {
|
||||
value: value as Locale,
|
||||
label: (
|
||||
<span className="flex items-center gap-x-2">
|
||||
<span className="font-mono text-muted-foreground text-xs">{value}</span>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
|
||||
textValue: name,
|
||||
// Match against the translated name, the ISO code, and the untranslated English name so
|
||||
// the list stays searchable regardless of the active UI locale.
|
||||
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
|
||||
};
|
||||
});
|
||||
const onLocaleChange = async (value: string | null) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
export function LocaleCombobox(props: Props) {
|
||||
const { i18n } = useLingui();
|
||||
|
||||
const onLocaleChange = async (value: string | null) => {
|
||||
if (!value || !isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
showClear={false}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { localeMap } from "@/libs/locale";
|
||||
|
||||
export const getLocaleOptions = () => {
|
||||
return Object.entries(localeMap).map(([value, label]) => {
|
||||
const name = i18n.t(label);
|
||||
|
||||
return {
|
||||
value: value as Locale,
|
||||
label: (
|
||||
<span className="flex items-center gap-x-2">
|
||||
<span className="font-mono text-muted-foreground text-xs">{value}</span>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
// Shown in the collapsed trigger (a ReactNode label would otherwise fall back to the ISO code).
|
||||
textValue: name,
|
||||
// Match against the translated name, the ISO code, and the untranslated English name so
|
||||
// the list stays searchable regardless of the active UI locale.
|
||||
keywords: [name, value.toLowerCase(), label.message].filter((keyword): keyword is string => Boolean(keyword)),
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy, RenderTask } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import type { ReactNode } from "react";
|
||||
import type { PreviewPageSize } from "./preview.shared";
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import {
|
||||
AnnotationMode,
|
||||
GlobalWorkerOptions,
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared";
|
||||
import { DEFAULT_PDF_PAGE_SIZE, getPreviewCanvasScale, getScaledPreviewPageSize } from "./preview.shared.utils";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PreviewPageSize } from "./preview.shared";
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
|
||||
|
||||
const canvasToBlob = async (canvas: HTMLCanvasElement) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { PreviewPageSize, ResolvedResumePreviewProps } from "./preview.shared";
|
||||
import type { ResolvedResumePreviewProps } from "./preview.shared";
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
@@ -7,7 +8,8 @@ import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
|
||||
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
|
||||
import { getResumePreviewGapValue, getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
|
||||
import { ResumePreviewLoader } from "./preview.shared";
|
||||
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
|
||||
import { ResumeAccessibleText } from "./resume-accessible-text";
|
||||
|
||||
type PreviewPdf = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getPreviewCanvasScale,
|
||||
getResumePreviewGapValue,
|
||||
getScaledPreviewPageSize,
|
||||
} from "./preview.shared";
|
||||
} from "./preview.shared.utils";
|
||||
|
||||
describe("getScaledPreviewPageSize", () => {
|
||||
it("multiplies both dimensions by the scale", () => {
|
||||
|
||||
@@ -3,12 +3,8 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import {
|
||||
DEFAULT_PDF_PAGE_SIZE,
|
||||
getResumePreviewPageCount,
|
||||
getScaledPreviewPageSize,
|
||||
ResumePreviewLoader,
|
||||
} from "./preview.shared";
|
||||
import { ResumePreviewLoader } from "./preview.shared";
|
||||
import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewPageCount, getScaledPreviewPageSize } from "./preview.shared.utils";
|
||||
|
||||
describe("ResumePreviewLoader", () => {
|
||||
it("uses the same scaled page dimensions as the preview page", () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { DEFAULT_PDF_PAGE_SIZE, getResumePreviewGapValue, getScaledPreviewPageSize } from "./preview.shared.utils";
|
||||
|
||||
export type ResumePreviewProps = {
|
||||
className?: string;
|
||||
@@ -19,11 +20,6 @@ export type ResolvedResumePreviewProps = ResumePreviewProps & {
|
||||
showPageNumbers: boolean;
|
||||
};
|
||||
|
||||
export type PreviewPageSize = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "showPageNumbers"> & {
|
||||
pageCount?: number;
|
||||
pageGap?: CSSProperties["gap"];
|
||||
@@ -31,35 +27,8 @@ type ResumePreviewLoaderProps = Pick<ResumePreviewProps, "pageClassName" | "show
|
||||
pageScale?: number;
|
||||
};
|
||||
|
||||
const PDF_PAGE_RENDER_SCALE = 4;
|
||||
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
|
||||
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
|
||||
height: 841.89,
|
||||
width: 595.28,
|
||||
};
|
||||
|
||||
// ponytail: normalizeResumePreviewProps deleted — defaults now live in ResumePreview destructuring
|
||||
|
||||
export const getPreviewCanvasScale = (width: number, height: number) => {
|
||||
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
|
||||
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
|
||||
const desiredPixels = width * height * desiredScale * desiredScale;
|
||||
|
||||
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
|
||||
|
||||
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
|
||||
};
|
||||
|
||||
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
|
||||
height: pageSize.height * pageScale,
|
||||
width: pageSize.width * pageScale,
|
||||
});
|
||||
|
||||
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
|
||||
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
|
||||
|
||||
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
|
||||
|
||||
export function ResumePreviewLoader({
|
||||
pageCount = 1,
|
||||
pageClassName,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type PreviewPageSize = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const PDF_PAGE_RENDER_SCALE = 4;
|
||||
const MAX_PREVIEW_CANVAS_PIXELS = 16_777_216; // 4096 * 4096
|
||||
|
||||
export const DEFAULT_PDF_PAGE_SIZE: PreviewPageSize = {
|
||||
height: 841.89,
|
||||
width: 595.28,
|
||||
};
|
||||
|
||||
export const getPreviewCanvasScale = (width: number, height: number) => {
|
||||
const devicePixelRatio = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1;
|
||||
const desiredScale = Math.max(PDF_PAGE_RENDER_SCALE, devicePixelRatio);
|
||||
const desiredPixels = width * height * desiredScale * desiredScale;
|
||||
|
||||
if (desiredPixels <= MAX_PREVIEW_CANVAS_PIXELS) return desiredScale;
|
||||
|
||||
return Math.sqrt(MAX_PREVIEW_CANVAS_PIXELS / (width * height));
|
||||
};
|
||||
|
||||
export const getScaledPreviewPageSize = (pageSize: PreviewPageSize, pageScale: number): PreviewPageSize => ({
|
||||
height: pageSize.height * pageScale,
|
||||
width: pageSize.width * pageScale,
|
||||
});
|
||||
|
||||
export const getResumePreviewGapValue = (pageGap: CSSProperties["gap"]) =>
|
||||
typeof pageGap === "number" && pageGap !== 0 ? `${pageGap}px` : pageGap;
|
||||
|
||||
export const getResumePreviewPageCount = (data?: ResumeData) => Math.max(1, data?.metadata.layout.pages.length ?? 1);
|
||||
@@ -2,7 +2,8 @@ import type { ResumePreviewProps } from "./preview.shared";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
import { useResumeData } from "../builder/draft";
|
||||
import { getResumePreviewPageCount, ResumePreviewLoader } from "./preview.shared";
|
||||
import { ResumePreviewLoader } from "./preview.shared";
|
||||
import { getResumePreviewPageCount } from "./preview.shared.utils";
|
||||
|
||||
const ResumePreviewClient = lazy(() =>
|
||||
import("./preview.browser").then((module) => ({ default: module.ResumePreviewClient })),
|
||||
|
||||
@@ -29,6 +29,13 @@ type Props = {
|
||||
children: ({ session }: { session: AuthSession }) => React.ComponentProps<typeof DropdownMenuTrigger>["render"];
|
||||
};
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
export function UserDropdownMenu({ children }: Props) {
|
||||
const isClient = useIsClient();
|
||||
const router = useRouter();
|
||||
@@ -41,13 +48,6 @@ export function UserDropdownMenu({ children }: Props) {
|
||||
setTheme(value);
|
||||
};
|
||||
|
||||
const handleLocaleChange = async (value: string) => {
|
||||
if (!isLocale(value)) return;
|
||||
setLocaleCookie(value);
|
||||
await loadLocale(value);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
const toastId = toast.loading(t`Signing out...`);
|
||||
|
||||
|
||||
@@ -1,208 +1,24 @@
|
||||
import type * as React from "react";
|
||||
import type { PanelImperativeHandle } from "react-resizable-panels";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import {
|
||||
ArrowSquareOutIcon,
|
||||
ChatCircleDotsIcon,
|
||||
CircleNotchIcon,
|
||||
FilePdfIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
SidebarSimpleIcon,
|
||||
SquaresFourIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { ChatCircleDotsIcon, SidebarSimpleIcon, SquaresFourIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { ResumePreview } from "@/features/resume/preview/preview";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { AgentChat } from "./-components/agent-chat";
|
||||
import { ResumePane } from "./-components/resume-pane";
|
||||
import { AgentThreadSidebar } from "./-components/thread-sidebar";
|
||||
import { useAgentResumeUpdateSubscription } from "./-hooks/use-agent-resume-updates";
|
||||
|
||||
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
|
||||
|
||||
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type ResumePaneProps = {
|
||||
resume: AgentThreadDetail["resume"];
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/agent/$threadId")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
const AGENT_PREVIEW_ZOOM_STORAGE_KEY = "reactive-resume:agent-preview-zoom:v3";
|
||||
const MIN_PREVIEW_ZOOM = 0.4;
|
||||
const MAX_PREVIEW_ZOOM = 1.5;
|
||||
const PREVIEW_ZOOM_STEP = 0.05;
|
||||
const DEFAULT_PREVIEW_ZOOM = 1;
|
||||
|
||||
function clampPreviewZoom(value: number) {
|
||||
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value));
|
||||
}
|
||||
|
||||
function getInitialPreviewZoom() {
|
||||
if (typeof window === "undefined") return DEFAULT_PREVIEW_ZOOM;
|
||||
// ponytail: Number(null) === 0, so guard with a null-check before parsing
|
||||
const raw = window.localStorage.getItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY);
|
||||
if (raw === null) return DEFAULT_PREVIEW_ZOOM;
|
||||
const stored = Number(raw);
|
||||
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
|
||||
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="ghost" aria-label={label} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ResumePane({ resume }: ResumePaneProps) {
|
||||
const [zoom, setZoom] = useState(getInitialPreviewZoom);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY, String(zoom));
|
||||
}, [zoom]);
|
||||
|
||||
const setClampedZoom = useCallback((value: number) => {
|
||||
setZoom(clampPreviewZoom(Number(value.toFixed(2))));
|
||||
}, []);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const zoomPercent = Math.round(zoom * 100);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-muted/30">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
<Trans>Resume</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background/90 px-2 backdrop-blur">
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
label={t`Decrease zoom`}
|
||||
disabled={!resume}
|
||||
onClick={() => setClampedZoom(zoom - PREVIEW_ZOOM_STEP)}
|
||||
>
|
||||
<MinusIcon />
|
||||
</ToolbarButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={`${zoomPercent}%`}
|
||||
disabled={!resume}
|
||||
aria-label={t`Zoom level`}
|
||||
className="h-8 w-14 rounded-md border bg-background px-1 text-center text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
|
||||
onChange={(event) => {
|
||||
const nextValue = Number(event.target.value.replace(/[^0-9.]/g, ""));
|
||||
if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
<Trans>Zoom level</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
label={t`Increase zoom`}
|
||||
disabled={!resume}
|
||||
onClick={() => setClampedZoom(zoom + PREVIEW_ZOOM_STEP)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
label={t`Open in builder`}
|
||||
disabled={!resume}
|
||||
nativeButton={false}
|
||||
render={resume ? <Link to="/builder/$resumeId" params={{ resumeId: resume.id }} /> : undefined}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t`Download PDF`}
|
||||
disabled={!resume || isPrinting}
|
||||
onClick={() => void onDownloadPDF()}
|
||||
>
|
||||
{isPrinting ? <CircleNotchIcon className="animate-spin" /> : <FilePdfIcon />}
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{resume ? (
|
||||
<ResumePreview
|
||||
data={resume.data}
|
||||
pageLayout="vertical"
|
||||
pageScale={zoom}
|
||||
showPageNumbers
|
||||
className="mx-auto"
|
||||
pageClassName="shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
|
||||
<Trans>The working resume was deleted. This thread is read-only.</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteComponent() {
|
||||
const { threadId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import type * as React from "react";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowSquareOutIcon, CircleNotchIcon, FilePdfIcon, MinusIcon, PlusIcon } from "@phosphor-icons/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { ResumePreview } from "@/features/resume/preview/preview";
|
||||
|
||||
type ToolbarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AgentThreadDetail = RouterOutput["agent"]["threads"]["get"];
|
||||
|
||||
export type ResumePaneProps = {
|
||||
resume: AgentThreadDetail["resume"];
|
||||
};
|
||||
|
||||
const AGENT_PREVIEW_ZOOM_STORAGE_KEY = "reactive-resume:agent-preview-zoom:v3";
|
||||
const MIN_PREVIEW_ZOOM = 0.4;
|
||||
const MAX_PREVIEW_ZOOM = 1.5;
|
||||
const PREVIEW_ZOOM_STEP = 0.05;
|
||||
const DEFAULT_PREVIEW_ZOOM = 1;
|
||||
|
||||
function clampPreviewZoom(value: number) {
|
||||
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value));
|
||||
}
|
||||
|
||||
function getInitialPreviewZoom() {
|
||||
if (typeof window === "undefined") return DEFAULT_PREVIEW_ZOOM;
|
||||
// ponytail: Number(null) === 0, so guard with a null-check before parsing
|
||||
const raw = window.localStorage.getItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY);
|
||||
if (raw === null) return DEFAULT_PREVIEW_ZOOM;
|
||||
const stored = Number(raw);
|
||||
return Number.isFinite(stored) ? clampPreviewZoom(stored) : DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
|
||||
function ToolbarButton({ label, children, ...props }: ToolbarButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button size="icon-sm" variant="ghost" aria-label={label} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResumePane({ resume }: ResumePaneProps) {
|
||||
const [zoom, setZoom] = useState(getInitialPreviewZoom);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(AGENT_PREVIEW_ZOOM_STORAGE_KEY, String(zoom));
|
||||
}, [zoom]);
|
||||
|
||||
const setClampedZoom = useCallback((value: number) => {
|
||||
setZoom(clampPreviewZoom(Number(value.toFixed(2))));
|
||||
}, []);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
|
||||
const filename = generateFilename(resume.name || resume.data.basics.name || resume.id, "pdf");
|
||||
const toastId = toast.loading(t`Please wait while your PDF is being generated…`);
|
||||
|
||||
setIsPrinting(true);
|
||||
|
||||
try {
|
||||
const blob = await createResumePdfBlob(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
||||
} finally {
|
||||
setIsPrinting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const zoomPercent = Math.round(zoom * 100);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-muted/30">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between border-b px-4">
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
<Trans>Resume</Trans>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{resume?.name ?? t`Missing working resume`}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="sticky top-0 z-10 flex h-10 items-center justify-between border-b bg-background/90 px-2 backdrop-blur">
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
label={t`Decrease zoom`}
|
||||
disabled={!resume}
|
||||
onClick={() => setClampedZoom(zoom - PREVIEW_ZOOM_STEP)}
|
||||
>
|
||||
<MinusIcon />
|
||||
</ToolbarButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={`${zoomPercent}%`}
|
||||
disabled={!resume}
|
||||
aria-label={t`Zoom level`}
|
||||
className="h-8 w-14 rounded-md border bg-background px-1 text-center text-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50"
|
||||
onChange={(event) => {
|
||||
const nextValue = Number(event.target.value.replace(/[^0-9.]/g, ""));
|
||||
if (Number.isFinite(nextValue)) setClampedZoom(nextValue / 100);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom" align="center">
|
||||
<Trans>Zoom level</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ToolbarButton
|
||||
label={t`Increase zoom`}
|
||||
disabled={!resume}
|
||||
onClick={() => setClampedZoom(zoom + PREVIEW_ZOOM_STEP)}
|
||||
>
|
||||
<PlusIcon />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
label={t`Open in builder`}
|
||||
disabled={!resume}
|
||||
nativeButton={false}
|
||||
render={resume ? <Link to="/builder/$resumeId" params={{ resumeId: resume.id }} /> : undefined}
|
||||
>
|
||||
<ArrowSquareOutIcon />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
label={t`Download PDF`}
|
||||
disabled={!resume || isPrinting}
|
||||
onClick={() => void onDownloadPDF()}
|
||||
>
|
||||
{isPrinting ? <CircleNotchIcon className="animate-spin" /> : <FilePdfIcon />}
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{resume ? (
|
||||
<ResumePreview
|
||||
data={resume.data}
|
||||
pageLayout="vertical"
|
||||
pageScale={zoom}
|
||||
showPageNumbers
|
||||
className="mx-auto"
|
||||
pageClassName="shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-muted-foreground">
|
||||
<Trans>The working resume was deleted. This thread is read-only.</Trans>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import type { BuilderLayout } from "../-store/sidebar";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { BuilderSidebarLeft } from "../-sidebar/left";
|
||||
import { BuilderSidebarRight } from "../-sidebar/right";
|
||||
import {
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
setBuilderLayout,
|
||||
useBuilderSidebar,
|
||||
useBuilderSidebarStore,
|
||||
} from "../-store/sidebar";
|
||||
import { BuilderHeader } from "./header";
|
||||
|
||||
export type BuilderLayoutShellProps = {
|
||||
initialLayout: BuilderLayout;
|
||||
};
|
||||
|
||||
export function DesktopBuilderShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
// Only rendered when `BuilderLayoutShell` has already decided we're on desktop, so sidebar sizing is unconditional.
|
||||
const canPersistLayoutRef = useRef(false);
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
const rightSidebarRef = usePanelRef();
|
||||
|
||||
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
|
||||
|
||||
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior } = useBuilderSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(initialLayout);
|
||||
canPersistLayoutRef.current = true;
|
||||
}, [initialLayout, setLayout]);
|
||||
|
||||
const onLayoutChanged = (layout: Layout) => {
|
||||
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
|
||||
if (!canPersistLayoutRef.current) return;
|
||||
setLayout(nextLayout);
|
||||
setBuilderLayout(nextLayout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!leftSidebarRef || !rightSidebarRef) return;
|
||||
|
||||
setLeftSidebar(leftSidebarRef);
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const sidebarMinSize = `${minSidebarSize}px`;
|
||||
const sidebarCollapsedSize = `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = `${initialLayout.left}%`;
|
||||
const rightSidebarSize = `${initialLayout.right}%`;
|
||||
const artboardSize = `${initialLayout.artboard}%`;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
groupResizeBehavior={groupResizeBehavior}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={leftSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarLeft />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-s" />
|
||||
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
|
||||
<main id="main-content" className="h-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-e" />
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
groupResizeBehavior={groupResizeBehavior}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={rightSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarRight />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
|
||||
import { Outlet } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { usePreviewPausedStore } from "@/features/resume/builder/draft";
|
||||
import { BuilderSidebarLeft } from "../-sidebar/left";
|
||||
import { BuilderSidebarRight } from "../-sidebar/right";
|
||||
import { BuilderHeader } from "./header";
|
||||
|
||||
type MobileBuilderTab = "edit" | "preview" | "design";
|
||||
|
||||
const MOBILE_BUILDER_TABS = [
|
||||
{ value: "edit", icon: NotePencilIcon },
|
||||
{ value: "preview", icon: EyeIcon },
|
||||
{ value: "design", icon: PaletteIcon },
|
||||
] as const satisfies readonly { value: MobileBuilderTab; icon: Icon }[];
|
||||
|
||||
type MobileSidebarPanelProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function MobileSidebarPanel({ children }: MobileSidebarPanelProps) {
|
||||
// Sits below the header (top-14) and above the tab bar (bottom-16). The sidebar's ScrollArea hardcodes
|
||||
// `h-[calc(100svh-3.5rem)]`; override it to fill this panel so its last item isn't hidden under the tab bar.
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-14 bottom-16 z-40 bg-background [&_[data-slot=scroll-area]]:h-full">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileBuilderTabBarProps = {
|
||||
activeTab: MobileBuilderTab;
|
||||
onTabChange: (tab: MobileBuilderTab) => void;
|
||||
};
|
||||
|
||||
function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) {
|
||||
const labels: Record<MobileBuilderTab, string> = { edit: t`Edit`, preview: t`Preview`, design: t`Design` };
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-popover pb-[env(safe-area-inset-bottom)]">
|
||||
{MOBILE_BUILDER_TABS.map(({ value, icon: Icon }) => {
|
||||
const isActive = value === activeTab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
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",
|
||||
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
|
||||
<span>{labels[value]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileBuilderShell() {
|
||||
// Local state is enough — mobile view mode is shell-scoped and doesn't need to persist.
|
||||
const [tab, setTab] = useState<MobileBuilderTab>("edit");
|
||||
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
|
||||
|
||||
// The preview stays mounted under the Edit/Design overlay; pause its render while it's covered.
|
||||
useEffect(() => {
|
||||
setPreviewPaused(tab !== "preview");
|
||||
return () => setPreviewPaused(false);
|
||||
}, [tab, setPreviewPaused]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<BuilderHeader />
|
||||
|
||||
{/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */}
|
||||
<main id="main-content" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{tab === "edit" && (
|
||||
<MobileSidebarPanel>
|
||||
<BuilderSidebarLeft />
|
||||
</MobileSidebarPanel>
|
||||
)}
|
||||
{tab === "design" && (
|
||||
<MobileSidebarPanel>
|
||||
<BuilderSidebarRight />
|
||||
</MobileSidebarPanel>
|
||||
)}
|
||||
|
||||
<MobileBuilderTabBar activeTab={tab} onTabChange={setTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+9
-7
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -75,14 +76,15 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: {
|
||||
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
|
||||
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: {
|
||||
sections: {
|
||||
education: { title: "Education", columns: 1, hidden: false, items: educationItems },
|
||||
experience: { title: "Experience", columns: 1, hidden: false, items: experienceItems },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
+14
-12
@@ -5,6 +5,7 @@
|
||||
// shape: render a SectionItem per data row with title/subtitle mapped to specific
|
||||
// fields, plus an "Add a new X" button. Test them together to amortize the mock setup.
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -87,19 +88,20 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: {
|
||||
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
|
||||
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
|
||||
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
|
||||
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
|
||||
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
|
||||
references: { title: "References", columns: 1, hidden: false, items: sections.references },
|
||||
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: {
|
||||
sections: {
|
||||
awards: { title: "Awards", columns: 1, hidden: false, items: sections.awards },
|
||||
certifications: { title: "Certifications", columns: 1, hidden: false, items: sections.certifications },
|
||||
interests: { title: "Interests", columns: 1, hidden: false, items: sections.interests },
|
||||
languages: { title: "Languages", columns: 1, hidden: false, items: sections.languages },
|
||||
publications: { title: "Publications", columns: 1, hidden: false, items: sections.publications },
|
||||
references: { title: "References", columns: 1, hidden: false, items: sections.references },
|
||||
volunteer: { title: "Volunteer", columns: 1, hidden: false, items: sections.volunteer },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -41,11 +42,12 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: {
|
||||
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
|
||||
},
|
||||
}),
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: {
|
||||
sections: { profiles: { title: "Profiles", columns: 1, hidden: false, items: sectionItems } },
|
||||
},
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -49,9 +50,10 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
|
||||
}),
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: { sections: { projects: { title: "Projects", columns: 1, hidden: false, items } } },
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Resume } from "@/features/resume/builder/draft";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -25,9 +26,10 @@ type SectionItemProps = {
|
||||
};
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentResume: () => ({
|
||||
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
|
||||
}),
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: Resume) => unknown) =>
|
||||
selector({
|
||||
data: { sections: { skills: { title: "Skills", columns: 1, hidden: false, items: sectionItems } } },
|
||||
} as unknown as Resume),
|
||||
useUpdateResumeData: () => vi.fn(),
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
|
||||
@@ -135,7 +135,9 @@ function CustomStylesSectionForm() {
|
||||
const nextIntent = compactIntent({ ...currentIntent, ...patch });
|
||||
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.styleRules ??= [];
|
||||
// Plain `?? ` assignment (not `??=`) so React Compiler can memoize this component;
|
||||
// the compiler bails on logical-assignment operators today. Behavior is identical.
|
||||
draft.metadata.styleRules = draft.metadata.styleRules ?? [];
|
||||
const rules = draft.metadata.styleRules;
|
||||
const existingIndex = rules.findIndex((rule) => rule.id === ruleId);
|
||||
const existingRule = rules[existingIndex];
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { getLocaleOptions } from "@/features/locale/combobox";
|
||||
import { getLocaleOptions } from "@/features/locale/locale-options";
|
||||
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { ArrowRightIcon, InfoIcon, LightningIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { match } from "ts-pattern";
|
||||
import { Alert, AlertDescription } from "@reactive-resume/ui/components/alert";
|
||||
@@ -51,11 +51,10 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
const resume = useResume();
|
||||
|
||||
const resumeId = resume?.id ?? "";
|
||||
const providersQuery = useQuery(orpc.aiProviders.list.queryOptions());
|
||||
const aiEnabled =
|
||||
providersQuery.data?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
|
||||
const { data: providers } = useQuery(orpc.aiProviders.list.queryOptions());
|
||||
const aiEnabled = providers?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
|
||||
|
||||
const analysisQuery = useQuery({
|
||||
const { data: analysis, isFetched: analysisFetched } = useQuery({
|
||||
...orpc.resume.analysis.getById.queryOptions({ input: { id: resumeId } }),
|
||||
enabled: !!resume,
|
||||
});
|
||||
@@ -88,10 +87,11 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
},
|
||||
});
|
||||
|
||||
const analysis = analysisQuery.data;
|
||||
const score = analysis?.overallScore ?? null;
|
||||
const updatedAt = analysis?.updatedAt ?? null;
|
||||
const [updatedAtLabel, setUpdatedAtLabel] = useState<string | null>(null);
|
||||
// Derived during render (not via state+effect): the analysis comes from a client-fetched query,
|
||||
// so the server render has no date and there's no hydration mismatch to defer around.
|
||||
const updatedAtLabel = updatedAt ? new Date(updatedAt).toLocaleString() : null;
|
||||
const analyzeLabel = isPending ? t`Analyzing…` : t`Analyze Resume`;
|
||||
|
||||
const scoreTone = useMemo(() => {
|
||||
@@ -101,10 +101,6 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
return "bg-rose-600";
|
||||
}, [score]);
|
||||
|
||||
useEffect(() => {
|
||||
setUpdatedAtLabel(updatedAt ? new Date(updatedAt).toLocaleString() : null);
|
||||
}, [updatedAt]);
|
||||
|
||||
const onAnalyze = () => {
|
||||
if (!resume) return;
|
||||
|
||||
@@ -168,7 +164,7 @@ export function ResumeAnalysisSectionBuilder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisQuery.isFetched && !analysis && !isPending && (
|
||||
{analysisFetched && !analysis && !isPending && (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="max-w-xs text-muted-foreground text-sm">
|
||||
<Trans>Run your first analysis to get a scorecard, strengths, and prioritized suggestions.</Trans>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeDelta, getSparklinePoints } from "./statistics";
|
||||
import { computeDelta, getSparklinePoints } from "./statistics.utils";
|
||||
|
||||
describe("computeDelta", () => {
|
||||
it("returns null when the prior period had no activity", () => {
|
||||
|
||||
@@ -8,33 +8,12 @@ import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/compone
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
import { computeDelta, getSparklinePoints } from "./statistics.utils";
|
||||
|
||||
// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days.
|
||||
const TREND_DAYS = 60;
|
||||
const WINDOW = 30;
|
||||
|
||||
// Percent change of the most recent `window` days vs the `window` days before it.
|
||||
// Returns null when the prior period had no activity (division by zero / no baseline).
|
||||
export function computeDelta(series: number[], window: number): number | null {
|
||||
const recent = series.slice(-window);
|
||||
const previous = series.slice(-window * 2, -window);
|
||||
const recentSum = recent.reduce((sum, n) => sum + n, 0);
|
||||
const previousSum = previous.reduce((sum, n) => sum + n, 0);
|
||||
if (previousSum === 0) return null;
|
||||
return Math.round(((recentSum - previousSum) / previousSum) * 100);
|
||||
}
|
||||
|
||||
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
|
||||
// points, or an all-zero series) where there is nothing meaningful to draw.
|
||||
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
|
||||
if (values.length < 2 || values.every((n) => n === 0)) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const step = width / (values.length - 1);
|
||||
return values
|
||||
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function StatisticsSectionBuilder() {
|
||||
const params = useParams({ from: "/builder/$resumeId" });
|
||||
const { data: statistics } = useQuery(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Percent change of the most recent `window` days vs the `window` days before it.
|
||||
// Returns null when the prior period had no activity (division by zero / no baseline).
|
||||
export function computeDelta(series: number[], window: number): number | null {
|
||||
const recent = series.slice(-window);
|
||||
const previous = series.slice(-window * 2, -window);
|
||||
const recentSum = recent.reduce((sum, n) => sum + n, 0);
|
||||
const previousSum = previous.reduce((sum, n) => sum + n, 0);
|
||||
if (previousSum === 0) return null;
|
||||
return Math.round(((recentSum - previousSum) / previousSum) * 100);
|
||||
}
|
||||
|
||||
// Polyline points for the sparkline, or null for degenerate inputs (fewer than two
|
||||
// points, or an all-zero series) where there is nothing meaningful to draw.
|
||||
export function getSparklinePoints(values: number[], width: number, height: number): string | null {
|
||||
if (values.length < 2 || values.every((n) => n === 0)) return null;
|
||||
const max = Math.max(...values, 1);
|
||||
const step = width / (values.length - 1);
|
||||
return values
|
||||
.map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`)
|
||||
.join(" ");
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
InputGroupText,
|
||||
} from "@reactive-resume/ui/components/input-group";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { FontFamilyCombobox, FontWeightCombobox, getNextWeights } from "@/components/typography/combobox";
|
||||
import { FontFamilyCombobox, FontWeightCombobox } from "@/components/typography/combobox";
|
||||
import { getNextWeights } from "@/components/typography/get-next-weights";
|
||||
import { useResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useSyncFormValues } from "@/hooks/use-sync-form-values";
|
||||
import { useAppForm } from "@/libs/tanstack-form";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Layout, usePanelRef } from "react-resizable-panels";
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMediaQuery, useWindowSize } from "usehooks-ts";
|
||||
import { create } from "zustand/react";
|
||||
@@ -146,3 +147,14 @@ export function useBuilderSidebar(): UseBuilderSidebarReturn {
|
||||
};
|
||||
}, [maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior, isCollapsed, toggleSidebar]);
|
||||
}
|
||||
|
||||
export const setBuilderLayout = (data: BuilderLayout) => {
|
||||
const layout = parseBuilderLayoutCookie(JSON.stringify(data));
|
||||
Cookies.set(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(layout), { path: "/" });
|
||||
};
|
||||
|
||||
export const getBuilderLayout = (): BuilderLayout => {
|
||||
const layout = Cookies.get(BUILDER_LAYOUT_COOKIE_NAME);
|
||||
if (!layout) return DEFAULT_BUILDER_LAYOUT;
|
||||
return parseBuilderLayoutCookie(layout);
|
||||
};
|
||||
|
||||
@@ -1,37 +1,14 @@
|
||||
import type { Icon } from "@phosphor-icons/react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Layout } from "react-resizable-panels";
|
||||
import type { BuilderLayout } from "./-store/sidebar";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeIcon, NotePencilIcon, PaletteIcon } from "@phosphor-icons/react";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||
import Cookies from "js-cookie";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePanelRef } from "react-resizable-panels";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import {
|
||||
useBuilderResumeUpdateSubscription,
|
||||
usePreviewPausedStore,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
} from "@/features/resume/builder/draft";
|
||||
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createNoindexFollowMeta } from "@/libs/seo";
|
||||
import { BuilderHeader } from "./-components/header";
|
||||
import { BuilderSidebarLeft } from "./-sidebar/left";
|
||||
import { BuilderSidebarRight } from "./-sidebar/right";
|
||||
import {
|
||||
BUILDER_LAYOUT_COOKIE_NAME,
|
||||
DEFAULT_BUILDER_LAYOUT,
|
||||
mapPanelLayoutToBuilderLayout,
|
||||
parseBuilderLayoutCookie,
|
||||
useBuilderSidebar,
|
||||
useBuilderSidebarStore,
|
||||
} from "./-store/sidebar";
|
||||
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
|
||||
import { MobileBuilderShell } from "./-components/mobile-builder-shell";
|
||||
import { getBuilderLayout } from "./-store/sidebar";
|
||||
|
||||
export const Route = createFileRoute("/builder/$resumeId")({
|
||||
component: RouteComponent,
|
||||
@@ -93,211 +70,10 @@ function RouteComponent() {
|
||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
type BuilderLayoutShellProps = {
|
||||
initialLayout: BuilderLayout;
|
||||
};
|
||||
|
||||
function BuilderLayoutShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
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 });
|
||||
|
||||
if (isMobile) return <MobileBuilderShell />;
|
||||
return <DesktopBuilderShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
function DesktopBuilderShell({ initialLayout }: BuilderLayoutShellProps) {
|
||||
// Only rendered when `BuilderLayoutShell` has already decided we're on desktop, so sidebar sizing is unconditional.
|
||||
const canPersistLayoutRef = useRef(false);
|
||||
|
||||
const leftSidebarRef = usePanelRef();
|
||||
const rightSidebarRef = usePanelRef();
|
||||
|
||||
const setLeftSidebar = useBuilderSidebarStore((state) => state.setLeftSidebar);
|
||||
const setRightSidebar = useBuilderSidebarStore((state) => state.setRightSidebar);
|
||||
const setLayout = useBuilderSidebarStore((state) => state.setLayout);
|
||||
|
||||
const { maxSidebarSize, minSidebarSize, collapsedSidebarSize, groupResizeBehavior } = useBuilderSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
setLayout(initialLayout);
|
||||
canPersistLayoutRef.current = true;
|
||||
}, [initialLayout, setLayout]);
|
||||
|
||||
const onLayoutChanged = (layout: Layout) => {
|
||||
const nextLayout = mapPanelLayoutToBuilderLayout(layout);
|
||||
if (!canPersistLayoutRef.current) return;
|
||||
setLayout(nextLayout);
|
||||
setBuilderLayout(nextLayout);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!leftSidebarRef || !rightSidebarRef) return;
|
||||
|
||||
setLeftSidebar(leftSidebarRef);
|
||||
setRightSidebar(rightSidebarRef);
|
||||
}, [leftSidebarRef, rightSidebarRef, setLeftSidebar, setRightSidebar]);
|
||||
|
||||
const sidebarMinSize = `${minSidebarSize}px`;
|
||||
const sidebarCollapsedSize = `${collapsedSidebarSize}px`;
|
||||
const leftSidebarSize = `${initialLayout.left}%`;
|
||||
const rightSidebarSize = `${initialLayout.right}%`;
|
||||
const artboardSize = `${initialLayout.artboard}%`;
|
||||
|
||||
return (
|
||||
<div className="flex h-svh flex-col">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<BuilderHeader />
|
||||
|
||||
<ResizableGroup orientation="horizontal" className="mt-14 flex-1" onLayoutChanged={onLayoutChanged}>
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="left"
|
||||
panelRef={leftSidebarRef}
|
||||
groupResizeBehavior={groupResizeBehavior}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={leftSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarLeft />
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-s" />
|
||||
<ResizablePanel id="artboard" defaultSize={artboardSize} className="h-[calc(100svh-3.5rem)]">
|
||||
<main id="main-content" className="h-full">
|
||||
<Outlet />
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
<ResizableSeparator withHandle className="z-50 border-e" />
|
||||
<ResizablePanel
|
||||
collapsible
|
||||
id="right"
|
||||
panelRef={rightSidebarRef}
|
||||
groupResizeBehavior={groupResizeBehavior}
|
||||
maxSize={maxSidebarSize}
|
||||
minSize={sidebarMinSize}
|
||||
collapsedSize={sidebarCollapsedSize}
|
||||
defaultSize={rightSidebarSize}
|
||||
className="z-20 h-[calc(100svh-3.5rem)]"
|
||||
>
|
||||
<BuilderSidebarRight />
|
||||
</ResizablePanel>
|
||||
</ResizableGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileBuilderTab = "edit" | "preview" | "design";
|
||||
|
||||
function MobileBuilderShell() {
|
||||
// Local state is enough — mobile view mode is shell-scoped and doesn't need to persist.
|
||||
const [tab, setTab] = useState<MobileBuilderTab>("edit");
|
||||
const setPreviewPaused = usePreviewPausedStore((state) => state.setPaused);
|
||||
|
||||
// The preview stays mounted under the Edit/Design overlay; pause its render while it's covered.
|
||||
useEffect(() => {
|
||||
setPreviewPaused(tab !== "preview");
|
||||
return () => setPreviewPaused(false);
|
||||
}, [tab, setPreviewPaused]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only rounded-md bg-popover px-4 py-2 text-sm ring-2 ring-ring focus:not-sr-only focus:absolute focus:inset-s-2 focus:top-2 focus:z-[100]"
|
||||
>
|
||||
<Trans>Skip to main content</Trans>
|
||||
</a>
|
||||
|
||||
<BuilderHeader />
|
||||
|
||||
{/* The preview (fixed inset-0) stays mounted so zoom/pan state survives tab switches. */}
|
||||
<main id="main-content" className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{tab === "edit" && (
|
||||
<MobileSidebarPanel>
|
||||
<BuilderSidebarLeft />
|
||||
</MobileSidebarPanel>
|
||||
)}
|
||||
{tab === "design" && (
|
||||
<MobileSidebarPanel>
|
||||
<BuilderSidebarRight />
|
||||
</MobileSidebarPanel>
|
||||
)}
|
||||
|
||||
<MobileBuilderTabBar activeTab={tab} onTabChange={setTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type MobileSidebarPanelProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function MobileSidebarPanel({ children }: MobileSidebarPanelProps) {
|
||||
// Sits below the header (top-14) and above the tab bar (bottom-16). The sidebar's ScrollArea hardcodes
|
||||
// `h-[calc(100svh-3.5rem)]`; override it to fill this panel so its last item isn't hidden under the tab bar.
|
||||
return (
|
||||
<div className="fixed inset-x-0 top-14 bottom-16 z-40 bg-background [&_[data-slot=scroll-area]]:h-full">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MOBILE_BUILDER_TABS = [
|
||||
{ value: "edit", icon: NotePencilIcon },
|
||||
{ value: "preview", icon: EyeIcon },
|
||||
{ value: "design", icon: PaletteIcon },
|
||||
] as const satisfies readonly { value: MobileBuilderTab; icon: Icon }[];
|
||||
|
||||
type MobileBuilderTabBarProps = {
|
||||
activeTab: MobileBuilderTab;
|
||||
onTabChange: (tab: MobileBuilderTab) => void;
|
||||
};
|
||||
|
||||
function MobileBuilderTabBar({ activeTab, onTabChange }: MobileBuilderTabBarProps) {
|
||||
const labels: Record<MobileBuilderTab, string> = { edit: t`Edit`, preview: t`Preview`, design: t`Design` };
|
||||
|
||||
return (
|
||||
<nav className="fixed inset-x-0 bottom-0 z-50 flex border-t bg-popover pb-[env(safe-area-inset-bottom)]">
|
||||
{MOBILE_BUILDER_TABS.map(({ value, icon: Icon }) => {
|
||||
const isActive = value === activeTab;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
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",
|
||||
isActive ? "text-primary" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5" weight={isActive ? "fill" : "regular"} />
|
||||
<span>{labels[value]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
const setBuilderLayout = (data: BuilderLayout) => {
|
||||
const layout = parseBuilderLayoutCookie(JSON.stringify(data));
|
||||
Cookies.set(BUILDER_LAYOUT_COOKIE_NAME, JSON.stringify(layout), { path: "/" });
|
||||
};
|
||||
|
||||
const getBuilderLayout = (): BuilderLayout => {
|
||||
const layout = Cookies.get(BUILDER_LAYOUT_COOKIE_NAME);
|
||||
if (!layout) return DEFAULT_BUILDER_LAYOUT;
|
||||
return parseBuilderLayoutCookie(layout);
|
||||
};
|
||||
|
||||
@@ -39,7 +39,11 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) =
|
||||
};
|
||||
|
||||
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
|
||||
const thumbnailQuery = useQuery({
|
||||
const {
|
||||
data: thumbnailData,
|
||||
error: thumbnailError,
|
||||
isError: thumbnailIsError,
|
||||
} = useQuery({
|
||||
queryKey: ["resume-thumbnail", cacheKey],
|
||||
queryFn: ({ signal }) => {
|
||||
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
|
||||
@@ -50,20 +54,20 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (thumbnailQuery.error) console.error("Failed to generate resume thumbnail", thumbnailQuery.error);
|
||||
}, [thumbnailQuery.error]);
|
||||
if (thumbnailError) console.error("Failed to generate resume thumbnail", thumbnailError);
|
||||
}, [thumbnailError]);
|
||||
|
||||
useEffect(() => {
|
||||
const url = thumbnailQuery.data;
|
||||
const url = thumbnailData;
|
||||
|
||||
return () => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [thumbnailQuery.data]);
|
||||
}, [thumbnailData]);
|
||||
|
||||
if (!data || !cacheKey) return { status: "idle" };
|
||||
if (thumbnailQuery.isError) return { status: "error" };
|
||||
if (thumbnailQuery.data) return { status: "ready", url: thumbnailQuery.data };
|
||||
if (thumbnailIsError) return { status: "error" };
|
||||
if (thumbnailData) return { status: "ready", url: thumbnailData };
|
||||
|
||||
return { status: "loading" };
|
||||
}
|
||||
@@ -71,15 +75,15 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
|
||||
const resumeQuery = useQuery({
|
||||
const { data: resumeData, isError: resumeIsError } = useQuery({
|
||||
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
|
||||
enabled: isInView,
|
||||
});
|
||||
const thumbnail = useResumeThumbnail(
|
||||
resumeQuery.data?.data,
|
||||
resumeData?.data,
|
||||
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
|
||||
);
|
||||
const hasFailed = resumeQuery.isError || thumbnail.status === "error";
|
||||
const hasFailed = resumeIsError || thumbnail.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user