feat: Implement dynamic template previews with i18n support and locale-aware routing

This commit is contained in:
JOYCEQL
2026-02-26 01:02:25 +08:00
parent 793aca3f1c
commit 77a5e670ed
9 changed files with 488 additions and 178 deletions
+14 -6
View File
@@ -2,17 +2,25 @@
"css.validate": false,
"less.validate": false,
"scss.validate": false,
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
"stylelint.validate": ["css", "less", "postcss", "scss", "sass"],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"stylelint.validate": [
"css",
"less",
"postcss",
"scss",
"sass"
],
"typescript.tsdk": "./node_modules/typescript/lib",
"search.exclude": {
"**/node_modules": true,
"dist": true,
"build": true
},
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
@@ -34,7 +42,7 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
+13 -14
View File
@@ -22,7 +22,7 @@ import {
TooltipTrigger
} from "@/components/ui/tooltip";
import Logo from "@/components/shared/Logo";
import { useTranslations } from "@/i18n/compat/client";
import { useLocale, useTranslations } from "@/i18n/compat/client";
interface MenuItem {
title: string;
@@ -60,6 +60,7 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname();
const locale = useLocale();
const [open, setOpen] = useState(true);
const [collapsible, setCollapsible] = useState<"offcanvas" | "icon" | "none">(
"icon"
@@ -94,11 +95,11 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
className="border-r border-border/40 bg-card/50 backdrop-blur-xl"
>
<SidebarHeader className="h-16 flex items-center justify-center border-b border-border/40">
<div className="w-full justify-center flex items-center">
<div className="w-full cursor-pointer justify-center flex items-center" onClick={() => router.push(`/${locale}`)}
>
<Logo
className="cursor-pointer hover:opacity-80 transition-opacity"
className=" hover:opacity-80 transition-opacity"
size={48}
onClick={() => router.push("/")}
/>
{open && (
<span className="font-bold text-lg tracking-tight">
@@ -121,11 +122,10 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
<SidebarMenuButton
asChild
isActive={active}
className={`w-full transition-all duration-200 ease-in-out h-12 mb-1 [&>svg]:size-auto ${
active
? "bg-primary/10 text-primary font-bold hover:bg-primary/20 hover:text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
className={`w-full transition-all duration-200 ease-in-out h-12 mb-1 [&>svg]:size-auto ${active
? "bg-primary/10 text-primary font-bold hover:bg-primary/20 hover:text-primary"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<div
className="flex items-center gap-2 px-2 cursor-pointer"
@@ -147,11 +147,10 @@ const DashboardLayout = ({ children }: { children: React.ReactNode }) => {
{item.items.map((subItem) => (
<div
key={subItem.href}
className={`cursor-pointer px-3 py-2 rounded-md text-sm transition-colors ${
pathname === subItem.href
? "text-primary font-medium bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
className={`cursor-pointer px-3 py-2 rounded-md text-sm transition-colors ${pathname === subItem.href
? "text-primary font-medium bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
onClick={() => router.push(subItem.href)}
>
{subItem.title}
+187 -98
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useEffect } from "react";
import { useTranslations } from "@/i18n/compat/client";
import { useTranslations, useLocale } from "@/i18n/compat/client";
import { useRouter } from "@/lib/navigation";
import { Plus, FileText, Settings, AlertCircle, Upload } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
@@ -18,14 +18,176 @@ import { cn } from "@/lib/utils";
import { getConfig, getFileHandle, verifyPermission } from "@/utils/fileSystem";
import { useResumeStore } from "@/store/useResumeStore";
import { initialResumeState } from "@/config/initialResumeData";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import ResumeTemplateComponent from "@/components/templates";
import { DEFAULT_TEMPLATES } from "@/config";
import { generateUUID } from "@/utils/uuid";
const ResumesList = () => {
return <ResumeWorkbench />;
};
const ResumeCardItem = ({ id, resume, t, locale, setActiveResume, router, deleteResume, index }: any) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [scale, setScale] = React.useState(0.24);
React.useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
const { width } = entries[0].contentRect;
if (width > 0) {
setScale(width / 793.700787); // Exact 210mm in pixels at 96dpi
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
delay: index * 0.1,
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
className={cn(
"group border transition-all duration-200 aspect-[210/297] flex flex-col overflow-hidden",
"hover:border-primary/40 hover:shadow-lg",
"dark:hover:border-primary/40"
)}
>
<CardContent className="p-0 flex-1 relative bg-gray-50 dark:bg-gray-900 overflow-hidden cursor-pointer" onClick={() => { setActiveResume(id); router.push(`/app/workbench/${id}`); }}>
<div className="absolute inset-0 pb-6 flex items-center justify-center pointer-events-none transition-transform duration-300 group-hover:scale-[1.02] overflow-hidden" ref={containerRef}>
<div className="w-full h-full relative origin-top bg-white">
<div
className="absolute top-0 left-0 bg-white"
style={{
width: "210mm",
height: "297mm",
transform: `scale(${scale})`,
transformOrigin: "top left",
padding: `${resume.globalSettings?.pagePadding || 32}px`,
fontFamily: resume.globalSettings?.fontFamily || "Alibaba PuHuiTi, sans-serif",
}}
>
{(() => {
const template = DEFAULT_TEMPLATES.find(t => t.id === resume.templateId) || DEFAULT_TEMPLATES[0];
return <ResumeTemplateComponent data={resume as any} template={template} />;
})()}
</div>
</div>
</div>
<div className="absolute inset-x-0 bottom-0 top-[60%] pointer-events-none bg-gradient-to-t from-white via-white/90 to-transparent dark:from-gray-950 dark:via-gray-950/90 z-0"></div>
<div className="absolute inset-x-0 bottom-0 pt-12 pb-3 px-4 flex justify-between items-end border-t border-transparent z-10 transition-colors group-hover:bg-white/50 dark:group-hover:bg-gray-950/50">
<div className="flex flex-col w-full">
<span className="text-[15px] font-semibold truncate text-gray-900 dark:text-gray-100 drop-shadow-sm w-[90%]">
{resume.title || t("dashboard.resumes.untitled")}
</span>
<span className="text-[11px] text-gray-600 dark:text-gray-300 mt-0.5 font-medium">
{t(`dashboard.templates.${DEFAULT_TEMPLATES.find(t => t.id === resume.templateId)?.id === "left-right" ? "leftRight" : (DEFAULT_TEMPLATES.find(t => t.id === resume.templateId)?.id || "classic")}.name`)} · {new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }).format(new Date(resume.updatedAt || resume.createdAt))}
</span>
</div>
</div>
</CardContent>
<CardFooter className="pt-2 pb-2 px-2 bg-white dark:bg-gray-950 border-t border-gray-100 dark:border-gray-800 z-10">
<div className="grid grid-cols-2 gap-2 w-full">
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 17,
}}
>
<Button
variant="outline"
className="w-full text-sm hover:bg-gray-100 dark:border-primary/50 dark:hover:bg-primary/10"
size="sm"
onClick={(e) => {
e.stopPropagation();
setActiveResume(id);
router.push(`/app/workbench/${id}`);
}}
>
{t("common.edit")}
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 17,
}}
>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="w-full text-sm text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-500 dark:hover:bg-red-950/50 dark:hover:text-red-400"
size="sm"
onClick={(e) => {
e.stopPropagation();
}}
>
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent onClick={(e) => e.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>{t("dashboard.resumes.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.resumes.deleteConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={(e) => e.stopPropagation()}>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700 text-white focus:ring-red-600 border-none"
onClick={(e) => {
e.stopPropagation();
deleteResume(resume);
toast.success(t("common.deleteSuccess"));
}}
>
{t("common.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
</div>
</CardFooter>
</Card>
</motion.div>
);
};
const ResumeWorkbench = () => {
const t = useTranslations();
const locale = useLocale();
const {
resumes,
setActiveResume,
@@ -125,13 +287,14 @@ const ResumeWorkbench = () => {
};
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="flex-1 space-y-6"
>
<ScrollArea className="h-[calc(100vh-2rem)] w-full">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="flex-1 space-y-6 py-8"
>
<motion.div
className="flex w-full items-center justify-center px-4"
initial={{ y: 20, opacity: 0 }}
@@ -231,7 +394,7 @@ const ResumeWorkbench = () => {
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.3, delay: 0.2 }}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4 sm:gap-6">
<motion.div
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
@@ -240,12 +403,12 @@ const ResumeWorkbench = () => {
>
<Card
className={cn(
"relative border border-dashed cursor-pointer h-[260px] transition-all duration-200",
"relative border border-dashed cursor-pointer transition-all duration-200 aspect-[210/297] flex flex-col",
"hover:border-gray-400 hover:bg-gray-50",
"dark:hover:border-primary dark:hover:bg-primary/10"
)}
>
<CardContent className="flex-1 pt-6 text-center flex flex-col items-center justify-center">
<CardContent className="flex-1 p-0 text-center flex flex-col items-center justify-center">
<motion.div
className="mb-4 p-4 rounded-full bg-gray-100 dark:bg-primary/10"
whileHover={{ rotate: 90 }}
@@ -253,10 +416,10 @@ const ResumeWorkbench = () => {
>
<Plus className="h-8 w-8 text-gray-600 dark:text-primary" />
</motion.div>
<CardTitle className="text-xl text-gray-900 dark:text-gray-100">
<CardTitle className="text-xl text-gray-900 dark:text-gray-100 px-4">
{t("dashboard.resumes.newResume")}
</CardTitle>
<CardDescription className="mt-2 text-gray-600 dark:text-gray-400">
<CardDescription className="mt-2 text-gray-600 dark:text-gray-400 px-4">
{t("dashboard.resumes.newResumeDescription")}
</CardDescription>
</CardContent>
@@ -265,97 +428,23 @@ const ResumeWorkbench = () => {
<AnimatePresence>
{Object.entries(resumes).map(([id, resume], index) => (
<motion.div
<ResumeCardItem
key={id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
delay: index * 0.1,
}}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Card
className={cn(
"group border transition-all duration-200 h-[260px] flex flex-col",
"hover:border-gray-400 hover:bg-gray-50",
"dark:hover:border-primary dark:hover:bg-primary/10"
)}
>
<CardContent className="relative flex-1 pt-6 text-center flex flex-col items-center">
<motion.div
className="mb-4 p-4 rounded-full bg-gray-100 dark:bg-primary/10"
whileHover={{ rotate: 90 }}
transition={{ duration: 0.2 }}
>
<FileText className="h-8 w-8 text-gray-600 dark:text-primary" />
</motion.div>
<CardTitle className="text-xl line-clamp-1 text-gray-900 dark:text-gray-100">
{resume.title || "未命名简历"}
</CardTitle>
<CardDescription className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{t("dashboard.resumes.created")}
<span className="ml-2">
{new Date(resume.createdAt).toLocaleDateString()}
</span>
</CardDescription>
</CardContent>
<CardFooter className="pt-0 pb-4 px-4">
<div className="grid grid-cols-2 gap-2 w-full">
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 17,
}}
>
<Button
variant="outline"
className="w-full text-sm hover:bg-gray-100 dark:border-primary/50 dark:hover:bg-primary/10"
size="sm"
onClick={(e) => {
e.stopPropagation();
setActiveResume(id);
router.push(`/app/workbench/${id}`);
}}
>
{t("common.edit")}
</Button>
</motion.div>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{
type: "spring",
stiffness: 400,
damping: 17,
}}
>
<Button
variant="outline"
className="w-full text-sm text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-500 dark:hover:bg-red-950/50 dark:hover:text-red-400"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteResume(resume);
}}
>
{t("common.delete")}
</Button>
</motion.div>
</div>
</CardFooter>
</Card>
</motion.div>
id={id}
resume={resume}
t={t}
locale={locale}
setActiveResume={setActiveResume}
router={router}
deleteResume={deleteResume}
index={index}
/>
))}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
</ScrollArea>
);
};
export const runtime = "edge";
+132 -21
View File
@@ -1,5 +1,5 @@
"use client";
import { useState } from "react";
import { useState, useRef } from "react";
import { useTranslations } from "@/i18n/compat/client";
import { motion } from "framer-motion";
import { useRouter } from "@/lib/navigation";
@@ -9,6 +9,10 @@ import { useResumeStore } from "@/store/useResumeStore";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import ResumeTemplateComponent from "@/components/templates";
import { initialResumeState, initialResumeStateEn } from "@/config/initialResumeData";
import { useLocale } from "@/i18n/compat/client";
const container = {
hidden: { opacity: 0 },
@@ -27,6 +31,7 @@ const item = {
const TemplatesPage = () => {
const t = useTranslations("dashboard.templates");
const locale = useLocale();
const router = useRouter();
const createResume = useResumeStore((state) => state.createResume);
const [previewTemplate, setPreviewTemplate] = useState<{
@@ -34,6 +39,42 @@ const TemplatesPage = () => {
open: boolean;
} | null>(null);
const PRESET_COLORS = [
{ name: "default", value: "" },
{ name: "blue", value: "#3b82f6" },
{ name: "green", value: "#10b981" },
{ name: "purple", value: "#8b5cf6" },
{ name: "orange", value: "#f97316" },
{ name: "red", value: "#ef4444" },
{ name: "slate", value: "#475569" },
{ name: "black", value: "#000000" },
];
const [selectedColor, setSelectedColor] = useState<string>(PRESET_COLORS[0].value);
const autoPlayRef = useRef<NodeJS.Timeout | null>(null);
// Auto-cycle colors every 3 seconds
useState(() => {
// Only run on client
if (typeof window !== "undefined") {
let currentIndex = 0;
autoPlayRef.current = setInterval(() => {
currentIndex = (currentIndex + 1) % PRESET_COLORS.length;
setSelectedColor(PRESET_COLORS[currentIndex].value);
}, 3000);
}
});
const handleColorSelect = (value: string) => {
setSelectedColor(value);
// Stop autoplay when user manually selects a color
if (autoPlayRef.current) {
clearInterval(autoPlayRef.current);
autoPlayRef.current = null;
}
};
const baseData = locale === "en" ? initialResumeStateEn : initialResumeState;
const handleCreateResume = (templateId: string) => {
const template = DEFAULT_TEMPLATES.find((t) => t.id === templateId);
if (!template) return;
@@ -46,7 +87,7 @@ const TemplatesPage = () => {
updateResume(resumeId, {
globalSettings: {
...resume.globalSettings,
themeColor: template.colorScheme.primary,
themeColor: selectedColor || template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
@@ -62,10 +103,36 @@ const TemplatesPage = () => {
};
return (
<div className="w-full max-w-[1600px] mx-auto py-8 px-6 lg:px-8">
<div className="flex flex-col space-y-8">
<div>
<ScrollArea className="h-[calc(100vh-2rem)] w-full">
<div className="w-full max-w-[1600px] mx-auto py-8 px-6 lg:px-8">
<div className="flex flex-col space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<h2 className="text-3xl font-bold tracking-tight">{t("title")}</h2>
<div className="flex items-center space-x-2 bg-gray-50/50 dark:bg-gray-900/50 p-2 rounded-full border border-gray-100 dark:border-gray-800 backdrop-blur-sm self-start sm:self-auto overflow-x-auto">
{PRESET_COLORS.map((color) => (
<button
key={color.name}
onClick={() => handleColorSelect(color.value)}
className={cn(
"relative w-8 h-8 rounded-full flex items-center justify-center shrink-0 transition-transform hover:scale-110",
selectedColor === color.value ? "ring-2 ring-primary ring-offset-2 dark:ring-offset-gray-950 scale-110" : ""
)}
title={color.name === "default" ? "Default" : color.name}
>
{color.value ? (
<div
className="w-full h-full rounded-full border border-black/10 dark:border-white/10 shadow-sm"
style={{ backgroundColor: color.value }}
/>
) : (
<div className="w-full h-full rounded-full bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900 border border-gray-300 dark:border-gray-700 shadow-sm flex items-center justify-center">
<span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium tracking-tighter">Tpl</span>
</div>
)}
</button>
))}
</div>
</div>
<motion.div
@@ -103,18 +170,37 @@ const TemplatesPage = () => {
height: "297px",
}}
>
<iframe
src={`/app/preview-template/${template.id}`}
className="absolute top-0 left-0 border-0"
<div
className="absolute top-0 left-0 bg-white"
style={{
width: "210mm",
height: "297mm",
transform: "scale(0.264583333)", // (210px / 210mm) approximation
transformOrigin: "top left"
transformOrigin: "top left",
padding: `${template.spacing.contentPadding}px`,
fontFamily: "Alibaba PuHuiTi, sans-serif",
}}
scrolling="no"
tabIndex={-1}
/>
>
<ResumeTemplateComponent
data={{
...baseData,
id: "preview-mock-id",
templateId: template.id,
globalSettings: {
...baseData.globalSettings,
themeColor: selectedColor || template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
} as any}
template={template}
/>
</div>
</div>
</div>
@@ -177,24 +263,48 @@ const TemplatesPage = () => {
</div>
<div className="overflow-hidden flex items-center justify-center bg-gray-50 dark:bg-gray-950 py-8 pointer-events-none">
<div
className="relative bg-white shadow-md ring-1 ring-gray-200/50"
className="relative bg-white shadow-md ring-1 ring-gray-200/50 overflow-hidden"
style={{
width: "420px",
height: "594px",
}}
>
<iframe
src={`/app/preview-template/${previewTemplate.id}`}
className="absolute top-0 left-0 border-0"
<div
className="absolute top-0 left-0 bg-white"
style={{
width: "210mm",
height: "297mm",
transform: "scale(0.529166667)", // (420px / 210mm) approximation
transformOrigin: "top left"
transformOrigin: "top left",
padding: `${DEFAULT_TEMPLATES.find(t => t.id === previewTemplate.id)?.spacing.contentPadding || 32}px`,
fontFamily: "Alibaba PuHuiTi, sans-serif",
}}
scrolling="no"
tabIndex={-1}
/>
>
{(() => {
const tpl = DEFAULT_TEMPLATES.find(t => t.id === previewTemplate.id) || DEFAULT_TEMPLATES[0];
return (
<ResumeTemplateComponent
data={{
...baseData,
id: "preview-mock-id-large",
templateId: tpl.id,
globalSettings: {
...baseData.globalSettings,
themeColor: selectedColor || tpl.colorScheme.primary,
sectionSpacing: tpl.spacing.sectionGap,
paragraphSpacing: tpl.spacing.itemGap,
pagePadding: tpl.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: tpl.basic.layout,
},
} as any}
template={tpl}
/>
);
})()}
</div>
</div>
</div>
<div className="p-3 pt-2 border-t border-gray-100 dark:border-gray-800 flex justify-center">
@@ -213,7 +323,8 @@ const TemplatesPage = () => {
)}
</Dialog>
</div>
</div>
</div>
</ScrollArea>
);
};
+122 -30
View File
@@ -1,4 +1,5 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Layout, PanelsLeftBottom } from "lucide-react";
import { motion } from "framer-motion";
import { useTranslations } from "@/i18n/compat/client";
@@ -14,6 +15,118 @@ import { cn } from "@/lib/utils";
import { DEFAULT_TEMPLATES } from "@/config";
import { useResumeStore } from "@/store/useResumeStore";
import { ScrollArea } from "@/components/ui/scroll-area";
import ResumeTemplateComponent from "@/components/templates";
import { useLocale } from "@/i18n/compat/client";
import { initialResumeState, initialResumeStateEn } from "@/config/initialResumeData";
const A4_WIDTH_PX = 794;
const A4_HEIGHT_PX = 1123;
type TemplateItem = (typeof DEFAULT_TEMPLATES)[number];
interface TemplatePreviewProps {
template: TemplateItem;
isActive: boolean;
baseData: typeof initialResumeState;
onSelect: (templateId: string) => void;
}
const TemplatePreview = ({
template,
isActive,
baseData,
onSelect,
}: TemplatePreviewProps) => {
const paperRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(0.18);
useEffect(() => {
const paper = paperRef.current;
if (!paper) return;
const updateScale = () => {
const { width } = paper.getBoundingClientRect();
if (!width) return;
setScale(Math.min(width / A4_WIDTH_PX, 1));
};
updateScale();
const resizeObserver = new ResizeObserver(updateScale);
resizeObserver.observe(paper);
return () => resizeObserver.disconnect();
}, []);
return (
<button
key={template.id}
onClick={() => onSelect(template.id)}
className={cn(
"relative group rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-[1.02] text-left",
isActive
? "border-primary dark:border-primary shadow-lg dark:shadow-primary/30"
: "dark:border-neutral-800 dark:hover:border-neutral-700 border-gray-100 hover:border-gray-200"
)}
>
<div
className="relative aspect-[210/297] w-full overflow-hidden bg-gray-50 dark:bg-gray-900"
>
<div className="h-full w-full p-2 transition-all duration-300 group-hover:scale-[1.02] flex items-center justify-center pointer-events-none">
<div
ref={paperRef}
className="relative overflow-hidden bg-white shadow-sm ring-1 ring-gray-200/50 rounded-sm"
style={{
width: "min(210px, calc(100% - 8px))",
aspectRatio: "210 / 297",
}}
>
<div
className="absolute top-0 left-0 bg-white"
style={{
width: `${A4_WIDTH_PX}px`,
height: `${A4_HEIGHT_PX}px`,
transform: `scale(${scale})`,
transformOrigin: "top left",
boxSizing: "border-box",
padding: `${template.spacing.contentPadding}px`,
fontFamily: "Alibaba PuHuiTi, sans-serif",
}}
>
<ResumeTemplateComponent
data={{
...baseData,
id: `preview-mock-sheet-${template.id}`,
templateId: template.id,
globalSettings: {
...baseData.globalSettings,
themeColor: template.colorScheme.primary,
sectionSpacing: template.spacing.sectionGap,
paragraphSpacing: template.spacing.itemGap,
pagePadding: template.spacing.contentPadding,
},
basic: {
...baseData.basic,
layout: template.basic.layout,
},
} as any}
template={template}
/>
</div>
</div>
</div>
</div>
{isActive && (
<motion.div
layoutId="template-selected"
className="absolute inset-0 flex items-center justify-center bg-black/10 dark:bg-black/40 pointer-events-none z-20"
>
<Layout className="w-8 h-8 text-primary shadow-sm" />
</motion.div>
)}
</button>
);
};
const TemplateSheet = () => {
const t = useTranslations("templates");
@@ -22,6 +135,9 @@ const TemplateSheet = () => {
DEFAULT_TEMPLATES.find((t) => t.id === activeResume?.templateId) ||
DEFAULT_TEMPLATES[0];
const locale = useLocale();
const baseData = locale === "en" ? initialResumeStateEn : initialResumeState;
return (
<Sheet>
<SheetTrigger asChild>
@@ -39,37 +155,13 @@ const TemplateSheet = () => {
<ScrollArea className="h-full w-full pr-4">
<div className="grid grid-cols-4 gap-4 pb-8">
{DEFAULT_TEMPLATES.map((template) => (
<button
<TemplatePreview
key={template.id}
onClick={() => setTemplate(template.id)}
className={cn(
"relative group rounded-lg overflow-hidden border-2 transition-all duration-200 hover:scale-[1.02]",
template.id === currentTemplate.id
? "border-primary dark:border-primary shadow-lg dark:shadow-primary/30"
: "dark:border-neutral-800 dark:hover:border-neutral-700 border-gray-100 hover:border-gray-200"
)}
>
<div className="relative aspect-[210/297] w-full overflow-hidden bg-white pointer-events-none">
<svg viewBox="0 0 794 1123" className="absolute inset-0 w-full h-full pointer-events-none rounded-md">
<foreignObject x="0" y="0" width="794" height="1123">
<iframe
src={`/app/preview-template/${template.id}`}
className="w-[794px] h-[1123px] border-0 pointer-events-none"
scrolling="no"
tabIndex={-1}
/>
</foreignObject>
</svg>
</div>
{template.id === currentTemplate.id && (
<motion.div
layoutId="template-selected"
className="absolute inset-0 flex items-center justify-center bg-black/10 dark:bg-black/40 pointer-events-none z-20"
>
<Layout className="w-8 h-8 text-primary shadow-sm" />
</motion.div>
)}
</button>
template={template}
isActive={template.id === currentTemplate.id}
baseData={baseData}
onSelect={setTemplate}
/>
))}
</div>
</ScrollArea>
+1 -1
View File
@@ -152,7 +152,7 @@ export const DEFAULT_TEMPLATES: ResumeTemplate[] = [
thumbnail: "creative",
layout: "creative",
colorScheme: {
primary: "#3b82f6",
primary: "#18181b",
secondary: "#64748b",
background: "#ffffff",
text: "#1e293b"
+7 -2
View File
@@ -7,7 +7,10 @@
"edit": "Edit",
"delete": "Delete",
"newResume": "New Resume",
"copy": "Copy"
"copy": "Copy",
"cancel": "Cancel",
"confirm": "Confirm",
"deleteSuccess": "Deleted successfully"
},
"home": {
"header": {
@@ -119,7 +122,9 @@
"title": "Attention",
"description": "It is recommended to configure a resume backup folder in the settings to prevent your data from being lost when the browser cache is cleared",
"goToSettings": "Go to Settings"
}
},
"deleteConfirmTitle": "Confirm Delete Resume?",
"deleteConfirmDescription": "This action cannot be undone. This will permanently delete this resume and remove the data from your device."
},
"settings": {
"title": "Settings",
+7 -2
View File
@@ -7,7 +7,10 @@
"edit": "编辑",
"delete": "删除",
"newResume": "新建简历",
"copy": "复制"
"copy": "复制",
"cancel": "取消",
"confirm": "确认",
"deleteSuccess": "删除成功"
},
"home": {
"header": {
@@ -120,7 +123,9 @@
"title": "注意",
"description": "建议在设置里中配置简历备份文件夹,防止您的数据可能会在浏览器清除缓存后丢失",
"goToSettings": "前往设置"
}
},
"deleteConfirmTitle": "确认删除简历?",
"deleteConfirmDescription": "此操作无法撤销。这将永久删除此简历,并从设备中移除数据。"
},
"settings": {
"title": "设置",
+4 -3
View File
@@ -1,8 +1,9 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { defaultLocale } from "@/i18n/config";
import { getPreferredLocale } from "@/i18n/runtime";
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({ to: "/$locale", params: { locale: defaultLocale } });
beforeLoad: ({ location }) => {
const locale = getPreferredLocale(location.pathname);
throw redirect({ to: "/$locale", params: { locale } });
}
});