Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"framer-motion": "^11.11.10",
"html2canvas": "^1.4.1",
"html2pdf.js": "^0.10.2",
"jspdf": "2.5.2",
"lodash": "^4.17.21",
"lucide-react": "^0.379.0",
"mark.js": "^8.11.1",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 36 additions & 1 deletion src/components/preview/PreviewDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
FileJson,
Loader2,
Eye,
FileText
FileText,
EyeOff
} from "lucide-react";
import { RiMarkdownLine } from "@remixicon/react";
import { toast } from "sonner";
Expand Down Expand Up @@ -106,6 +107,7 @@ const PreviewDock = ({

const { duplicateResume, setActiveResume, activeResumeId, activeResume, updateGlobalSettings } = useResumeStore();
const { globalSettings = {} } = activeResume || {};
const pageBreakLinesVisible = globalSettings?.pageBreakLinesVisible !== false;

const { checkConfiguration } = useAIConfiguration();

Expand Down Expand Up @@ -239,6 +241,39 @@ const PreviewDock = ({
</TooltipContent>
</Tooltip>
</DockIcon>
<DockIcon>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"flex cursor-pointer h-7 w-7 items-center justify-center rounded-lg",
"hover:bg-gray-100/50 dark:hover:bg-neutral-800/50",
"transition-all duration-200",
!pageBreakLinesVisible && [
"bg-primary text-primary-foreground",
"hover:bg-primary/90 dark:hover:bg-primary/90",
"shadow-sm"
]
)}
onClick={() => {
updateGlobalSettings({
pageBreakLinesVisible: !pageBreakLinesVisible
});
toast.success(
pageBreakLinesVisible
? t("pageBreakLine.hidden")
: t("pageBreakLine.visible")
);
}}
>
<EyeOff className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={10}>
<p>{t("pageBreakLine.tooltip")}</p>
</TooltipContent>
</Tooltip>
</DockIcon>
<DockIcon>
<Tooltip>
<PdfExport>
Expand Down
4 changes: 3 additions & 1 deletion src/components/preview/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ const PreviewPanel = React.forwardRef<HTMLDivElement, PreviewPanelProps>(

const pagePadding = activeResume?.globalSettings?.pagePadding || 0;
const autoOnePageEnabled = activeResume?.globalSettings?.autoOnePage || false;
const pageBreakLinesVisible =
activeResume?.globalSettings?.pageBreakLinesVisible !== false;

const { scaleFactor, isScaled, cannotFit } = useAutoOnePage({
contentHeight,
Expand Down Expand Up @@ -271,7 +273,7 @@ const PreviewPanel = React.forwardRef<HTMLDivElement, PreviewPanelProps>(
}
`}</style>
<ResumeTemplateComponent data={activeResume} template={template} />
{contentHeight > 0 && (
{pageBreakLinesVisible && contentHeight > 0 && (
<>
<div key={`page-breaks-container-${contentHeight}`}>
{Array.from(
Expand Down
25 changes: 25 additions & 0 deletions src/components/shared/GlassIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,28 @@ export const MarkdownGlassIcon = ({ className, isLoading }: GlassIconProps) => (
</path>
</svg>
);

export const ImageGlassIcon = ({ className, isLoading }: GlassIconProps) => (
<svg viewBox="0 0 100 100" fill="none" className={className}>
<defs>
<filter id="img-glow"><feGaussianBlur stdDeviation="8" /></filter>
<linearGradient id="img-glass" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.68" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.08" />
</linearGradient>
<linearGradient id="img-border" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.9" />
<stop offset="100%" stopColor="#ffffff" stopOpacity="0.18" />
</linearGradient>
</defs>
<rect x="20" y="18" width="56" height="64" rx="10" fill="#0f766e" filter="url(#img-glow)" opacity="0.62" />
<rect x="18" y="16" width="60" height="68" rx="12" fill="#0f766e" />
<rect x="12" y="10" width="68" height="74" rx="14" fill="url(#img-glass)" stroke="url(#img-border)" strokeWidth="1.5" />
<circle cx="35" cy="31" r="5" fill="#ffffff" opacity="0.9" />
<path d="M22 67 L38 49 L48 59 L59 45 L74 67" stroke="#ffffff" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" opacity="0.95" />
<path d="M23 70H75" stroke="#ffffff" strokeWidth="3" strokeLinecap="round" opacity="0.28" />
<rect x="26" y="73" width={isLoading ? "18" : "24"} height="4" rx="2" fill="#ffffff" opacity="0.72">
{isLoading && <animate attributeName="width" values="14;26;14" dur="1.2s" repeatCount="indefinite" />}
</rect>
</svg>
);
90 changes: 79 additions & 11 deletions src/components/shared/PdfExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { useTranslations } from "@/i18n/compat/client";
import { Download, Loader2, ChevronDown, ShieldCheck } from "lucide-react";
import { useResumeStore } from "@/store/useResumeStore";
import { Button } from "@/components/ui/button";
import { exportResumeAsJson, exportResumeAsMarkdown, exportToPdf } from "@/utils/export";
import {
exportResumeAsJson,
exportResumeAsMarkdown,
exportToLongPageImage,
exportToLongPagePdf,
exportToPdf
} from "@/utils/export";
import { exportResumeToBrowserPrint } from "@/utils/print";
import { cn } from "@/lib/utils";
import {
Expand All @@ -17,6 +23,7 @@ import {

import {
PdfGlassIcon,
ImageGlassIcon,
PrintGlassIcon,
JsonGlassIcon,
MarkdownGlassIcon,
Expand All @@ -29,6 +36,7 @@ const ExportCard = ({
description,
onClick,
isLoading,
isDisabled,
bgGradientClass,
hoverBorderClass,
}: {
Expand All @@ -37,17 +45,20 @@ const ExportCard = ({
description: string,
onClick: () => void,
isLoading: boolean,
isDisabled?: boolean,
bgGradientClass?: string,
hoverBorderClass?: string,
}) => {
const disabled = isLoading || isDisabled;

return (
<div
onClick={() => {
if (!isLoading) onClick();
if (!disabled) onClick();
}}
className={cn(
"group relative flex flex-col justify-center overflow-hidden p-6 pl-[136px] min-h-[130px] rounded-3xl border border-border/50 bg-card text-card-foreground shadow-sm transition-all duration-500",
isLoading ? "opacity-70 cursor-not-allowed" : cn("cursor-pointer hover:shadow-2xl active:scale-[0.98] hover:-translate-y-1 hover:bg-muted/10", hoverBorderClass)
disabled ? "opacity-70 cursor-not-allowed" : cn("cursor-pointer hover:shadow-2xl active:scale-[0.98] hover:-translate-y-1 hover:bg-muted/10", hoverBorderClass)
)}
>
{/* 顶部内发光高光,增加卡片立体感 */}
Expand All @@ -56,14 +67,14 @@ const ExportCard = ({
{/* 底部环境光晕,让图标色渗入背景 */}
<div className={cn(
"absolute inset-0 pointer-events-none transition-opacity duration-500 bg-gradient-to-r to-transparent",
isLoading ? "opacity-50" : "opacity-0 group-hover:opacity-100",
disabled ? "opacity-50" : "opacity-0 group-hover:opacity-100",
bgGradientClass
)} />

{/* 调整后的图标层:完全收入卡片内部,尺寸克制 */}
<div className={cn(
"absolute left-6 top-1/2 -translate-y-1/2 w-24 h-24 transition-transform duration-500 will-change-transform drop-shadow-xl pointer-events-none",
isLoading ? "-rotate-6 scale-[1.05] opacity-80" : "-rotate-6 group-hover:scale-[1.1] group-hover:-rotate-3"
disabled ? "-rotate-6 scale-[1.05] opacity-80" : "-rotate-6 group-hover:scale-[1.1] group-hover:-rotate-3"
)}>
<Icon className="w-full h-full object-contain" isLoading={isLoading} />
</div>
Expand All @@ -82,6 +93,8 @@ const ExportCard = ({
const PdfExport = ({ children }: { children?: React.ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isExportingLongPage, setIsExportingLongPage] = useState(false);
const [isExportingImage, setIsExportingImage] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
const [isExportingJson, setIsExportingJson] = useState(false);
const [isExportingMarkdown, setIsExportingMarkdown] = useState(false);
Expand All @@ -103,6 +116,32 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => {
});
};

const handleLongPageExport = async () => {
await exportToLongPagePdf({
elementId: "resume-preview",
title: title || "resume",
pagePadding: globalSettings?.pagePadding || 0,
fontFamily: globalSettings?.fontFamily,
onStart: () => setIsExportingLongPage(true),
onEnd: () => setIsExportingLongPage(false),
successMessage: t("toast.success"),
errorMessage: t("toast.error")
});
};

const handleLongPageImageExport = async () => {
await exportToLongPageImage({
elementId: "resume-preview",
title: title || "resume",
pagePadding: globalSettings?.pagePadding || 0,
fontFamily: globalSettings?.fontFamily,
onStart: () => setIsExportingImage(true),
onEnd: () => setIsExportingImage(false),
successMessage: t("toast.imageSuccess"),
errorMessage: t("toast.imageError")
});
};

const handleJsonExport = () => {
exportResumeAsJson({
resume: activeResume,
Expand Down Expand Up @@ -156,16 +195,21 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => {
}
};

const isLoading = isExporting || isExportingJson || isExportingMarkdown || isPrinting;
const loadingText = isExporting
? t("button.exporting")
const isLoading =
isExporting ||
isExportingLongPage ||
isExportingImage ||
isExportingJson ||
isExportingMarkdown ||
isPrinting;
const loadingText =
isExporting || isExportingLongPage || isExportingImage || isPrinting
? t("button.exporting")
: isExportingJson
? t("button.exportingJson")
: isExportingMarkdown
? t("button.exportingMarkdown")
: isPrinting
? t("button.exporting")
: "";
: "";

return (
<Dialog open={isOpen} onOpenChange={(val) => {
Expand Down Expand Up @@ -218,15 +262,37 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => {
title={t("button.exportPdf")}
description={t("modal.pdfDesc")}
isLoading={isExporting}
isDisabled={isLoading}
onClick={handleExport}
bgGradientClass="from-rose-500/10 dark:from-rose-500/20"
hoverBorderClass="hover:border-rose-500/40 hover:ring-1 hover:ring-rose-500/20"
/>
<ExportCard
icon={PdfGlassIcon}
title={t("button.exportLongPagePdf")}
description={t("modal.longPagePdfDesc")}
isLoading={isExportingLongPage}
isDisabled={isLoading}
onClick={handleLongPageExport}
bgGradientClass="from-violet-500/10 dark:from-violet-500/20"
hoverBorderClass="hover:border-violet-500/40 hover:ring-1 hover:ring-violet-500/20"
/>
<ExportCard
icon={ImageGlassIcon}
title={t("button.exportLongPageImage")}
description={t("modal.longPageImageDesc")}
isLoading={isExportingImage}
isDisabled={isLoading}
onClick={handleLongPageImageExport}
bgGradientClass="from-teal-500/10 dark:from-teal-500/20"
hoverBorderClass="hover:border-teal-500/40 hover:ring-1 hover:ring-teal-500/20"
/>
<ExportCard
icon={PrintGlassIcon}
title={t("button.print")}
description={t("modal.printDesc")}
isLoading={isPrinting}
isDisabled={isLoading}
onClick={handlePrint}
bgGradientClass="from-sky-500/10 dark:from-sky-500/20"
hoverBorderClass="hover:border-sky-500/40 hover:ring-1 hover:ring-sky-500/20"
Expand All @@ -236,6 +302,7 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => {
title={t("button.exportJson")}
description={t("modal.jsonDesc")}
isLoading={isExportingJson}
isDisabled={isLoading}
onClick={handleJsonExport}
bgGradientClass="from-amber-500/10 dark:from-amber-500/20"
hoverBorderClass="hover:border-amber-500/40 hover:ring-1 hover:ring-amber-500/20"
Expand All @@ -245,6 +312,7 @@ const PdfExport = ({ children }: { children?: React.ReactNode }) => {
title={t("button.exportMarkdown")}
description={t("modal.markdownDesc")}
isLoading={isExportingMarkdown}
isDisabled={isLoading}
onClick={handleMarkdownExport}
bgGradientClass="from-indigo-500/10 dark:from-indigo-500/20"
hoverBorderClass="hover:border-indigo-500/40 hover:ring-1 hover:ring-indigo-500/20"
Expand Down
1 change: 1 addition & 0 deletions src/config/initialResumeData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const initialGlobalSettings: GlobalSettings = {
useIconMode: true,
themeColor: "#000000",
centerSubtitle: true,
pageBreakLinesVisible: true,
};

export const initialResumeState = {
Expand Down
13 changes: 12 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
},
{
"question": "What export formats are supported?",
"answer": "We currently support PDF export, ensuring your resume maintains consistent formatting on any device. We plan to support more export formats in the future."
"answer": "We currently support PDF export, PNG long image export, browser print, JSON config export, and Markdown export. PDF is best for formal applications and archiving, while PNG long image is useful for mobile previews, portfolio display, or image-upload workflows."
},
{
"question": "How can I sync across devices?",
Expand Down Expand Up @@ -258,6 +258,8 @@
"title": "Export Resume",
"subtitle": "Select the format you want to export your resume",
"pdfDesc": "High-precision rendering with 100% format accuracy. Recommended for job applications.",
"longPagePdfDesc": "Browser-rendered as a single long page. Best for resumes that do not need pagination.",
"longPageImageDesc": "Browser-rendered as a single PNG long image. Best for mobile previews, portfolio display, or image-upload workflows.",
"printDesc": "Save via system print dialog. Use as a fallback or for custom margins.",
"jsonDesc": "Export your resume data as JSON to backup or transfer between devices.",
"markdownDesc": "Convert to plain Markdown text for easy sharing with AI models.",
Expand All @@ -266,6 +268,8 @@
"button": {
"export": "Export",
"exportPdf": "Export PDF (Server)",
"exportLongPagePdf": "PDF (Long Page)",
"exportLongPageImage": "PNG (Long Image)",
"exportJson": "Export JSON Config",
"exportMarkdown": "Export Markdown",
"exporting": "Exporting...",
Expand All @@ -276,6 +280,8 @@
"toast": {
"success": "PDF exported successfully",
"error": "PDF export failed",
"imageSuccess": "PNG long image exported successfully",
"imageError": "PNG long image export failed",
"jsonSuccess": "Configuration exported successfully",
"jsonError": "Configuration export failed",
"markdownSuccess": "Markdown exported successfully",
Expand Down Expand Up @@ -323,6 +329,11 @@
"disabled": "One page mode disabled",
"cannotFit": "Too much content. Optimized as much as possible but cannot fit on one page. Try simplifying the content or adjusting margins and font size in the sidebar."
},
"pageBreakLine": {
"tooltip": "Page Break Line",
"visible": "Page break line shown",
"hidden": "Page break line hidden"
},
"backup": {
"configured": "Backed up",
"notConfigured": "Not backed up",
Expand Down
Loading