mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-06-01 23:38:48 +02:00
feat: Implement dynamic template previews with i18n support and locale-aware routing
This commit is contained in:
Vendored
+14
-6
@@ -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"
|
||||
|
||||
@@ -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,8 +122,7 @@ 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
|
||||
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"
|
||||
}`}
|
||||
@@ -147,8 +147,7 @@ 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
|
||||
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"
|
||||
}`}
|
||||
|
||||
@@ -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,12 +287,13 @@ const ResumeWorkbench = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<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"
|
||||
className="flex-1 space-y-6 py-8"
|
||||
>
|
||||
<motion.div
|
||||
className="flex w-full items-center justify-center px-4"
|
||||
@@ -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";
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<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,20 +170,39 @@ 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>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
|
||||
@@ -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">
|
||||
@@ -214,6 +324,7 @@ const TemplatesPage = () => {
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
template={template}
|
||||
isActive={template.id === currentTemplate.id}
|
||||
baseData={baseData}
|
||||
onSelect={setTemplate}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
+1
-1
@@ -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,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,7 +7,10 @@
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"newResume": "新建简历",
|
||||
"copy": "复制"
|
||||
"copy": "复制",
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"deleteSuccess": "删除成功"
|
||||
},
|
||||
"home": {
|
||||
"header": {
|
||||
@@ -120,7 +123,9 @@
|
||||
"title": "注意",
|
||||
"description": "建议在设置里中配置简历备份文件夹,防止您的数据可能会在浏览器清除缓存后丢失",
|
||||
"goToSettings": "前往设置"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "确认删除简历?",
|
||||
"deleteConfirmDescription": "此操作无法撤销。这将永久删除此简历,并从设备中移除数据。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user