mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-26 08:12:20 +10:00
v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type z from "zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
@@ -221,6 +222,7 @@ function QuickColorCircle({ color, active, onSelect, className, ...props }: Quic
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t`Use color ${color}`}
|
||||
onClick={() => onSelect(color)}
|
||||
className={cn(
|
||||
"relative flex size-8 items-center justify-center rounded-md bg-transparent",
|
||||
|
||||
@@ -1,58 +1,13 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, FileDocIcon, FileJsIcon, FilePdfIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { buildDocx } from "@reactive-resume/docx";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { useResume } from "@/features/resume/builder/draft";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { useResumeExport } from "@/features/resume/export/use-resume-export";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function ExportSectionBuilder() {
|
||||
const resumeData = useResume();
|
||||
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
const resume = resumeData;
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "json");
|
||||
const jsonString = JSON.stringify(resume.data, null, 2);
|
||||
const blob = new Blob([jsonString], { type: "application/json" });
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadDOCX = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "docx");
|
||||
|
||||
try {
|
||||
const blob = await buildDocx(resume.data);
|
||||
downloadWithAnchor(blob, filename);
|
||||
} catch {
|
||||
toast.error(t`There was a problem while generating the DOCX, please try again.`);
|
||||
}
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadPDF = useCallback(async () => {
|
||||
if (!resume) return;
|
||||
const filename = generateFilename(resume.name, "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 resume = useResume();
|
||||
const { onDownloadJSON, onDownloadDOCX, onDownloadPDF, isExporting } = useResumeExport(resume);
|
||||
|
||||
if (!resume) return null;
|
||||
|
||||
@@ -94,11 +49,11 @@ export function ExportSectionBuilder() {
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isPrinting}
|
||||
disabled={isExporting}
|
||||
onClick={onDownloadPDF}
|
||||
className="h-auto gap-x-4 whitespace-normal p-4! text-start font-normal active:scale-98"
|
||||
>
|
||||
{isPrinting ? (
|
||||
{isExporting ? (
|
||||
<CircleNotchIcon className="size-6 shrink-0 animate-spin" />
|
||||
) : (
|
||||
<FilePdfIcon className="size-6 shrink-0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { CSSProperties, HTMLAttributes, Ref } from "react";
|
||||
import {
|
||||
closestCorners,
|
||||
@@ -13,10 +14,29 @@ import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy }
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { DotsSixVerticalIcon, PlusIcon, TrashIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
ArrowBendUpRightIcon,
|
||||
DotsSixVerticalIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
FileIcon,
|
||||
PlusCircleIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useCallback, useId, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { Switch } from "@reactive-resume/ui/components/switch";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
@@ -385,24 +405,202 @@ type SortableLayoutItemProps = {
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
function SortableLayoutItem({ id }: SortableLayoutItemProps) {
|
||||
function SortableLayoutItem({ id, pageIndex, columnId }: SortableLayoutItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
|
||||
|
||||
return (
|
||||
<LayoutItemContent ref={setNodeRef} id={id} style={style} isDragging={isDragging} {...attributes} {...listeners} />
|
||||
<LayoutItemContent
|
||||
ref={setNodeRef}
|
||||
id={id}
|
||||
pageIndex={pageIndex}
|
||||
columnId={columnId}
|
||||
style={style}
|
||||
isDragging={isDragging}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type MoveToSubmenuProps = {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
columnId: ColumnId;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Move to" submenu that mirrors the left-panel item menu but works at the
|
||||
* section level: it splices the section out of its current page/column and
|
||||
* pushes it onto the chosen target (or a brand new page).
|
||||
*/
|
||||
function MoveToSubmenu({ id, pageIndex, columnId }: MoveToSubmenuProps) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const pages = resume.data.metadata.layout.pages;
|
||||
// When the template collapses the sidebar, no page has a usable sidebar column.
|
||||
const sidebarCollapsed = templates[resume.data.metadata.template].sidebarPosition === "none";
|
||||
|
||||
const moveTo = (targetPageIndex: number, targetColumnId: ColumnId) => {
|
||||
updateResumeData((draft) => {
|
||||
const from = draft.metadata.layout.pages[pageIndex][columnId];
|
||||
const index = from.indexOf(id);
|
||||
if (index === -1) return;
|
||||
from.splice(index, 1);
|
||||
draft.metadata.layout.pages[targetPageIndex][targetColumnId].push(id);
|
||||
});
|
||||
};
|
||||
|
||||
const moveToNewPage = () => {
|
||||
updateResumeData((draft) => {
|
||||
const from = draft.metadata.layout.pages[pageIndex][columnId];
|
||||
const index = from.indexOf(id);
|
||||
if (index === -1) return;
|
||||
from.splice(index, 1);
|
||||
draft.metadata.layout.pages.push({ fullWidth: false, main: [id], sidebar: [] });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<ArrowBendUpRightIcon />
|
||||
<Trans>Move to</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
{pages.map((page, targetPageIndex) => {
|
||||
// Full-width pages hide their sidebar, so never offer it as a target.
|
||||
const sidebarHidden = sidebarCollapsed || page.fullWidth;
|
||||
|
||||
return (
|
||||
<DropdownMenuSub key={`page-${targetPageIndex}`}>
|
||||
<DropdownMenuSubTrigger>
|
||||
<FileIcon />
|
||||
<Trans>Page {targetPageIndex + 1}</Trans>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
disabled={targetPageIndex === pageIndex && columnId === "main"}
|
||||
onClick={() => moveTo(targetPageIndex, "main")}
|
||||
>
|
||||
{getColumnLabel("main")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!sidebarHidden && (
|
||||
<DropdownMenuItem
|
||||
disabled={targetPageIndex === pageIndex && columnId === "sidebar"}
|
||||
onClick={() => moveTo(targetPageIndex, "sidebar")}
|
||||
>
|
||||
{getColumnLabel("sidebar")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={moveToNewPage}>
|
||||
<PlusCircleIcon />
|
||||
<Trans>New Page</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
type SectionBreakField = "keepTogether" | "startOnNewPage";
|
||||
|
||||
const readSectionBreak = (data: ResumeData, id: string, field: SectionBreakField): boolean => {
|
||||
if (id === "summary") return data.summary[field];
|
||||
if (id in data.sections) return data.sections[id as SectionType][field];
|
||||
return data.customSections.find((section) => section.id === id)?.[field] ?? false;
|
||||
};
|
||||
|
||||
type SectionBreakItemsProps = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-section page-break controls. These write the declarative `keepTogether` /
|
||||
* `startOnNewPage` flags onto the section metadata (summary, standard, or custom),
|
||||
* which the PDF renderer applies as `wrap` / `break` on the section container.
|
||||
*/
|
||||
function SectionBreakItems({ id }: SectionBreakItemsProps) {
|
||||
const resume = useCurrentResume();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
|
||||
const keepTogether = readSectionBreak(resume.data, id, "keepTogether");
|
||||
const startOnNewPage = readSectionBreak(resume.data, id, "startOnNewPage");
|
||||
|
||||
const toggle = (field: SectionBreakField) => {
|
||||
updateResumeData((draft) => {
|
||||
if (id === "summary") {
|
||||
draft.summary[field] = !draft.summary[field];
|
||||
return;
|
||||
}
|
||||
if (id in draft.sections) {
|
||||
const section = draft.sections[id as SectionType];
|
||||
section[field] = !section[field];
|
||||
return;
|
||||
}
|
||||
const custom = draft.customSections.find((section) => section.id === id);
|
||||
if (custom) custom[field] = !custom[field];
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={keepTogether}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={() => toggle("keepTogether")}
|
||||
>
|
||||
<Trans comment="Layout editor toggle that prevents a section from splitting across pages">Keep together</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<p className="px-2 pb-1 text-muted-foreground text-xs">
|
||||
<Trans comment="Helper note explaining the keep-together limitation">
|
||||
Only applies when the section fits on a single page.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={startOnNewPage}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={() => toggle("startOnNewPage")}
|
||||
>
|
||||
<Trans comment="Layout editor toggle that forces a section to begin on a new page">Start on new page</Trans>
|
||||
</DropdownMenuCheckboxItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LayoutItemContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
id: string;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
pageIndex?: number;
|
||||
columnId?: ColumnId;
|
||||
isDragging?: boolean;
|
||||
isOverlay?: boolean;
|
||||
};
|
||||
|
||||
function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, ...rest }: LayoutItemContentProps) {
|
||||
function LayoutItemContent({
|
||||
id,
|
||||
ref,
|
||||
pageIndex,
|
||||
columnId,
|
||||
isDragging,
|
||||
isOverlay,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
}: LayoutItemContentProps) {
|
||||
const resume = useCurrentResume();
|
||||
const title = resume ? resolveLayoutSectionTitle(resume.data, id) : id;
|
||||
|
||||
@@ -422,7 +620,26 @@ function LayoutItemContent({ id, ref, isDragging, isOverlay, className, style, .
|
||||
{...rest}
|
||||
>
|
||||
<DotsSixVerticalIcon className="opacity-40 transition-opacity group-hover/item:opacity-100" />
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{title}</span>
|
||||
|
||||
{/* The drag overlay renders without a location; only real rows get the menu. */}
|
||||
{!isOverlay && pageIndex !== undefined && columnId !== undefined && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t`Move section to another column or page`}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
className="flex cursor-context-menu items-center rounded p-0.5 opacity-40 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover/item:opacity-100"
|
||||
>
|
||||
<DotsThreeVerticalIcon />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<MoveToSubmenu id={id} pageIndex={pageIndex} columnId={columnId} />
|
||||
<DropdownMenuSeparator />
|
||||
<SectionBreakItems id={id} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ export function SharingSectionBuilder() {
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Input readOnly id="sharing-url" value={publicUrl} />
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={onCopyUrl}>
|
||||
<Button size="icon" variant="ghost" aria-label={t`Copy URL`} onClick={onCopyUrl}>
|
||||
<ClipboardIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeDelta, getSparklinePoints } from "./statistics";
|
||||
|
||||
describe("computeDelta", () => {
|
||||
it("returns null when the prior period had no activity", () => {
|
||||
// Prior 2 days are all zero -> no baseline to compare against.
|
||||
expect(computeDelta([0, 0, 5, 5], 2)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a positive percentage when the recent period grew", () => {
|
||||
// previous sum = 2, recent sum = 4 -> +100%
|
||||
expect(computeDelta([1, 1, 2, 2], 2)).toBe(100);
|
||||
});
|
||||
|
||||
it("returns a negative percentage when the recent period shrank", () => {
|
||||
// previous sum = 4, recent sum = 2 -> -50%
|
||||
expect(computeDelta([2, 2, 1, 1], 2)).toBe(-50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSparklinePoints", () => {
|
||||
it("returns null for a single point", () => {
|
||||
expect(getSparklinePoints([5], 80, 24)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an all-zero series", () => {
|
||||
expect(getSparklinePoints([0, 0, 0], 80, 24)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns points for all-equal non-zero values (flat line at the top)", () => {
|
||||
// max === value, so every y is 0; x spans 0..width.
|
||||
expect(getSparklinePoints([3, 3, 3], 80, 24)).toBe("0.0,0.0 40.0,0.0 80.0,0.0");
|
||||
});
|
||||
});
|
||||
@@ -17,18 +17,29 @@ const queryResult = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const dailyResult = vi.hoisted(() => ({
|
||||
data: undefined as undefined | { date: string; views: number; downloads: number }[],
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => queryResult,
|
||||
useQuery: (options: { __key?: string }) => (options.__key === "daily" ? dailyResult : queryResult),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => ({ resumeId: "r1" }),
|
||||
}));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: { resume: { statistics: { getById: { queryOptions: () => ({}) } } } },
|
||||
orpc: {
|
||||
resume: {
|
||||
statistics: {
|
||||
getById: { queryOptions: () => ({ __key: "getById" }) },
|
||||
getDailyById: { queryOptions: () => ({ __key: "daily" }) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div>{children}</div>,
|
||||
@@ -42,6 +53,7 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
queryResult.data = undefined;
|
||||
dailyResult.data = undefined;
|
||||
});
|
||||
|
||||
const renderStats = () =>
|
||||
@@ -84,6 +96,24 @@ describe("StatisticsSectionBuilder", () => {
|
||||
expect(screen.getByText("Downloads")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a prior-period delta from the daily series", () => {
|
||||
queryResult.data = {
|
||||
isPublic: true,
|
||||
views: 30,
|
||||
downloads: 0,
|
||||
lastViewedAt: null,
|
||||
lastDownloadedAt: null,
|
||||
};
|
||||
// 60 days: prior 30 sum to 10, recent 30 sum to 20 -> +100%.
|
||||
dailyResult.data = Array.from({ length: 60 }, (_, i) => ({
|
||||
date: `2024-01-${String(i + 1).padStart(2, "0")}`,
|
||||
views: i < 30 ? (i < 10 ? 1 : 0) : i < 50 ? 1 : 0,
|
||||
downloads: 0,
|
||||
}));
|
||||
renderStats();
|
||||
expect(screen.getByText(/\+100%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders 'last viewed/downloaded' timestamps when present", () => {
|
||||
queryResult.data = {
|
||||
isPublic: true,
|
||||
|
||||
@@ -5,17 +5,53 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
// 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(
|
||||
orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }),
|
||||
);
|
||||
const { data: daily } = useQuery(
|
||||
orpc.resume.statistics.getDailyById.queryOptions({
|
||||
input: { id: params.resumeId, days: TREND_DAYS },
|
||||
enabled: Boolean(statistics?.isPublic),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!statistics) return null;
|
||||
|
||||
const viewsSeries = daily?.map((day) => day.views) ?? [];
|
||||
const downloadsSeries = daily?.map((day) => day.downloads) ?? [];
|
||||
|
||||
return (
|
||||
<SectionBase type="statistics">
|
||||
<Accordion value={statistics.isPublic ? ["isPublic"] : ["isPrivate"]}>
|
||||
@@ -41,12 +77,14 @@ export function StatisticsSectionBuilder() {
|
||||
<StatisticsItem
|
||||
label={t`Views`}
|
||||
value={statistics.views}
|
||||
series={viewsSeries}
|
||||
timestamp={statistics.lastViewedAt ? t`Last viewed on ${statistics.lastViewedAt.toDateString()}` : null}
|
||||
/>
|
||||
|
||||
<StatisticsItem
|
||||
label={t`Downloads`}
|
||||
value={statistics.downloads}
|
||||
series={downloadsSeries}
|
||||
timestamp={
|
||||
statistics.lastDownloadedAt ? t`Last downloaded on ${statistics.lastDownloadedAt.toDateString()}` : null
|
||||
}
|
||||
@@ -61,15 +99,59 @@ export function StatisticsSectionBuilder() {
|
||||
type StatisticsItemProps = {
|
||||
label: string;
|
||||
value: number;
|
||||
series: number[];
|
||||
timestamp: string | null;
|
||||
};
|
||||
|
||||
function StatisticsItem({ label, value, timestamp }: StatisticsItemProps) {
|
||||
function StatisticsItem({ label, value, series, timestamp }: StatisticsItemProps) {
|
||||
const recent = series.slice(-WINDOW);
|
||||
const delta = computeDelta(series, WINDOW);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="mb-1 font-mono font-semibold text-4xl">{value}</h4>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<h4 className="font-mono font-semibold text-4xl">{value}</h4>
|
||||
<Sparkline title={t`${label} over the last 30 days`} values={recent} />
|
||||
</div>
|
||||
<p className="font-medium text-muted-foreground leading-none">{label}</p>
|
||||
{timestamp && <span className="text-muted-foreground text-xs">{timestamp}</span>}
|
||||
{delta === null ? (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<Trans>No prior data</Trans>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("text-xs", delta >= 0 ? "text-emerald-600 dark:text-emerald-500" : "text-red-600")}>
|
||||
{`${delta >= 0 ? "+" : ""}${delta}% `}
|
||||
<Trans>vs previous 30 days</Trans>
|
||||
</span>
|
||||
)}
|
||||
{timestamp && <span className="block text-muted-foreground text-xs">{timestamp}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SparklineProps = {
|
||||
title: string;
|
||||
values: number[];
|
||||
};
|
||||
|
||||
function Sparkline({ title, values }: SparklineProps) {
|
||||
const width = 80;
|
||||
const height = 24;
|
||||
const points = getSparklinePoints(values, width, height);
|
||||
|
||||
if (!points) return null;
|
||||
|
||||
return (
|
||||
<svg className="text-primary" height={height} role="img" viewBox={`0 0 ${width} ${height}`} width={width}>
|
||||
<title>{title}</title>
|
||||
<polyline
|
||||
fill="none"
|
||||
points={points}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { SwapIcon } from "@phosphor-icons/react";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@reactive-resume/ui/components/hover-card";
|
||||
import { templates } from "@/dialogs/resume/template/data";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useCurrentResume } from "@/features/resume/builder/draft";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
// Lazy so the browser PDF pipeline (pdf.js) loads only when a preview card actually opens, keeping it out
|
||||
// of the SSR/module graph — mirrors the `ResumePreview` entry convention.
|
||||
const TemplateLivePreview = lazy(() =>
|
||||
import("@/features/resume/preview/template-live-preview").then((module) => ({
|
||||
default: module.TemplateLivePreview,
|
||||
})),
|
||||
);
|
||||
|
||||
export function TemplateSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="template">
|
||||
@@ -29,19 +40,43 @@ function TemplateSectionForm() {
|
||||
|
||||
return (
|
||||
<div className="flex @md:flex-row flex-col items-stretch gap-x-4 gap-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger
|
||||
delay={300}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onOpenTemplateGallery}
|
||||
className="group/preview relative h-auto w-40 shrink-0 cursor-pointer p-0"
|
||||
>
|
||||
<div className="relative z-10 aspect-page size-full overflow-hidden rounded-md opacity-100 transition-opacity group-hover/preview:opacity-50">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<SwapIcon size={48} weight="thin" className="size-12" />
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<HoverCardContent side="right" align="start" className="w-64 p-1.5">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="aspect-page w-full overflow-hidden rounded-md bg-white">
|
||||
<img src={metadata.imageUrl} alt={metadata.name} className="size-full object-contain" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TemplateLivePreview
|
||||
data={resume.data}
|
||||
template={template}
|
||||
fallbackSrc={metadata.imageUrl}
|
||||
alt={t`Live preview of your resume in the ${metadata.name} template`}
|
||||
/>
|
||||
</Suspense>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-y-4 @md:pt-1 @md:pb-3">
|
||||
<div className="space-y-1">
|
||||
|
||||
Reference in New Issue
Block a user