├── .dockerignore
├── .env
├── .eslintrc.json
├── .github
├── FUNDING.yml
└── workflows
│ └── docker-publish.yml
├── .gitignore
├── .vscode
└── settings.json
├── Dockerfile
├── LICENSE
├── README.en-US.md
├── README.md
├── components.json
├── docker-compose.yml
├── fonts
├── GeistMonoVF.woff
└── fonts.conf
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── public
├── avatar.png
├── features
│ ├── grammar.png
│ ├── polish.png
│ └── svg
│ │ ├── export-formats.svg
│ │ ├── grammar.svg
│ │ ├── local-storage.svg
│ │ └── polish.svg
├── fonts
│ ├── MiSans-VF.ttf
│ └── NotoSansSC.ttf
├── icon.png
├── logo.svg
├── robots.txt
├── vercel.svg
└── web-shot.png
├── src
├── actions
│ └── navigation.ts
├── app
│ ├── (public)
│ │ └── [locale]
│ │ │ ├── changelog
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ ├── api
│ │ ├── grammar
│ │ │ └── route.ts
│ │ ├── polish
│ │ │ └── route.ts
│ │ └── proxy
│ │ │ └── image
│ │ │ └── route.ts
│ ├── app
│ │ ├── dashboard
│ │ │ ├── ai
│ │ │ │ └── page.tsx
│ │ │ ├── client.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── page.tsx
│ │ │ ├── resumes
│ │ │ │ └── page.tsx
│ │ │ ├── settings
│ │ │ │ └── page.tsx
│ │ │ └── templates
│ │ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── workbench
│ │ │ ├── [id]
│ │ │ └── page.tsx
│ │ │ └── layout.tsx
│ ├── favicon.ico
│ ├── font.css
│ ├── globals.css
│ ├── layout.tsx
│ ├── manifest.ts
│ ├── providers.tsx
│ └── sitemap.ts
├── assets
│ └── images
│ │ ├── logo@2x.svg
│ │ └── template-cover
│ │ ├── classic.png
│ │ ├── left-right.png
│ │ ├── modern.png
│ │ └── timeline.png
├── components
│ ├── Document.tsx
│ ├── ai
│ │ └── icon
│ │ │ ├── IconDeepseek.tsx
│ │ │ ├── IconDoubao.tsx
│ │ │ └── IconOpenAi.tsx
│ ├── editor
│ │ ├── EditPanel.tsx
│ │ ├── EditorHeader.tsx
│ │ ├── Field.tsx
│ │ ├── IconSelector.tsx
│ │ ├── SidePanel.tsx
│ │ ├── basic
│ │ │ ├── AlignSelector.tsx
│ │ │ └── BasicPanel.tsx
│ │ ├── custom
│ │ │ ├── CustomItem.tsx
│ │ │ └── CustomPanel.tsx
│ │ ├── education
│ │ │ ├── EducationItem.tsx
│ │ │ └── EducationPanel.tsx
│ │ ├── experience
│ │ │ ├── ExperienceItem.tsx
│ │ │ └── ExperiencePanel.tsx
│ │ ├── layout
│ │ │ ├── LayoutItem.tsx
│ │ │ └── LayoutSetting.tsx
│ │ ├── project
│ │ │ ├── ProjectItem.tsx
│ │ │ └── ProjectPanel.tsx
│ │ └── skills
│ │ │ └── SkillPanel.tsx
│ ├── home
│ │ ├── CTASection.tsx
│ │ ├── FAQSection.tsx
│ │ ├── FeaturesSection.tsx
│ │ ├── Footer.tsx
│ │ ├── GoDashboard.tsx
│ │ ├── HeroSection.tsx
│ │ ├── LandingHeader.tsx
│ │ ├── NewsAlert.tsx
│ │ └── client
│ │ │ ├── AnimatedFeature.tsx
│ │ │ ├── MenuToggle.tsx
│ │ │ ├── MobileMenu.tsx
│ │ │ ├── ScrollBackground.tsx
│ │ │ └── ScrollHeader.tsx
│ ├── magicui
│ │ └── dock.tsx
│ ├── preview
│ │ ├── BaseInfo.tsx
│ │ ├── CustomSection.tsx
│ │ ├── EducationSection.tsx
│ │ ├── ExperienceSection.tsx
│ │ ├── PreviewDock.tsx
│ │ ├── ProjectSection.tsx
│ │ ├── SectionTitle.tsx
│ │ ├── SkillPanel.tsx
│ │ └── index.tsx
│ ├── shared
│ │ ├── ChangelogTimeline.tsx
│ │ ├── EditButton.tsx
│ │ ├── GitHubStars.tsx
│ │ ├── GithubContribution.tsx
│ │ ├── LanguageSwitch.tsx
│ │ ├── Logo.tsx
│ │ ├── PdfExport.tsx
│ │ ├── PhotoConfigDrawer.tsx
│ │ ├── PhotoSelector.tsx
│ │ ├── ScrollToTop.tsx
│ │ ├── TemplateSheet.tsx
│ │ ├── ThemeModal.tsx
│ │ ├── ThemeToggle.tsx
│ │ ├── UpdateLocale.tsx
│ │ ├── ai
│ │ │ └── AIPolishDialog.tsx
│ │ └── rich-editor
│ │ │ └── RichEditor.tsx
│ ├── templates
│ │ ├── ClassicTemplate.tsx
│ │ ├── LeftRightTemplate.tsx
│ │ ├── ModernTemplate.tsx
│ │ ├── TimelineTemplate.tsx
│ │ └── index.tsx
│ └── ui
│ │ ├── accordion.tsx
│ │ ├── alert-dialog.tsx
│ │ ├── alert.tsx
│ │ ├── badge.tsx
│ │ ├── button.tsx
│ │ ├── calendar.tsx
│ │ ├── card.tsx
│ │ ├── command.tsx
│ │ ├── dialog.tsx
│ │ ├── drawer.tsx
│ │ ├── dropdown-menu.tsx
│ │ ├── hover-card.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── navigation-menu.tsx
│ │ ├── popover.tsx
│ │ ├── resizable.tsx
│ │ ├── scroll-area.tsx
│ │ ├── select.tsx
│ │ ├── separator.tsx
│ │ ├── sheet-no-overlay.tsx
│ │ ├── sheet.tsx
│ │ ├── sidebar.tsx
│ │ ├── skeleton.tsx
│ │ ├── slider.tsx
│ │ ├── sonner.tsx
│ │ ├── switch.tsx
│ │ ├── tabs.tsx
│ │ ├── textarea.tsx
│ │ └── tooltip.tsx
├── config
│ ├── ai.ts
│ ├── index.ts
│ ├── initialResumeData.ts
│ └── templates.ts
├── hooks
│ ├── use-mobile.tsx
│ └── useGrammarCheck.ts
├── i18n
│ ├── config.ts
│ ├── db.ts
│ ├── locales
│ │ ├── en.json
│ │ └── zh.json
│ ├── request.ts
│ └── routing.public.ts
├── lib
│ ├── getChangelog.ts
│ └── utils.ts
├── middleware.ts
├── store
│ ├── useAIConfigStore.ts
│ ├── useGrammarStore.ts
│ └── useResumeStore.ts
├── styles
│ └── tiptap.scss
├── theme
│ └── themeConfig.ts
├── types
│ ├── global.d.ts
│ ├── resume.ts
│ └── template.ts
└── utils
│ ├── fileSystem.ts
│ ├── imageUtils.ts
│ ├── index.ts
│ └── uuid.ts
├── tailwind.config.ts
└── tsconfig.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | Dockerfile
2 | .dockerignore
3 | node_modules
4 | npm-debug.log
5 | README.md
6 | .next
7 | .git
8 | .env.local
9 | .env.development.local
10 | .env.test.local
11 | .env.production.local
12 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 |
2 | FONTCONFIG_PATH=/var/task/fonts
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12 | polar: # Replace with a single Polar username
13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14 | thanks_dev: # Replace with a single thanks.dev username
15 | custom: https://github.com/JOYCEQL/picx-images-hosting/raw/master/pintu-fulicat.com-1741081632544.26lmg2uc2m.webp
16 |
--------------------------------------------------------------------------------
/.github/workflows/docker-publish.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI/CD
2 |
3 | on:
4 | push:
5 | branches: [ "main" ]
6 | release:
7 | types: [created]
8 |
9 | env:
10 | REGISTRY: docker.io
11 | IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/magic-resume
12 |
13 | jobs:
14 | build-and-push:
15 | runs-on: ubuntu-latest
16 | permissions:
17 | contents: read
18 | packages: write
19 |
20 | steps:
21 | - name: Checkout repository
22 | uses: actions/checkout@v4
23 |
24 | - name: Log in to Docker Hub
25 | uses: docker/login-action@v3
26 | with:
27 | username: ${{ secrets.DOCKERHUB_USERNAME }}
28 | password: ${{ secrets.DOCKERHUB_TOKEN }}
29 |
30 | - name: Extract metadata (tags, labels) for Docker
31 | id: meta
32 | uses: docker/metadata-action@v5
33 | with:
34 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
35 | tags: |
36 | type=ref,event=branch
37 | type=ref,event=pr
38 | type=semver,pattern={{version}}
39 | type=semver,pattern={{major}}.{{minor}}
40 |
41 | - name: Build and push Docker image
42 | uses: docker/build-push-action@v5
43 | with:
44 | context: .
45 | push: true
46 | tags: ${{ steps.meta.outputs.tags }}
47 | labels: ${{ steps.meta.outputs.labels }}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # idea
39 | .idea
40 |
41 | .history
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "less.validate": false,
4 | "scss.validate": false,
5 |
6 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
7 | "stylelint.validate": ["css", "less", "postcss", "scss", "sass"],
8 | "typescript.tsdk": "./node_modules/typescript/lib",
9 |
10 | "search.exclude": {
11 | "**/node_modules": true,
12 | "dist": true,
13 | "build": true
14 | },
15 |
16 | "editor.formatOnSave": true,
17 | "editor.codeActionsOnSave": {
18 | "source.fixAll.eslint": "explicit",
19 | "source.fixAll.stylelint": "explicit"
20 | },
21 | "[css]": {
22 | "editor.defaultFormatter": "esbenp.prettier-vscode"
23 | },
24 | "[scss]": {
25 | "editor.defaultFormatter": "esbenp.prettier-vscode"
26 | },
27 | "[javascript]": {
28 | "editor.defaultFormatter": "esbenp.prettier-vscode"
29 | },
30 | "[javascriptreact]": {
31 | "editor.defaultFormatter": "esbenp.prettier-vscode"
32 | },
33 | "[typescript]": {
34 | "editor.defaultFormatter": "esbenp.prettier-vscode"
35 | },
36 | "[typescriptreact]": {
37 | "editor.defaultFormatter": "esbenp.prettier-vscode"
38 | },
39 | "[json]": {
40 | "editor.defaultFormatter": "esbenp.prettier-vscode"
41 | },
42 | "i18n-ally.localesPaths": [
43 | "src/i18n",
44 | "src/i18n/locales"
45 | ]
46 | }
47 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # syntax=docker.io/docker/dockerfile:1
2 |
3 | FROM node:20-alpine AS base
4 | ENV PNPM_HOME="/pnpm"
5 | ENV PATH="$PNPM_HOME:$PATH"
6 | RUN npm install -g corepack@latest && corepack enable
7 |
8 | WORKDIR /app
9 |
10 | FROM base AS deps
11 | COPY package.json pnpm-lock.yaml* ./
12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile --force
13 |
14 | FROM base AS builder
15 | WORKDIR /app
16 | COPY --from=deps /app/node_modules ./node_modules
17 | COPY . .
18 |
19 | RUN pnpm run build
20 |
21 | FROM base AS runner
22 | WORKDIR /app
23 |
24 | ENV NODE_ENV production
25 |
26 |
27 | RUN addgroup --system --gid 1001 nodejs
28 | RUN adduser --system --uid 1001 nextjs
29 |
30 | COPY --from=builder /app/public ./public
31 |
32 | RUN mkdir .next
33 | RUN chown nextjs:nodejs .next
34 |
35 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
36 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
37 |
38 | USER nextjs
39 |
40 | EXPOSE 3000
41 |
42 | ENV PORT 3000
43 | ENV HOSTNAME "0.0.0.0"
44 |
45 | CMD ["node", "server.js"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | ## Magic Resume 用户协议
2 |
3 | 欢迎使用 Magic Resume 简历制作工具。请仔细阅读以下协议条款,继续使用本软件即表示您同意本协议内容。
4 |
5 | **许可协议**
6 |
7 | 本软件采用 Apache License 2.0 许可。除 Apache License 2.0 规定的条款外,您在使用 Magic Resume 时还应遵守以下附加条款:
8 |
9 | **一. 商用许可**
10 |
11 | 1. **免费商用**:用户在不修改代码的情况下,可以免费用于商业目的。
12 | 2. **商业授权**:如果您满足以下任意条件之一,需取得商业授权:
13 | 1. 对本软件进行二次修改、开发(包括但不限于修改应用名称、logo、代码以及功能)。
14 |
15 | **二. 贡献者协议**
16 |
17 | 作为 Magic Resume 的贡献者,您应当同意以下条款:
18 |
19 | 1. **许可调整**:生产者有权根据需要对开源协议进行调整,使其更加严格或宽松。
20 | 2. **商业用途**:您贡献的代码可能会被用于商业用途,包括但不限于云业务运营。
21 |
22 | **三. 其他条款**
23 |
24 | 1. 本协议条款的解释权归 Magic Resume 开发者所有。
25 | 2. 本协议可能根据实际情况进行更新,更新时将通过本软件通知用户。
26 |
27 | 如有任何问题或需申请商业授权,请联系 Magic Resume 开发者。
28 |
29 | 除上述特定条件外,其他所有权利和限制均遵循 Apache License 2.0。有关 Apache License 2.0 的详细信息,请访问 http://www.apache.org/licenses/LICENSE-2.0。
30 |
31 | ---
32 |
33 | 根据 Apache 许可证 2.0 版("许可证")进行许可;除非符合许可证,否则您不得使用此文件。您可以在以下网址获取许可证副本:
34 |
35 | http://www.apache.org/licenses/LICENSE-2.0
36 |
37 | 除非适用法律要求或书面同意,软件根据许可证分发的内容以"原样"分发,不附带任何明示或暗示的保证或条件。请参阅特定语言管理权限的许可证和许可证下的限制。
38 |
39 | ## Magic Resume User Agreement
40 |
41 | Welcome to Magic Resume, a resume creation tool. Please read the following agreement carefully. By continuing to use this software, you agree to the terms outlined below.
42 |
43 | **License Agreement**
44 |
45 | This software is licensed under the **Apache License 2.0**. In addition to the terms of the Apache License 2.0, the following additional terms apply to the use of Magic Resume:
46 |
47 | **I. Commercial Use License**
48 |
49 | 1. **Free Commercial Use**: Users can use the software for commercial purposes without modifying the code.
50 | 2. **Commercial License Required**: A commercial license is required if any of the following conditions are met:
51 | 1. You modify, develop, or alter the software, including but not limited to changes to the application name, logo, code, or functionality.
52 |
53 | **II. Contributor Agreement**
54 |
55 | As a contributor to Magic Resume, you agree to the following:
56 |
57 | 1. **License Adjustment**: The producer reserves the right to adjust the open-source license as needed, making it stricter or more lenient.
58 | 2. **Commercial Use**: Any code you contribute may be used for commercial purposes, including but not limited to cloud business operations.
59 |
60 | **III. Other Terms**
61 |
62 | 1. The interpretation of these terms is subject to the discretion of Magic Resume developers.
63 | 2. These terms may be updated, and users will be notified through the software when changes occur.
64 |
65 | For any questions or to request a commercial license, please contact the Magic Resume development team.
66 |
67 | Apart from the specific conditions mentioned above, all other rights and restrictions follow the Apache License 2.0. Detailed information about the Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
68 |
69 | ---
70 |
71 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
72 |
73 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
74 |
--------------------------------------------------------------------------------
/README.en-US.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # ✨ Magic Resume ✨
4 |
5 | [](https://opensource.org/licenses/Apache-2.0)
6 | 
7 | 
8 |
9 | [简体中文](./README.md) | English
10 |
11 |
12 |
13 | Magic Resume is a modern online resume editor that makes creating professional resumes simple and enjoyable. Built with Next.js and Framer Motion, it supports real-time preview and custom themes.
14 |
15 | ## 📸 Screenshots
16 |
17 | 
18 |
19 | ## ✨ Features
20 |
21 | - 🚀 Built with Next.js 14+
22 | - 💫 Smooth animations (Framer Motion)
23 | - 🎨 Custom theme support
24 | - 📱 Responsive design
25 | - 🌙 Dark mode
26 | - 📤 Export to PDF
27 | - 🔄 Real-time preview
28 | - 💾 Auto-save
29 | - 🔒 Local storage
30 |
31 | ## 🛠️ Tech Stack
32 |
33 | - Next.js 14+
34 | - TypeScript
35 | - Motion
36 | - Tiptap
37 | - Tailwind CSS
38 | - Zustand
39 | - Shadcn/ui
40 | - Lucide Icons
41 |
42 | ## 🚀 Quick Start
43 |
44 | 1. Clone the project
45 |
46 | ```bash
47 | git clone git@github.com:JOYCEQL/magic-resume.git
48 | cd magic-resume
49 | ```
50 |
51 | 2. Install dependencies
52 |
53 | ```bash
54 | pnpm install
55 | ```
56 |
57 | 3. Start development server
58 |
59 | ```bash
60 | pnpm dev
61 | ```
62 |
63 | 4. Open browser and visit `http://localhost:3000`
64 |
65 | ## 📦 Build and Deploy
66 |
67 | ```bash
68 | pnpm build
69 | ```
70 |
71 | ## ⚡ Deploy with Vercel
72 |
73 | You can deploy your own instance of Magic Resume with one click:
74 |
75 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FJOYCEQL%2Fmagic-resume)
76 |
77 | ## 🐳 Docker Deployment
78 |
79 | ### Docker Compose
80 |
81 | 1. Ensure you have Docker and Docker Compose installed
82 |
83 | 2. Run the following command in the project root directory:
84 |
85 | ```bash
86 | docker compose up -d
87 | ```
88 |
89 | This will:
90 |
91 | - Automatically build the application image
92 | - Start the container in the background
93 |
94 | ### Docker Hub
95 |
96 | The latest version of Magic Resume is available on Docker Hub:
97 |
98 | [Docker Hub](https://hub.docker.com/r/siyueqingchen/magic-resume/)
99 |
100 | ```bash
101 | docker pull siyueqingchen/magic-resume:main
102 | ```
103 |
104 | ## 📝 License
105 |
106 | This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details
107 |
108 | ## 🗺️ Roadmap
109 |
110 | - [x] AI-assisted writing
111 | - [x] Multi-language support
112 | - [ ] Support for more resume templates
113 | - [ ] Support for more export formats
114 | - [ ] Import PDF, Markdown, etc.
115 | - [ ] Custom model
116 | - [ ] Smart single page
117 | - [ ] Online resume hosting
118 |
119 | ## 📞 Contact
120 |
121 | You can follow the latest updates via:
122 |
123 | - Author: Siyue
124 | - X: @GuangzhouY81070
125 | - Discord: Join our community https://discord.gg/9mWgZrW3VN
126 | - Email: 18806723365@163.com
127 | - Project Homepage: https://github.com/JOYCEQL/magic-resume
128 |
129 | ## 🌟 Support
130 |
131 | If you find this project helpful, please give it a star ⭐️
132 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # ✨ Magic Resume ✨
4 |
5 | [](https://opensource.org/licenses/Apache-2.0)
6 | 
7 | 
8 |
9 | 简体中文 | [English](./README.en-US.md)
10 |
11 |
12 |
13 | Magic Resume 是一个现代化的在线简历编辑器,让创建专业简历变得简单有趣。基于 Next.js 和 Motion 构建,支持实时预览和自定义主题。
14 |
15 | ## 📸 项目截图
16 |
17 | 
18 |
19 | ## ✨ 特性
20 |
21 | - 🚀 基于 Next.js 14+ 构建
22 | - 💫 流畅的动画效果 (Motion)
23 | - 🎨 自定义主题支持
24 | - 🌙 深色模式
25 | - 📤 导出为 PDF
26 | - 🔄 实时预览
27 | - 💾 自动保存
28 | - 🔒 硬盘级存储
29 |
30 | ## 🛠️ 技术栈
31 |
32 | - Next.js 14+
33 | - TypeScript
34 | - Motion
35 | - Tiptap
36 | - Tailwind CSS
37 | - Zustand
38 | - Shadcn/ui
39 | - Lucide Icons
40 |
41 | ## 🚀 快速开始
42 |
43 | 1. 克隆项目
44 |
45 | ```bash
46 | git clone git@github.com:JOYCEQL/magic-resume.git
47 | cd magic-resume
48 | ```
49 |
50 | 2. 安装依赖
51 |
52 | ```bash
53 | pnpm install
54 | ```
55 |
56 | 3. 启动开发服务器
57 |
58 | ```bash
59 | pnpm dev
60 | ```
61 |
62 | 4. 打开浏览器访问 `http://localhost:3000`
63 |
64 | ## 📦 构建打包
65 |
66 | ```bash
67 | pnpm build
68 | ```
69 |
70 | ## ⚡ Vercel 部署
71 |
72 | 你可以一键部署自己的 Magic Resume 实例:
73 |
74 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FJOYCEQL%2Fmagic-resume)
75 |
76 | ## 🐳 Docker 部署
77 |
78 | ### Docker Compose
79 |
80 | 1. 确保你已经安装了 Docker 和 Docker Compose
81 |
82 | 2. 在项目根目录运行:
83 |
84 | ```bash
85 | docker compose up -d
86 | ```
87 |
88 | 这将会:
89 |
90 | - 自动构建应用镜像
91 | - 在后台启动容器
92 |
93 | ### Docker Hub
94 |
95 | 最新版本的 Magic Resume 已经发布在 Docker Hub:
96 |
97 | [Docker Hub](https://hub.docker.com/r/siyueqingchen/magic-resume/)
98 |
99 | ```bash
100 | docker pull siyueqingchen/magic-resume:main
101 | ```
102 |
103 | ## 📝 开源协议
104 |
105 | 本项目采用 Apache 2.0 协议,但有一些自定义的部分 - 查看 [LICENSE](LICENSE) 了解详情
106 |
107 | ## 🗺️ 路线图
108 |
109 | - [x] AI 辅助编写
110 | - [x] 多语言支持
111 | - [ ] 支持更多简历模板
112 | - [ ] 更多格式导出
113 | - [ ] 自定义模型
114 | - [ ] 智能一页
115 | - [ ] 导入 PDF, Markdown 等
116 | - [ ] 在线简历托管
117 |
118 | ## 📞 联系方式
119 |
120 | 可以通过以下方式关注最新动态:
121 |
122 | - 作者:SiYue
123 | - X: @GuangzhouY81070
124 | - Discord: 欢迎加入群组 https://discord.gg/9mWgZrW3VN
125 | - 用户群:加微信 qingchensiyue
126 | - 邮箱:18806723365@163.com
127 | - 项目主页:https://github.com/JOYCEQL/magic-resume
128 |
129 | ## 🌟 支持项目
130 |
131 | 如果这个项目对你有帮助,欢迎点个 star ⭐️
132 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "src/app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 |
2 | services:
3 | web:
4 | build:
5 | context: .
6 | dockerfile: Dockerfile
7 | ports:
8 | - "3000:3000"
9 | environment:
10 | - NODE_ENV=production
11 | restart: always
12 |
--------------------------------------------------------------------------------
/fonts/GeistMonoVF.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/fonts/GeistMonoVF.woff
--------------------------------------------------------------------------------
/fonts/fonts.conf:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | /var/task/fonts/
5 | /tmp/fonts-cache/
6 |
7 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import createNextIntlPlugin from "next-intl/plugin";
2 | import path from "path";
3 | import { fileURLToPath } from "url";
4 |
5 | const withNextIntl = createNextIntlPlugin();
6 |
7 | /** @type {import('next').NextConfig} */
8 | const config = {
9 | typescript: {
10 | ignoreBuildErrors: true,
11 | },
12 | output: "standalone",
13 | };
14 |
15 | export default withNextIntl(config);
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "magic-resume",
3 | "version": "1.0.0",
4 | "private": true,
5 | "packageManager": "pnpm@10.3.0",
6 | "scripts": {
7 | "dev": "next dev",
8 | "build": "next build",
9 | "start": "next start",
10 | "lint": "next lint"
11 | },
12 | "dependencies": {
13 | "@radix-ui/react-accordion": "^1.2.2",
14 | "@radix-ui/react-alert-dialog": "^1.1.2",
15 | "@radix-ui/react-dialog": "^1.1.2",
16 | "@radix-ui/react-dropdown-menu": "^2.1.2",
17 | "@radix-ui/react-hover-card": "^1.1.4",
18 | "@radix-ui/react-label": "^2.1.0",
19 | "@radix-ui/react-navigation-menu": "^1.2.1",
20 | "@radix-ui/react-popover": "^1.1.1",
21 | "@radix-ui/react-scroll-area": "^1.2.2",
22 | "@radix-ui/react-select": "^2.1.2",
23 | "@radix-ui/react-separator": "^1.1.0",
24 | "@radix-ui/react-slider": "^1.2.1",
25 | "@radix-ui/react-slot": "^1.0.2",
26 | "@radix-ui/react-switch": "^1.1.1",
27 | "@radix-ui/react-tabs": "^1.0.4",
28 | "@radix-ui/react-tooltip": "^1.1.3",
29 | "@sparticuz/chromium": "^131.0.0",
30 | "@tiptap/extension-bullet-list": "^2.10.2",
31 | "@tiptap/extension-color": "^2.4.0",
32 | "@tiptap/extension-highlight": "^2.9.1",
33 | "@tiptap/extension-list-item": "^2.4.0",
34 | "@tiptap/extension-ordered-list": "^2.10.2",
35 | "@tiptap/extension-text-align": "^2.4.0",
36 | "@tiptap/extension-text-style": "^2.4.0",
37 | "@tiptap/extension-underline": "^2.9.1",
38 | "@tiptap/pm": "^2.4.0",
39 | "@tiptap/react": "^2.4.0",
40 | "@tiptap/starter-kit": "^2.4.0",
41 | "@vercel/analytics": "^1.5.0",
42 | "chrome-aws-lambda": "^10.1.0",
43 | "class-variance-authority": "^0.7.1",
44 | "clsx": "^2.1.1",
45 | "cmdk": "1.0.0",
46 | "date-fns": "^3.6.0",
47 | "dayjs": "^1.11.12",
48 | "framer-motion": "^11.11.10",
49 | "html2pdf.js": "^0.10.2",
50 | "lodash": "^4.17.21",
51 | "lucide-react": "^0.379.0",
52 | "mark.js": "^8.11.1",
53 | "next": "14.2.3",
54 | "next-intl": "^3.26.3",
55 | "next-themes": "^0.4.3",
56 | "puppeteer": "^23.9.0",
57 | "puppeteer-core": "^23.9.0",
58 | "react": "^18",
59 | "react-day-picker": "^8.10.1",
60 | "react-dom": "^18",
61 | "react-resizable-panels": "^2.0.20",
62 | "sharp": "^0.33.5",
63 | "sonner": "^1.7.1",
64 | "tailwind-merge": "^2.6.0",
65 | "tailwindcss-animate": "^1.0.7",
66 | "uuid": "^11.0.5",
67 | "vaul": "^1.1.1",
68 | "zustand": "^4.5.4"
69 | },
70 | "devDependencies": {
71 | "@types/lodash": "^4.17.13",
72 | "@types/node": "^20",
73 | "@types/react": "^18",
74 | "@types/react-dom": "^18",
75 | "eslint": "^8",
76 | "eslint-config-next": "14.2.3",
77 | "postcss": "^8",
78 | "postcss-normalize": "^13.0.1",
79 | "sass": "^1.77.4",
80 | "tailwindcss": "^3.4.1",
81 | "typescript": "^5"
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | "postcss-normalize": {
5 | // 配置选项
6 | allowDuplicates: false // 不允许重复导入
7 | },
8 | tailwindcss: {}
9 | }
10 | };
11 |
12 | export default config;
13 |
--------------------------------------------------------------------------------
/public/avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/avatar.png
--------------------------------------------------------------------------------
/public/features/grammar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/features/grammar.png
--------------------------------------------------------------------------------
/public/features/polish.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/features/polish.png
--------------------------------------------------------------------------------
/public/features/svg/export-formats.svg:
--------------------------------------------------------------------------------
1 |
2 |
75 |
--------------------------------------------------------------------------------
/public/features/svg/grammar.svg:
--------------------------------------------------------------------------------
1 |
2 |
77 |
--------------------------------------------------------------------------------
/public/features/svg/local-storage.svg:
--------------------------------------------------------------------------------
1 |
2 |
68 |
--------------------------------------------------------------------------------
/public/features/svg/polish.svg:
--------------------------------------------------------------------------------
1 |
2 |
66 |
--------------------------------------------------------------------------------
/public/fonts/MiSans-VF.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/fonts/MiSans-VF.ttf
--------------------------------------------------------------------------------
/public/fonts/NotoSansSC.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/fonts/NotoSansSC.ttf
--------------------------------------------------------------------------------
/public/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/icon.png
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
4 | # Sitemap
5 | Sitemap: https://magicv.art/sitemap.xml
6 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/web-shot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/public/web-shot.png
--------------------------------------------------------------------------------
/src/actions/navigation.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { redirect } from "next/navigation";
4 |
5 | export async function GoDashboardAction() {
6 | redirect("/app/dashboard");
7 | }
8 | export async function GoResumesAction() {
9 | redirect("/app/dashboard/resumes");
10 | }
11 | export async function GoTemplatesAction() {
12 | redirect("/app/dashboard/templates");
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/(public)/[locale]/changelog/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Metadata } from "next";
3 | import { NextIntlClientProvider } from "next-intl";
4 | import { getMessages, getTranslations } from "next-intl/server";
5 | import LandingHeader from "@/components/home/LandingHeader";
6 | import Footer from "@/components/home/Footer";
7 |
8 | type Props = {
9 | children: ReactNode;
10 | params: { locale: string };
11 | };
12 |
13 | export async function generateMetadata({
14 | params: { locale },
15 | }: Props): Promise {
16 | const t = await getTranslations({ locale });
17 |
18 | return {
19 | title: t("home.changelog") + " - " + t("common.title"),
20 | };
21 | }
22 |
23 | export default async function ChangelogLayout({
24 | children,
25 | params: { locale },
26 | }: Props) {
27 | const messages = await getMessages();
28 |
29 | return (
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/src/app/(public)/[locale]/changelog/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { useTranslations } from "next-intl";
5 | import { ArrowLeft } from "lucide-react";
6 | import { useRouter, usePathname } from "next/navigation";
7 | import ChangelogTimeline from "@/components/shared/ChangelogTimeline";
8 | import { getChangelog } from "@/lib/getChangelog";
9 | import { cn } from "@/lib/utils";
10 |
11 | export default function ChangelogPage() {
12 | const t = useTranslations("home");
13 | const changelogEntries = getChangelog();
14 | const router = useRouter();
15 | const pathname = usePathname();
16 | const locale = pathname.split("/")[1];
17 |
18 | return (
19 |
20 |
21 |
28 |
29 |
30 | {t("changelog")}
31 |
32 |
33 |
34 |
35 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/app/(public)/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Metadata } from "next";
3 | import { notFound } from "next/navigation";
4 | import { NextIntlClientProvider } from "next-intl";
5 | import {
6 | getMessages,
7 | getTranslations,
8 | setRequestLocale
9 | } from "next-intl/server";
10 | import Document from "@/components/Document";
11 | import { locales } from "@/i18n/config";
12 | import { Providers } from "@/app/providers";
13 |
14 | type Props = {
15 | children: ReactNode;
16 | params: { locale: string };
17 | };
18 |
19 | export function generateStaticParams() {
20 | return locales.map((locale) => ({ locale }));
21 | }
22 |
23 | export async function generateMetadata({
24 | params: { locale }
25 | }: Props): Promise {
26 | const t = await getTranslations({ locale, namespace: "common" });
27 | const baseUrl = "https://magicv.art";
28 |
29 | return {
30 | title: t("title") + " - " + t("subtitle"),
31 | description: t("description"),
32 | alternates: {
33 | canonical: `${baseUrl}/${locale}`
34 | },
35 | openGraph: {
36 | title: t("title"),
37 | description: t("description"),
38 | locale: locale,
39 | alternateLocale: locale === "en" ? ["zh"] : ["en"]
40 | }
41 | };
42 | }
43 |
44 | export default async function LocaleLayout({
45 | children,
46 | params: { locale }
47 | }: Props) {
48 | setRequestLocale(locale);
49 |
50 | if (!locales.includes(locale as any)) {
51 | notFound();
52 | }
53 |
54 | const messages = await getMessages();
55 |
56 | return (
57 |
58 |
59 | {children}
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/(public)/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | import LandingHeader from "@/components/home/LandingHeader";
2 | import HeroSection from "@/components/home/HeroSection";
3 | import FeaturesSection from "@/components/home/FeaturesSection";
4 | import CTASection from "@/components/home/CTASection";
5 | import Footer from "@/components/home/Footer";
6 | import NewsAlert from "@/components/home/NewsAlert";
7 | import FAQSection from "@/components/home/FAQSection";
8 |
9 | export default function LandingPage() {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/api/grammar/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 | import { AIModelType } from "@/store/useAIConfigStore";
3 | import { AI_MODEL_CONFIGS } from "@/config/ai";
4 |
5 | export async function POST(req: NextRequest) {
6 | try {
7 | const body = await req.json();
8 | const { apiKey, model, content, modelType } = body;
9 |
10 | const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
11 | if (!modelConfig) {
12 | throw new Error("Invalid model type");
13 | }
14 |
15 | const response = await fetch(modelConfig.url, {
16 | method: "POST",
17 | headers: modelConfig.headers(apiKey),
18 |
19 | body: JSON.stringify({
20 | model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
21 | response_format: {
22 | type: "json_object"
23 | },
24 | messages: [
25 | {
26 | role: "system",
27 | content: `你是一个专业的中文简历错别字检查助手。请完整检查以下文本,不要遗漏,以下是要求:
28 | 1.所有考虑中文语境下的语法组词错误,包括拼写错误,语境用词错误,专业术语错误。
29 | 2.验证是否有遗漏文字未检查
30 | 3.对每个发现的问题,请按JSON格式返回
31 |
32 | 以下是示例格式:
33 | {
34 | "errors": [
35 | {
36 | "text": "错误的文本",
37 | "message": "详细的错误说明",
38 | "type": "spelling"或"grammar",
39 | "suggestions": ["建议修改1", "建议修改2"]
40 | }
41 | ]
42 | }
43 |
44 | 请确保返回的格式可以正常解析`
45 | },
46 | {
47 | role: "user",
48 | content: content
49 | }
50 | ]
51 | })
52 | });
53 |
54 | const data = await response.json();
55 | return NextResponse.json(data);
56 | } catch (error) {
57 | console.error("Error in grammar check:", error);
58 | return NextResponse.json(
59 | { error: "Failed to check grammar" },
60 | { status: 500 }
61 | );
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/app/api/polish/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { AIModelType } from "@/store/useAIConfigStore";
3 | import { AI_MODEL_CONFIGS } from "@/config/ai";
4 |
5 | export async function POST(req: Request) {
6 | try {
7 | const body = await req.json();
8 | const { apiKey, model, content, modelType, apiEndpoint } = body;
9 |
10 | const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
11 | if (!modelConfig) {
12 | throw new Error("Invalid model type");
13 | }
14 |
15 | const response = await fetch(modelConfig.url(apiEndpoint), {
16 | method: "POST",
17 | headers: modelConfig.headers(apiKey),
18 | body: JSON.stringify({
19 | model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
20 | messages: [
21 | {
22 | role: "system",
23 | content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。
24 |
25 | 优化原则:
26 | 1. 使用更专业的词汇和表达方式
27 | 2. 突出关键成就和技能
28 | 3. 保持简洁清晰
29 | 4. 使用主动语气
30 | 5. 保持原有信息的完整性
31 | 6. 保留我输入的格式
32 |
33 | 请直接返回优化后的文本,不要包含任何解释或其他内容。`,
34 | },
35 | {
36 | role: "user",
37 | content,
38 | },
39 | ],
40 | stream: true,
41 | }),
42 | });
43 |
44 | const encoder = new TextEncoder();
45 | const stream = new ReadableStream({
46 | async start(controller) {
47 | if (!response.body) {
48 | controller.close();
49 | return;
50 | }
51 |
52 | const reader = response.body.getReader();
53 | const decoder = new TextDecoder();
54 |
55 | try {
56 | while (true) {
57 | const { done, value } = await reader.read();
58 | if (done) {
59 | controller.close();
60 | break;
61 | }
62 |
63 | const chunk = decoder.decode(value);
64 | const lines = chunk
65 | .split("\n")
66 | .filter((line) => line.trim() !== "");
67 |
68 | for (const line of lines) {
69 | if (line.includes("[DONE]")) continue;
70 | if (!line.startsWith("data:")) continue;
71 |
72 | try {
73 | const data = JSON.parse(line.slice(5));
74 | const content = data.choices[0]?.delta?.content;
75 | if (content) {
76 | controller.enqueue(encoder.encode(content));
77 | }
78 | } catch (e) {
79 | console.error("Error parsing JSON:", e);
80 | }
81 | }
82 | }
83 | } catch (error) {
84 | console.error("Stream reading error:", error);
85 | controller.error(error);
86 | }
87 | },
88 | });
89 |
90 | return new Response(stream, {
91 | headers: {
92 | "Content-Type": "text/event-stream",
93 | "Cache-Control": "no-cache",
94 | Connection: "keep-alive",
95 | },
96 | });
97 | } catch (error) {
98 | console.error("Polish error:", error);
99 | return NextResponse.json(
100 | { error: "Failed to polish content" },
101 | { status: 500 }
102 | );
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/src/app/api/proxy/image/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from "next/server";
2 |
3 | // resolve image proxy
4 | export async function GET(request: NextRequest) {
5 | try {
6 | const { searchParams } = new URL(request.url);
7 | const imageUrl = searchParams.get("url");
8 |
9 | if (!imageUrl) {
10 | console.error("缺少图片URL参数");
11 | return NextResponse.json({ error: "缺少图片URL参数" }, { status: 400 });
12 | }
13 |
14 | let parsedUrl;
15 | try {
16 | parsedUrl = new URL(imageUrl);
17 | } catch (e) {
18 | console.error(`图片URL格式不正确: ${imageUrl}`);
19 | return NextResponse.json({ error: "图片URL格式不正确" }, { status: 400 });
20 | }
21 |
22 | if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
23 | console.error(`不支持的URL协议: ${parsedUrl.protocol}`);
24 | return NextResponse.json(
25 | { error: "只支持HTTP和HTTPS协议" },
26 | { status: 400 }
27 | );
28 | }
29 |
30 | let response;
31 | try {
32 | response = await fetch(imageUrl, {
33 | headers: {
34 | // 模拟浏览器请求
35 | "User-Agent":
36 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
37 | Accept:
38 | "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
39 | "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
40 | Referer: parsedUrl.origin,
41 | },
42 | });
43 | } catch (error: any) {
44 | console.error(`获取图片失败: ${error.message || "未知错误"}`);
45 | return NextResponse.json(
46 | { error: `获取图片失败: ${error.message || "未知错误"}` },
47 | { status: 500 }
48 | );
49 | }
50 |
51 | // 检查响应状态
52 | if (!response.ok) {
53 | console.error(
54 | `图片服务器返回错误: ${response.status} ${response.statusText}`
55 | );
56 | return NextResponse.json(
57 | { error: `获取图片失败: ${response.status} ${response.statusText}` },
58 | { status: response.status }
59 | );
60 | }
61 |
62 | let imageBuffer;
63 | try {
64 | imageBuffer = await response.arrayBuffer();
65 | console.log(`成功获取图片,大小: ${imageBuffer.byteLength} 字节`);
66 | } catch (error: any) {
67 | console.error(`读取图片内容失败: ${error.message || "未知错误"}`);
68 | return NextResponse.json(
69 | { error: `读取图片内容失败: ${error.message || "未知错误"}` },
70 | { status: 500 }
71 | );
72 | }
73 |
74 | if (imageBuffer.byteLength === 0) {
75 | console.error("图片内容为空");
76 | return NextResponse.json({ error: "图片内容为空" }, { status: 400 });
77 | }
78 |
79 | const contentType = response.headers.get("content-type") || "image/jpeg";
80 |
81 | return new NextResponse(imageBuffer, {
82 | headers: {
83 | "Content-Type": contentType,
84 | "Cache-Control":
85 | "no-store, no-cache, must-revalidate, proxy-revalidate",
86 | Pragma: "no-cache",
87 | Expires: "0",
88 | "Surrogate-Control": "no-store",
89 | "Access-Control-Allow-Origin": "*",
90 | "Access-Control-Allow-Methods": "GET, OPTIONS",
91 | "Access-Control-Allow-Headers": "Content-Type",
92 | },
93 | });
94 | } catch (error: any) {
95 | console.error("图片代理未处理的错误:", error);
96 | return NextResponse.json(
97 | { error: `处理图片请求时出错: ${error.message || "未知错误"}` },
98 | { status: 500 }
99 | );
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/app/app/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Metadata } from "next";
3 | import { NextIntlClientProvider } from "next-intl";
4 | import { getLocale, getMessages, getTranslations } from "next-intl/server";
5 | import Document from "@/components/Document";
6 | import { Providers } from "@/app/providers";
7 | import Client from "./client";
8 | type Props = {
9 | children: ReactNode;
10 | params: {
11 | locale: string;
12 | };
13 | };
14 | export async function generateMetadata({
15 | params: { locale },
16 | }: Props): Promise {
17 | const t = await getTranslations({ locale, namespace: "common" });
18 | return {
19 | title: t("title") + " - " + t("dashboard"),
20 | };
21 | }
22 | export default async function LocaleLayout({ children }: Props) {
23 | const locale = await getLocale();
24 |
25 | const messages = await getMessages();
26 |
27 | return (
28 |
29 |
30 |
31 | {children}
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | export default function Dashboard() {
4 | redirect("/app/dashboard/resumes");
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next";
2 | import { NextIntlClientProvider } from "next-intl";
3 | import { ReactNode } from "react";
4 | import { getLocale, getMessages, getTranslations } from "next-intl/server";
5 | import Document from "@/components/Document";
6 |
7 | type Props = {
8 | children: ReactNode;
9 | params: { locale: string };
10 | };
11 |
12 | export async function generateMetadata({
13 | params: { locale }
14 | }: Props): Promise {
15 | const t = await getTranslations({ locale, namespace: "common" });
16 | return {
17 | title: t("title")
18 | };
19 | }
20 | export default async function LocaleLayout({ children }: Props) {
21 | const locale = await getLocale();
22 |
23 | const messages = await getMessages();
24 |
25 | return (
26 |
27 |
28 | {children}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/app/app/workbench/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Metadata } from "next";
3 | import { NextIntlClientProvider } from "next-intl";
4 | import { getLocale, getMessages, getTranslations } from "next-intl/server";
5 | import Document from "@/components/Document";
6 | import { Providers } from "@/app/providers";
7 | import { Toaster } from "@/components/ui/sonner";
8 |
9 | type Props = {
10 | children: ReactNode;
11 | params: {
12 | locale: string;
13 | };
14 | };
15 |
16 | export async function generateMetadata({
17 | params: { locale }
18 | }: Props): Promise {
19 | const t = await getTranslations({ locale, namespace: "common" });
20 | return {
21 | title: t("title") + " - " + t("dashboard")
22 | };
23 | }
24 |
25 | export default async function LocaleLayout({ children }: Props) {
26 | const locale = await getLocale();
27 |
28 | const messages = await getMessages();
29 |
30 | return (
31 |
35 |
36 | {children}
37 |
38 |
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/src/app/favicon.ico
--------------------------------------------------------------------------------
/src/app/font.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "MiSans VF";
3 | src: url("/fonts/MiSans-VF.ttf") format("woff2");
4 | font-weight: normal;
5 | font-style: normal;
6 | font-display: swap;
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react";
2 | import { Metadata } from "next";
3 | import "./globals.css";
4 | import "./font.css";
5 |
6 | type Props = {
7 | children: ReactNode;
8 | };
9 |
10 | export const metadata: Metadata = {
11 | metadataBase: new URL("https://magicv.art"),
12 | robots: {
13 | index: true,
14 | follow: true,
15 | googleBot: {
16 | index: true,
17 | follow: true,
18 | "max-video-preview": -1,
19 | "max-image-preview": "large",
20 | "max-snippet": -1,
21 | },
22 | },
23 | };
24 |
25 | export default function RootLayout({ children }: Props) {
26 | return children;
27 | }
28 |
--------------------------------------------------------------------------------
/src/app/manifest.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from "next";
2 |
3 | export default function manifest(): MetadataRoute.Manifest {
4 | return {
5 | name: "Magic Resume",
6 | short_name: "Magic Resume",
7 | description: "A Progressive Web App built with Next.js",
8 | start_url: "/",
9 | display: "standalone",
10 | background_color: "#ffffff",
11 | theme_color: "#000000",
12 | icons: [
13 | {
14 | src: "/icon.png",
15 | sizes: "512x512",
16 | type: "image/png"
17 | }
18 | ]
19 | };
20 | }
21 |
--------------------------------------------------------------------------------
/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ThemeProvider } from "next-themes";
4 | import { Analytics } from "@vercel/analytics/next";
5 |
6 | export function Providers({ children }: { children: React.ReactNode }) {
7 | return (
8 |
15 | {children}
16 |
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import { MetadataRoute } from "next";
2 |
3 | export default function sitemap(): MetadataRoute.Sitemap {
4 | const baseUrl = "https://magicv.art/";
5 |
6 | const routes = ["zh", "en"];
7 |
8 | const sitemap: MetadataRoute.Sitemap = routes.map((route) => ({
9 | url: `${baseUrl}${route}`,
10 | lastModified: new Date(),
11 | changeFrequency: "daily",
12 | priority: 1.0
13 | }));
14 |
15 | return sitemap;
16 | }
17 |
--------------------------------------------------------------------------------
/src/assets/images/logo@2x.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/assets/images/template-cover/classic.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/src/assets/images/template-cover/classic.png
--------------------------------------------------------------------------------
/src/assets/images/template-cover/left-right.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/src/assets/images/template-cover/left-right.png
--------------------------------------------------------------------------------
/src/assets/images/template-cover/modern.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/src/assets/images/template-cover/modern.png
--------------------------------------------------------------------------------
/src/assets/images/template-cover/timeline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JOYCEQL/magic-resume/fdd43ec09ef478d04f59d94a30908c4ed6a3007c/src/assets/images/template-cover/timeline.png
--------------------------------------------------------------------------------
/src/components/Document.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from "next/font/google";
2 | import { ReactNode } from "react";
3 |
4 | const inter = Inter({
5 | subsets: ["latin"],
6 | });
7 |
8 | type Props = {
9 | children: ReactNode;
10 | locale: string;
11 | bodyClassName?: string;
12 | };
13 |
14 | export default function Document({ children, locale, bodyClassName }: Props) {
15 | return (
16 |
17 |
18 |
19 |
20 | {children}
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/ai/icon/IconDeepseek.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | interface DeepSeekLogoProps extends React.SVGProps {
4 | size?: number;
5 | className?: string;
6 | }
7 |
8 | const DeepSeekLogo = ({
9 | size = 24,
10 | className = "",
11 | ...props
12 | }: DeepSeekLogoProps) => {
13 | return (
14 |
30 | );
31 | };
32 |
33 | export default DeepSeekLogo;
34 |
--------------------------------------------------------------------------------
/src/components/ai/icon/IconDoubao.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | interface DoubaoLogoProps extends React.SVGProps {
4 | size?: number;
5 | className?: string;
6 | }
7 |
8 | const DoubaoLogo = ({
9 | size = 24,
10 | className = "",
11 | ...props
12 | }: DoubaoLogoProps) => {
13 | return (
14 |
41 | );
42 | };
43 |
44 | export default DoubaoLogo;
45 |
--------------------------------------------------------------------------------
/src/components/ai/icon/IconOpenAi.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 |
3 | interface OpenAiLogoProps extends React.SVGProps {
4 | size?: number;
5 | className?: string;
6 | }
7 |
8 | const OpenAiLogo = ({
9 | size = 24,
10 | className = "",
11 | ...props
12 | }: OpenAiLogoProps) => {
13 | return (
14 |
27 | );
28 | };
29 |
30 | export default OpenAiLogo;
31 |
--------------------------------------------------------------------------------
/src/components/editor/basic/AlignSelector.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { cn } from "@/lib/utils";
3 |
4 | interface AlignSelectorProps {
5 | value: "left" | "center" | "right";
6 | onChange: (value: "left" | "center" | "right") => void;
7 | }
8 |
9 | const AlignSelector: React.FC = ({ value, onChange }) => {
10 | const layouts = [
11 | {
12 | value: "left",
13 | icon: (
14 |
26 | ),
27 | tooltip: "左对齐",
28 | },
29 | {
30 | value: "center",
31 | icon: (
32 |
50 | ),
51 | tooltip: "居中",
52 | },
53 | {
54 | value: "right",
55 | icon: (
56 |
68 | ),
69 | tooltip: "右对齐",
70 | },
71 | ];
72 |
73 | return (
74 |
75 | {layouts.map((layout) => (
76 |
91 | ))}
92 |
93 | );
94 | };
95 |
96 | export default AlignSelector;
97 |
--------------------------------------------------------------------------------
/src/components/editor/custom/CustomPanel.tsx:
--------------------------------------------------------------------------------
1 | import { memo } from "react";
2 | import { Button } from "@/components/ui/button";
3 | import { cn } from "@/lib/utils";
4 | import { Reorder } from "framer-motion";
5 | import { PlusCircle } from "lucide-react";
6 | import CustomItem from "./CustomItem";
7 | import { useResumeStore } from "@/store/useResumeStore";
8 | import { CustomItem as CustomItemType } from "@/types/resume";
9 |
10 | const CustomPanel = memo(({ sectionId }: { sectionId: string }) => {
11 | const { addCustomItem, updateCustomData, activeResume } = useResumeStore();
12 | const { customData } = activeResume || {};
13 | const items = customData?.[sectionId] || [];
14 | const handleCreateItem = () => {
15 | addCustomItem(sectionId);
16 | };
17 |
18 | return (
19 |
25 |
{
29 | updateCustomData(sectionId, newOrder);
30 | }}
31 | className="space-y-3"
32 | >
33 | {items.map((item: CustomItemType) => (
34 |
35 | ))}
36 |
37 |
41 |
42 |
43 | );
44 | });
45 |
46 | CustomPanel.displayName = "CustomPanel";
47 |
48 | export default CustomPanel;
49 |
--------------------------------------------------------------------------------
/src/components/editor/education/EducationPanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { cn } from "@/lib/utils";
3 | import { useResumeStore } from "@/store/useResumeStore";
4 | import { Reorder } from "framer-motion";
5 | import { PlusCircle } from "lucide-react";
6 | import { useTranslations } from "next-intl";
7 | import { Button } from "@/components/ui/button";
8 | import EducationItem from "./EducationItem";
9 | import { Education } from "@/types/resume";
10 | import { generateUUID } from "@/utils/uuid";
11 |
12 | const EducationPanel = () => {
13 | const t = useTranslations('workbench.educationPanel');
14 | const { activeResume, updateEducation, updateEducationBatch } =
15 | useResumeStore();
16 | const { education = [] } = activeResume || {};
17 | const handleCreateProject = () => {
18 | const newEducation: Education = {
19 | id: generateUUID(),
20 | school: t('defaultProject.school'),
21 | major: t('defaultProject.major'),
22 | degree: t('defaultProject.degree'),
23 | startDate: "2015-09-01",
24 | endDate: "2019-06-30",
25 | description: "",
26 | visible: true,
27 | };
28 | updateEducation(newEducation);
29 | };
30 |
31 | return (
32 |
39 |
{
43 | updateEducationBatch(newOrder);
44 | }}
45 | className="space-y-3"
46 | >
47 | {(education || []).map((education) => (
48 |
52 | ))}
53 |
54 |
58 |
59 |
60 | );
61 | };
62 |
63 | export default EducationPanel;
64 |
--------------------------------------------------------------------------------
/src/components/editor/experience/ExperiencePanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { cn } from "@/lib/utils";
3 | import { Reorder } from "framer-motion";
4 | import { PlusCircle } from "lucide-react";
5 | import { Button } from "@/components/ui/button";
6 | import { useTranslations } from "next-intl";
7 | import ExperienceItem from "./ExperienceItem";
8 | import { Experience } from "@/types/resume";
9 | import { useResumeStore } from "@/store/useResumeStore";
10 | import { generateUUID } from "@/utils/uuid";
11 |
12 | const ExperiencePanel = () => {
13 | const t = useTranslations("workbench.experiencePanel");
14 | const { activeResume, updateExperience, updateExperienceBatch } =
15 | useResumeStore();
16 | const { experience = [] } = activeResume || {};
17 | const handleCreateProject = () => {
18 | const newProject: Experience = {
19 | id: generateUUID(),
20 | company: t("defaultProject.company"),
21 | position: t("defaultProject.position"),
22 | date: t("defaultProject.date"),
23 | details: t("defaultProject.details"),
24 | visible: true,
25 | };
26 | updateExperience(newProject);
27 | };
28 |
29 | return (
30 |
36 |
{
40 | updateExperienceBatch(newOrder);
41 | }}
42 | className="space-y-3"
43 | >
44 | {experience.map((item) => (
45 |
46 | ))}
47 |
48 |
52 |
53 |
54 | );
55 | };
56 |
57 | export default ExperiencePanel;
58 |
--------------------------------------------------------------------------------
/src/components/editor/layout/LayoutSetting.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Reorder } from "framer-motion";
3 | import { MenuSection } from "@/types/resume";
4 | import LayoutItem from "./LayoutItem";
5 |
6 | interface LayoutPanelProps {
7 | menuSections: MenuSection[];
8 | activeSection: string;
9 | setActiveSection: (id: string) => void;
10 | toggleSectionVisibility: (id: string) => void;
11 | updateMenuSections: (sections: MenuSection[]) => void;
12 | reorderSections: (sections: MenuSection[]) => void;
13 | }
14 |
15 | const LayoutSetting = ({
16 | menuSections,
17 | activeSection,
18 | setActiveSection,
19 | toggleSectionVisibility,
20 | updateMenuSections,
21 | reorderSections,
22 | }: LayoutPanelProps) => {
23 | const basicSection = menuSections.find((item) => item.id === "basic");
24 | const draggableSections = menuSections.filter((item) => item.id !== "basic");
25 |
26 | return (
27 |
28 | {basicSection && (
29 |
38 | )}
39 |
40 | {
44 | const updatedSections = [
45 | ...menuSections.filter((item) => item.id === "basic"),
46 | ...newOrder,
47 | ];
48 | reorderSections(updatedSections);
49 | }}
50 | className="space-y-2"
51 | >
52 | {draggableSections.map((item) => (
53 |
62 | ))}
63 |
64 |
65 | );
66 | };
67 |
68 | export default LayoutSetting;
69 |
--------------------------------------------------------------------------------
/src/components/editor/project/ProjectPanel.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { useResumeStore } from "@/store/useResumeStore";
3 | import { Reorder } from "framer-motion";
4 | import { PlusCircle } from "lucide-react";
5 | import { useTranslations } from "next-intl";
6 | import { Button } from "@/components/ui/button";
7 | import ProjectItem from "./ProjectItem";
8 | import { Project } from "@/types/resume";
9 | import { generateUUID } from "@/utils/uuid";
10 |
11 | const ProjectPanel = () => {
12 | const t = useTranslations("workbench.projectPanel");
13 | const { activeResume, updateProjects, updateProjectsBatch } =
14 | useResumeStore();
15 | const { projects = [] } = activeResume || {};
16 | const handleCreateProject = () => {
17 | const newProject: Project = {
18 | id: generateUUID(),
19 | name: t("defaultProject.name"),
20 | role: t("defaultProject.role"),
21 | date: t("defaultProject.date"),
22 | description: t("defaultProject.description"),
23 | visible: true,
24 | };
25 | updateProjects(newProject);
26 | };
27 |
28 | return (
29 |
35 |
{
39 | updateProjectsBatch(newOrder);
40 | }}
41 | className="space-y-3"
42 | >
43 | {projects.map((project) => (
44 |
45 | ))}
46 |
47 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default ProjectPanel;
57 |
--------------------------------------------------------------------------------
/src/components/editor/skills/SkillPanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useResumeStore } from "@/store/useResumeStore";
3 | import { cn } from "@/lib/utils";
4 | import Field from "../Field";
5 |
6 | const SkillPanel = () => {
7 | const { activeResume, updateSkillContent } = useResumeStore();
8 | const { skillContent } = activeResume || {};
9 | const handleChange = (value: string) => {
10 | updateSkillContent(value);
11 | };
12 |
13 | return (
14 |
22 |
28 |
29 | );
30 | };
31 |
32 | export default SkillPanel;
33 |
--------------------------------------------------------------------------------
/src/components/home/CTASection.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Button } from "@/components/ui/button";
3 | import { ArrowRight } from "lucide-react";
4 | import { useTranslations } from "next-intl";
5 | import ScrollBackground from "./client/ScrollBackground";
6 | import AnimatedFeature from "./client/AnimatedFeature";
7 | import GoDashboard from "./GoDashboard";
8 |
9 | export default function CTASection() {
10 | const t = useTranslations("home");
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
{t("cta.title")}
19 |
20 | {t("cta.description")}
21 |
22 |
23 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/home/FAQSection.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useTranslations } from "next-intl";
3 | import {
4 | Accordion,
5 | AccordionContent,
6 | AccordionItem,
7 | AccordionTrigger,
8 | } from "@/components/ui/accordion";
9 |
10 | export default function FAQSection() {
11 | const t = useTranslations("home.faq");
12 | const faqItems = t.raw("items");
13 |
14 | return (
15 |
16 |
17 |
18 |
19 | {t("title")}
20 |
21 |
22 | {faqItems.map(
23 | (item: { question: string; answer: string }, index: number) => (
24 |
25 |
26 | {item.question}
27 |
28 |
29 | {item.answer}
30 |
31 |
32 | )
33 | )}
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/home/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { useTranslations } from "next-intl";
2 |
3 | export default function Footer() {
4 | const t = useTranslations("home");
5 |
6 | return (
7 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/home/GoDashboard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | GoDashboardAction,
5 | GoTemplatesAction,
6 | GoResumesAction,
7 | } from "@/actions/navigation";
8 |
9 | export default function GoDashboard({
10 | children,
11 | type = "dashboard",
12 | }: {
13 | children: React.ReactNode;
14 | type?: "dashboard" | "templates" | "resumes";
15 | }) {
16 | const actionMap = {
17 | dashboard: GoDashboardAction,
18 | resumes: GoResumesAction,
19 | templates: GoTemplatesAction,
20 | };
21 |
22 | return ;
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/home/HeroSection.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import { useTranslations } from "next-intl";
4 | import { Button } from "@/components/ui/button";
5 | import { Sparkles, ArrowRight } from "lucide-react";
6 | import ScrollBackground from "./client/ScrollBackground";
7 | import AnimatedFeature from "./client/AnimatedFeature";
8 | import GoDashboard from "./GoDashboard";
9 |
10 | export default function HeroSection() {
11 | const t = useTranslations("home");
12 |
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | {t("hero.badge")}
25 |
26 |
27 | {t("hero.title")}
28 |
29 |
30 | {t("hero.subtitle")}
31 |
32 |
33 |
34 |
42 |
43 |
44 |
45 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
74 |
75 |
76 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/home/LandingHeader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 | import { useTranslations } from "next-intl";
7 | import { FileText, Menu, Moon, Sun, X } from "lucide-react";
8 |
9 | import { Button } from "@/components/ui/button";
10 | import Logo from "@/components/shared/Logo";
11 | import ThemeToggle from "@/components/shared/ThemeToggle";
12 | import LanguageSwitch from "@/components/shared/LanguageSwitch";
13 | import { GitHubStars } from "@/components/shared/GitHubStars";
14 | import ScrollHeader from "./client/ScrollHeader";
15 | import MobileMenu from "./client/MobileMenu";
16 | import GoDashboard from "./GoDashboard";
17 |
18 | export default function LandingHeader() {
19 | const t = useTranslations("home");
20 | const pathname = usePathname();
21 | const locale = pathname.split("/")[1]; // 从路径中获取语言代码
22 | const [isMenuOpen, setIsMenuOpen] = useState(false);
23 |
24 | return (
25 | <>
26 |
27 |
28 |
29 |
(window.location.href = `/${locale}/`)}
32 | >
33 |
34 | {t("header.title")}
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | {/*
51 |
52 | {t("changelog") || "更新日志"}
53 | */}
54 |
55 |
56 |
62 |
63 |
64 |
65 |
75 |
76 |
77 |
78 |
79 | setIsMenuOpen(false)}
82 | buttonText={t("header.startButton")}
83 | />
84 | >
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/src/components/home/NewsAlert.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowRight, Sparkles } from "lucide-react";
2 | import { useTranslations } from "next-intl";
3 | import { cn } from "@/lib/utils";
4 |
5 | interface NewsAlertProps {
6 | className?: string;
7 | }
8 |
9 | export default function NewsAlert({ className }: NewsAlertProps) {
10 | const t = useTranslations("home");
11 |
12 | return (
13 |
23 |
24 | {t("news.content")}
25 |
26 | {/*
*/}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/home/client/AnimatedFeature.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { motion } from "framer-motion";
3 |
4 | interface AnimatedFeatureProps {
5 | children: React.ReactNode;
6 | delay?: number;
7 | }
8 |
9 | export default function AnimatedFeature({ children, delay = 0 }: AnimatedFeatureProps) {
10 | return (
11 |
17 | {children}
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/home/client/MenuToggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useState } from "react";
3 | import { X, Menu } from "lucide-react";
4 |
5 | interface MenuToggleProps {
6 | onToggle: (isOpen: boolean) => void;
7 | }
8 |
9 | export default function MenuToggle({ onToggle }: MenuToggleProps) {
10 | const [isOpen, setIsOpen] = useState(false);
11 |
12 | const handleToggle = () => {
13 | const newState = !isOpen;
14 | setIsOpen(newState);
15 | onToggle(newState);
16 | };
17 |
18 | return (
19 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/src/components/home/client/MobileMenu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { motion } from "framer-motion";
4 | import { useTranslations, useLocale } from "next-intl";
5 | import Link from "next/link";
6 | import { Sun, Moon, FileText } from "lucide-react";
7 | import { Button } from "@/components/ui/button";
8 | import ThemeToggle from "@/components/shared/ThemeToggle";
9 | import LanguageSwitch from "@/components/shared/LanguageSwitch";
10 | import { GitHubStars } from "@/components/shared/GitHubStars";
11 |
12 | interface MobileMenuProps {
13 | isOpen: boolean;
14 | onClose: () => void;
15 | buttonText: string;
16 | extraItems?: Array<{
17 | icon: React.ReactNode;
18 | label: string;
19 | component: React.ReactNode;
20 | }>;
21 | }
22 |
23 | export default function MobileMenu({
24 | isOpen,
25 | onClose,
26 | buttonText,
27 | extraItems = [],
28 | }: MobileMenuProps) {
29 | const t = useTranslations("home");
30 | const locale = useLocale();
31 |
32 | if (!isOpen) return null;
33 |
34 | return (
35 |
41 |
42 |
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/components/home/client/ScrollBackground.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { motion, useScroll } from "framer-motion";
3 |
4 | export default function ScrollBackground() {
5 | const { scrollY } = useScroll();
6 |
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/home/client/ScrollHeader.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useState, useEffect } from "react";
3 | import { useScroll } from "framer-motion";
4 |
5 | interface ScrollHeaderProps {
6 | children: React.ReactNode;
7 | }
8 |
9 | export default function ScrollHeader({ children }: ScrollHeaderProps) {
10 | const { scrollY } = useScroll();
11 | const [isScrolled, setIsScrolled] = useState(false);
12 |
13 | useEffect(() => {
14 | const unsubscribe = scrollY.on("change", (latest) => {
15 | setIsScrolled(latest > 0);
16 | });
17 |
18 | return () => {
19 | unsubscribe();
20 | };
21 | }, [scrollY]);
22 |
23 | return (
24 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/src/components/magicui/dock.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import React, { PropsWithChildren } from "react";
3 | import { motion } from "framer-motion";
4 |
5 | interface DockProps
6 | extends PropsWithChildren> {
7 | className?: string;
8 | }
9 |
10 | export function Dock({ children, className, ...props }: DockProps) {
11 | // Convert children to array to handle them
12 | const childrenArray = React.Children.toArray(children);
13 |
14 | // Find the index of TemplateSheet for splitting
15 | const templateSheetIndex = childrenArray.findIndex((child) => {
16 | if (React.isValidElement(child)) {
17 | const tooltip = child.props.children;
18 | if (React.isValidElement(tooltip)) {
19 | const trigger = tooltip.props.children.find(
20 | (child: any) => child?.type?.name === "TooltipTrigger"
21 | );
22 | if (trigger) {
23 | const content = trigger.props.children;
24 | if (React.isValidElement(content)) {
25 | const icon = content.props.children;
26 | return (
27 | React.isValidElement(icon) && icon.type?.name === "TemplateSheet"
28 | );
29 | }
30 | }
31 | }
32 | }
33 | return false;
34 | });
35 |
36 | // If TemplateSheet is not found, render all children in a single group
37 | if (templateSheetIndex === -1) {
38 | return (
39 |
48 | );
49 | }
50 |
51 | // Split children into three groups
52 | const topChildren = childrenArray.slice(0, templateSheetIndex);
53 | const middleChild = childrenArray[templateSheetIndex];
54 | const bottomChildren = childrenArray.slice(templateSheetIndex + 1);
55 |
56 | return (
57 |
64 | {/* Top group */}
65 | {topChildren.length > 0 && (
66 |
{topChildren}
67 | )}
68 |
69 | {/* Decorative line */}
70 | {topChildren.length > 0 && (
71 |
72 | )}
73 |
74 | {/* Middle (TemplateSheet) */}
75 | {middleChild}
76 |
77 | {/* Decorative line */}
78 | {bottomChildren.length > 0 && (
79 |
80 | )}
81 |
82 | {/* Bottom group */}
83 | {bottomChildren.length > 0 && (
84 |
{bottomChildren}
85 | )}
86 |
87 | );
88 | }
89 |
90 | interface DockIconProps extends PropsWithChildren {
91 | className?: string;
92 | onClick?: () => void;
93 | }
94 |
95 | export function DockIcon({ children, className, onClick }: DockIconProps) {
96 | return (
97 |
106 | {children}
107 |
108 | );
109 | }
110 |
--------------------------------------------------------------------------------
/src/components/preview/CustomSection.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { AnimatePresence, motion } from "framer-motion";
3 | import SectionTitle from "./SectionTitle";
4 | import { GlobalSettings, CustomItem } from "@/types/resume";
5 | import { useResumeStore } from "@/store/useResumeStore";
6 |
7 | interface CustomSectionProps {
8 | sectionId: string;
9 | title: string;
10 | items: CustomItem[];
11 | globalSettings?: GlobalSettings;
12 | showTitle?: boolean;
13 | }
14 |
15 | const CustomSection = ({
16 | sectionId,
17 | title,
18 | items,
19 | globalSettings,
20 | showTitle = true,
21 | }: CustomSectionProps) => {
22 | const { setActiveSection } = useResumeStore();
23 | const visibleItems = items?.filter((item) => {
24 | return item.visible && (item.title || item.description);
25 | });
26 |
27 | const centerSubtitle = globalSettings?.centerSubtitle;
28 | const gridColumns = centerSubtitle ? 3 : 2;
29 |
30 | return (
31 | {
37 | setActiveSection(sectionId);
38 | }}
39 | >
40 |
46 |
47 | {visibleItems.map((item) => (
48 |
55 | *:last-child]:justify-self-end`}
58 | >
59 |
60 |
66 | {item.title}
67 |
68 |
69 |
70 | {centerSubtitle && (
71 |
72 | {item.subtitle}
73 |
74 | )}
75 |
76 |
77 | {item.dateRange}
78 |
79 |
80 |
81 | {!centerSubtitle && item.subtitle && (
82 |
83 | {item.subtitle}
84 |
85 | )}
86 |
87 | {item.description && (
88 |
97 | )}
98 |
99 | ))}
100 |
101 |
102 | );
103 | };
104 |
105 | export default CustomSection;
106 |
--------------------------------------------------------------------------------
/src/components/preview/EducationSection.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { AnimatePresence, motion } from "framer-motion";
3 | import { Education, GlobalSettings } from "@/types/resume";
4 | import SectionTitle from "./SectionTitle";
5 | import { useResumeStore } from "@/store/useResumeStore";
6 | import { useLocale } from "next-intl";
7 |
8 | interface EducationSectionProps {
9 | education?: Education[];
10 | globalSettings?: GlobalSettings;
11 | showTitle?: boolean;
12 | }
13 |
14 | const EducationSection = ({
15 | education,
16 | globalSettings,
17 | showTitle = true,
18 | }: EducationSectionProps) => {
19 | const { setActiveSection } = useResumeStore();
20 | const locale = useLocale();
21 | const visibleEducation = education?.filter((edu) => edu.visible);
22 | return (
23 | {
36 | setActiveSection("education");
37 | }}
38 | >
39 |
44 |
45 | {visibleEducation?.map((edu) => (
46 |
53 | *:last-child]:justify-self-end`}
58 | >
59 |
65 | {edu.school}
66 |
67 |
68 | {globalSettings?.centerSubtitle && (
69 |
70 | {[edu.major, edu.degree].filter(Boolean).join(" · ")}
71 | {edu.gpa && ` · GPA ${edu.gpa}`}
72 |
73 | )}
74 |
75 |
79 | {`${new Date(edu.startDate).toLocaleDateString(
80 | locale
81 | )} - ${new Date(edu.endDate).toLocaleDateString(locale)}`}
82 |
83 |
84 |
85 | {!globalSettings?.centerSubtitle && (
86 |
87 | {[edu.major, edu.degree].filter(Boolean).join(" · ")}
88 | {edu.gpa && ` · GPA ${edu.gpa}`}
89 |
90 | )}
91 |
92 | {edu.description && (
93 |
102 | )}
103 |
104 | ))}
105 |
106 |
107 | );
108 | };
109 |
110 | export default EducationSection;
111 |
--------------------------------------------------------------------------------
/src/components/preview/ExperienceSection.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 | import { motion, AnimatePresence } from "framer-motion";
4 | import { Experience, GlobalSettings } from "@/types/resume";
5 | import SectionTitle from "./SectionTitle";
6 | import { useResumeStore } from "@/store/useResumeStore";
7 |
8 | interface ExperienceSectionProps {
9 | experiences?: Experience[];
10 | globalSettings?: GlobalSettings;
11 | showTitle?: boolean;
12 | }
13 |
14 | interface ExperienceItemProps {
15 | experience: Experience;
16 | globalSettings?: GlobalSettings;
17 | }
18 |
19 | const ExperienceItem = React.forwardRef(
20 | ({ experience, globalSettings }, ref) => {
21 | const centerSubtitle = globalSettings?.centerSubtitle;
22 | const gridColumns = centerSubtitle ? 3 : 2;
23 |
24 | return (
25 |
29 | *:last-child]:justify-self-end`}
31 | >
32 |
38 | {experience.company}
39 |
40 | {centerSubtitle && (
41 |
42 | {experience.position}
43 |
44 | )}
45 | {experience.date}
46 |
47 | {experience.position && !centerSubtitle && (
48 |
49 | {experience.position}
50 |
51 | )}
52 | {experience.details && (
53 |
61 | )}
62 |
63 | );
64 | }
65 | );
66 |
67 | ExperienceItem.displayName = "ExperienceItem";
68 |
69 | const ExperienceSection: React.FC = ({
70 | experiences,
71 | globalSettings,
72 | showTitle = true,
73 | }) => {
74 | const { setActiveSection } = useResumeStore();
75 |
76 | const visibleExperiences = experiences?.filter(
77 | (experience) => experience.visible
78 | );
79 |
80 | return (
81 | {
87 | setActiveSection("experience");
88 | }}
89 | >
90 | {showTitle && (
91 |
92 | )}
93 |
94 |
95 | {visibleExperiences?.map((experience) => (
96 |
101 | ))}
102 |
103 |
104 |
105 | );
106 | };
107 |
108 | export default ExperienceSection;
109 |
--------------------------------------------------------------------------------
/src/components/preview/SectionTitle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useMemo } from "react";
3 | import { GlobalSettings } from "@/types/resume";
4 | import { useResumeStore } from "@/store/useResumeStore";
5 | import { cn } from "@/lib/utils";
6 | import { templateConfigs } from "@/config/templates";
7 |
8 | interface SectionTitleProps {
9 | globalSettings?: GlobalSettings;
10 | type: string;
11 | title?: string;
12 | showTitle?: boolean;
13 | }
14 |
15 | const SectionTitle = ({ type, title, globalSettings, showTitle = true }: SectionTitleProps) => {
16 | const { activeResume } = useResumeStore();
17 | const { menuSections = [], templateId = "default" } = activeResume || {};
18 |
19 | const renderTitle = useMemo(() => {
20 | if (type === "custom") {
21 | return title;
22 | }
23 | const sectionConfig = menuSections.find((s) => s.id === type);
24 | return sectionConfig?.title;
25 | }, [menuSections, type, title]);
26 |
27 | const config =
28 | templateConfigs[templateId as string] || templateConfigs["default"];
29 | const { styles } = config.sectionTitle;
30 |
31 | const themeColor = globalSettings?.themeColor;
32 |
33 | const baseStyles = useMemo(
34 | () => ({
35 | fontSize: `${globalSettings?.headerSize || styles.fontSize}px`,
36 | fontWeight: "bold",
37 | color: themeColor,
38 | marginBottom: `${globalSettings?.paragraphSpacing}px`,
39 | }),
40 | [
41 | globalSettings?.headerSize,
42 | globalSettings?.paragraphSpacing,
43 | styles.fontSize,
44 | themeColor,
45 | ]
46 | );
47 |
48 | const renderTemplateTitle = () => {
49 | if (!showTitle) return null;
50 | switch (templateId) {
51 | case "modern":
52 | return (
53 |
60 | {renderTitle}
61 |
62 | );
63 |
64 | case "left-right":
65 | return (
66 |
67 |
74 |
82 | {renderTitle}
83 |
84 |
85 | );
86 |
87 | case "classic":
88 | return (
89 |
97 | {renderTitle}
98 |
99 | );
100 |
101 | default:
102 | return (
103 |
110 | {renderTitle}
111 |
112 | );
113 | }
114 | };
115 |
116 | return renderTemplateTitle();
117 | };
118 |
119 | export default SectionTitle;
120 |
--------------------------------------------------------------------------------
/src/components/preview/SkillPanel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { motion } from "framer-motion";
3 | import SectionTitle from "./SectionTitle";
4 | import { GlobalSettings } from "@/types/resume";
5 | import { useResumeStore } from "@/store/useResumeStore";
6 |
7 | interface SkillSectionProps {
8 | skill?: string;
9 | globalSettings?: GlobalSettings;
10 | showTitle?: boolean;
11 | }
12 |
13 | const SkillSection = ({ skill, globalSettings, showTitle = true }: SkillSectionProps) => {
14 | const { setActiveSection } = useResumeStore();
15 |
16 | return (
17 | {
23 | setActiveSection("skills");
24 | }}
25 | >
26 | {showTitle && }
27 |
32 |
41 |
42 |
43 | );
44 | };
45 |
46 | export default SkillSection;
47 |
--------------------------------------------------------------------------------
/src/components/shared/EditButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button, ButtonProps } from "@/components/ui/button";
2 | import Link from "next/link";
3 | import { JSX, RefAttributes } from "react";
4 |
5 | const EditButton = (
6 | props: JSX.IntrinsicAttributes &
7 | ButtonProps &
8 | RefAttributes
9 | ) => {
10 | return (
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default EditButton;
18 |
--------------------------------------------------------------------------------
/src/components/shared/GitHubStars.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useEffect, useState } from "react";
3 | import { Star } from "lucide-react";
4 | import { motion } from "framer-motion";
5 | import { cn } from "@/lib/utils";
6 |
7 | const REPO_URL = "https://github.com/JOYCEQL/magic-resume";
8 | const API_URL = "https://api.github.com/repos/JOYCEQL/magic-resume";
9 |
10 | export function GitHubStars() {
11 | const [stars, setStars] = useState(null);
12 | const [isHovered, setIsHovered] = useState(false);
13 |
14 | useEffect(() => {
15 | fetch(API_URL)
16 | .then((res) => res.json())
17 | .then((data) => {
18 | setStars(data.stargazers_count);
19 | })
20 | .catch((error) => {
21 | console.error("Error fetching GitHub stars:", error);
22 | });
23 | }, []);
24 |
25 | return (
26 | setIsHovered(true)}
40 | onMouseLeave={() => setIsHovered(false)}
41 | whileHover={{ scale: 1.02 }}
42 | whileTap={{ scale: 0.98 }}
43 | initial={{ opacity: 0, y: 20 }}
44 | animate={{ opacity: 1, y: 0 }}
45 | transition={{ duration: 0.3 }}
46 | >
47 |
58 |
59 |
64 |
73 |
74 |
75 | Star on GitHub
76 |
77 | {stars !== null && (
78 | <>
79 |
86 |
92 | {stars?.toLocaleString()}
93 |
94 | >
95 | )}
96 |
97 | );
98 | }
99 |
--------------------------------------------------------------------------------
/src/components/shared/LanguageSwitch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useLocale } from "next-intl";
3 | import { useRouter } from "next/navigation";
4 | import { Languages } from "lucide-react";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { Button } from "@/components/ui/button";
12 | import { locales, localeNames } from "@/i18n/config";
13 | import { Link, usePathname } from "@/i18n/routing.public";
14 |
15 | export default function LanguageSwitch() {
16 | const locale = useLocale();
17 | const pathname = usePathname();
18 |
19 | return (
20 |
21 |
22 |
29 |
30 |
31 | {locales.map((loc) => {
32 | return (
33 |
37 |
38 |
39 | {localeNames[loc]}
40 | {locale === loc && (
41 | ✓
42 | )}
43 |
44 |
45 |
46 | );
47 | })}
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/components/shared/Logo.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 | import { useTheme } from "next-themes";
4 |
5 | interface LogoProps {
6 | size?: number;
7 | className?: string;
8 | onClick?: () => void;
9 | startColor?: string;
10 | endColor?: string;
11 | }
12 |
13 | const Logo: React.FC = ({
14 | size = 100,
15 | className = "",
16 | onClick,
17 | startColor,
18 | endColor,
19 | }) => {
20 | const { theme } = useTheme();
21 |
22 | // 默认使用主题色
23 | const defaultStartColor = theme === "dark" ? "#A700FF" : "#000000";
24 | const defaultEndColor = theme === "dark" ? "#4F46E5" : "#171717";
25 |
26 | // 使用传入的颜色或默认颜色
27 | const gradientStartColor = startColor || defaultStartColor;
28 | const gradientEndColor = endColor || defaultEndColor;
29 |
30 | return (
31 |
76 | );
77 | };
78 |
79 | export default Logo;
80 |
--------------------------------------------------------------------------------
/src/components/shared/PhotoSelector.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 | import { Settings2, Image, EyeOff, Eye } from "lucide-react";
4 | import { Button } from "@/components/ui/button";
5 | import { cn } from "@/lib/utils";
6 | import PhotoConfigDrawer from "./PhotoConfigDrawer";
7 | import { useResumeStore } from "@/store/useResumeStore";
8 | import { BasicInfo, PhotoConfig } from "@/types/resume";
9 | import { useTranslations } from "next-intl";
10 |
11 | interface Props {
12 | className?: string;
13 | }
14 |
15 | const PhotoSelector: React.FC = ({ className }) => {
16 | const t = useTranslations("workbench");
17 | const [showConfig, setShowConfig] = useState(false);
18 | const { updateBasicInfo, activeResume } = useResumeStore();
19 | const { basic = {} as BasicInfo } = activeResume || {};
20 | const handlePhotoChange = (
21 | photo: string | undefined,
22 | config?: PhotoConfig
23 | ) => {
24 | updateBasicInfo({
25 | ...basic,
26 | photo,
27 | photoConfig: config,
28 | });
29 | };
30 |
31 | const handleConfigChange = (config: PhotoConfig) => {
32 | updateBasicInfo({
33 | ...basic,
34 | photoConfig: config,
35 | });
36 | };
37 |
38 | return (
39 |
40 |
41 |
42 |
43 | {t("basicPanel.avatar")}
44 |
45 |
46 |
54 |
74 |
75 |
76 |
77 |
78 | {basic.photo && (
79 |

84 | )}
85 |
86 |
87 |
setShowConfig(false)}
90 | photo={basic.photo}
91 | config={basic.photoConfig}
92 | onPhotoChange={handlePhotoChange}
93 | onConfigChange={handleConfigChange}
94 | />
95 |
96 | );
97 | };
98 |
99 | export default PhotoSelector;
100 |
--------------------------------------------------------------------------------
/src/components/shared/ScrollToTop.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState, useEffect, useRef } from "react";
3 | import { ArrowUp } from "lucide-react";
4 | import { Button } from "@/components/ui/button";
5 |
6 | const ScrollToTop = () => {
7 | const [isVisible, setIsVisible] = useState(false);
8 | const sentinelRef = useRef(null);
9 |
10 | useEffect(() => {
11 | // 创建观察器实例
12 | const observer = new IntersectionObserver(
13 | ([entry]) => {
14 | // 当哨兵元素可见时隐藏按钮,不可见时显示按钮
15 | setIsVisible(!entry.isIntersecting);
16 | },
17 | {
18 | // 将观察点设置在页面30%的位置(即滚动70%时触发)
19 | rootMargin: "60% 0px -40% 0px",
20 | threshold: 0,
21 | }
22 | );
23 |
24 | // 开始观察哨兵元素
25 | if (sentinelRef.current) {
26 | observer.observe(sentinelRef.current);
27 | }
28 |
29 | // 清理观察器
30 | return () => {
31 | if (sentinelRef.current) {
32 | observer.unobserve(sentinelRef.current);
33 | }
34 | };
35 | }, []);
36 |
37 | const scrollToTop = () => {
38 | window.scrollTo({
39 | top: 0,
40 | behavior: "smooth",
41 | });
42 | };
43 |
44 | return (
45 | <>
46 | {/* 哨兵元素放在页面顶部 */}
47 |
52 |
53 | {/* 回到顶部按钮 */}
54 |
58 |
72 |
73 | >
74 | );
75 | };
76 |
77 | export default ScrollToTop;
78 |
--------------------------------------------------------------------------------
/src/components/shared/TemplateSheet.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Layout, PanelsLeftBottom } from "lucide-react";
3 | import { motion } from "framer-motion";
4 | import { useTranslations } from "next-intl";
5 | import {
6 | Sheet,
7 | SheetContent,
8 | SheetDescription,
9 | SheetHeader,
10 | SheetTitle,
11 | SheetTrigger
12 | } from "@/components/ui/sheet-no-overlay";
13 | import { cn } from "@/lib/utils";
14 | import { DEFAULT_TEMPLATES } from "@/config";
15 | import { useResumeStore } from "@/store/useResumeStore";
16 | import classic from "@/assets/images/template-cover/classic.png";
17 | import modern from "@/assets/images/template-cover/modern.png";
18 | import leftRight from "@/assets/images/template-cover/left-right.png";
19 | import timeline from "@/assets/images/template-cover/timeline.png";
20 |
21 | const templateImages: { [key: string]: any } = {
22 | classic,
23 | modern,
24 | "left-right": leftRight,
25 | timeline
26 | };
27 |
28 | const TemplateSheet = () => {
29 | const t = useTranslations("templates");
30 | const { activeResume, setTemplate } = useResumeStore();
31 | const currentTemplate =
32 | DEFAULT_TEMPLATES.find((t) => t.id === activeResume?.templateId) ||
33 | DEFAULT_TEMPLATES[0];
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 | {t("switchTemplate")}
43 |
44 |
45 | {/* 解决警告问题 */}
46 |
47 |
48 |
49 | {DEFAULT_TEMPLATES.map((t) => (
50 |
74 | ))}
75 |
76 |
77 |
78 | );
79 | };
80 |
81 | export default TemplateSheet;
82 |
--------------------------------------------------------------------------------
/src/components/shared/ThemeToggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import { Moon, Sun } from "lucide-react";
4 | import { useTheme } from "next-themes";
5 | import { Button } from "@/components/ui/button";
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuItem,
10 | DropdownMenuTrigger,
11 | } from "@/components/ui/dropdown-menu";
12 | import { cn } from "@/lib/utils";
13 |
14 | const ThemeToggle = ({ children }: { children?: React.ReactNode }) => {
15 | const { theme, setTheme, systemTheme } = useTheme();
16 | const [mounted, setMounted] = React.useState(false);
17 |
18 | // 确保组件挂载后再渲染
19 | React.useEffect(() => {
20 | setMounted(true);
21 | }, []);
22 |
23 | // 在客户端渲染之前返回null
24 | if (!mounted) {
25 | return null;
26 | }
27 |
28 | // 获取当前实际主题
29 | const currentTheme = theme === "system" ? systemTheme : theme;
30 |
31 | return (
32 |
33 |
34 | {!children ? (
35 |
58 | ) : (
59 | children
60 | )}
61 |
62 |
63 | setTheme("light")}>
64 | Light
65 |
66 | setTheme("dark")}>
67 | Dark
68 |
69 | setTheme("system")}>
70 | System
71 |
72 |
73 |
74 | );
75 | };
76 |
77 | export default ThemeToggle;
78 |
--------------------------------------------------------------------------------
/src/components/shared/UpdateLocale.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { setUserLocale } from "@/i18n/db";
3 | import { revalidatePath } from "next/cache";
4 | export default async function updateLocale(locale: string) {
5 | setUserLocale(locale);
6 | revalidatePath("/");
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/templates/ClassicTemplate.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import GithubContribution from "@/components/shared/GithubContribution";
3 | import BaseInfo from "../preview/BaseInfo";
4 | import ExperienceSection from "../preview/ExperienceSection";
5 | import EducationSection from "../preview/EducationSection";
6 | import SkillSection from "../preview/SkillPanel";
7 | import CustomSection from "../preview/CustomSection";
8 | import { ResumeData } from "@/types/resume";
9 | import { ResumeTemplate } from "@/types/template";
10 | import ProjectSection from "../preview/ProjectSection";
11 |
12 | interface ClassicTemplateProps {
13 | data: ResumeData;
14 | template: ResumeTemplate;
15 | }
16 |
17 | const ClassicTemplate: React.FC = ({
18 | data,
19 | template,
20 | }) => {
21 | const { colorScheme } = template;
22 | const enabledSections = data.menuSections.filter(
23 | (section) => section.enabled
24 | );
25 | const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
26 |
27 | const renderSection = (sectionId: string) => {
28 | switch (sectionId) {
29 | case "basic":
30 | return (
31 | <>
32 |
33 |
34 | {data.basic.githubContributionsVisible && (
35 |
40 | )}
41 | >
42 | );
43 | case "experience":
44 | return (
45 |
49 | );
50 | case "education":
51 | return (
52 |
56 | );
57 | case "skills":
58 | return (
59 |
63 | );
64 | case "projects":
65 | return (
66 |
70 | );
71 | default:
72 | if (sectionId in data.customData) {
73 | const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
74 | return (
75 |
82 | );
83 | }
84 | return null;
85 | }
86 | };
87 |
88 | return (
89 |
96 | {sortedSections.map((section) => (
97 |
{renderSection(section.id)}
98 | ))}
99 |
100 | );
101 | };
102 |
103 | export default ClassicTemplate;
104 |
--------------------------------------------------------------------------------
/src/components/templates/LeftRightTemplate.tsx:
--------------------------------------------------------------------------------
1 | import BaseInfo from "../preview/BaseInfo";
2 | import ExperienceSection from "../preview/ExperienceSection";
3 | import EducationSection from "../preview/EducationSection";
4 | import SkillSection from "../preview/SkillPanel";
5 | import ProjectSection from "../preview/ProjectSection";
6 | import CustomSection from "../preview/CustomSection";
7 | import { ResumeData } from "@/types/resume";
8 | import { ResumeTemplate } from "@/types/template";
9 | import GithubContribution from "../shared/GithubContribution";
10 |
11 | interface LeftRightTemplateProps {
12 | data: ResumeData;
13 | template: ResumeTemplate;
14 | }
15 |
16 | const LeftRightTemplate: React.FC = ({
17 | data,
18 | template,
19 | }) => {
20 | const { colorScheme } = template;
21 | const enabledSections = data.menuSections.filter(
22 | (section) => section.enabled
23 | );
24 | const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
25 |
26 | const renderSection = (sectionId: string) => {
27 | switch (sectionId) {
28 | case "basic":
29 | return (
30 | <>
31 |
36 | {data.basic.githubContributionsVisible && (
37 |
42 | )}
43 | >
44 | );
45 | case "experience":
46 | return (
47 |
51 | );
52 | case "education":
53 | return (
54 |
58 | );
59 | case "skills":
60 | return (
61 |
65 | );
66 | case "projects":
67 | return (
68 |
72 | );
73 | default:
74 | if (sectionId in data.customData) {
75 | const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
76 | return (
77 |
84 | );
85 | }
86 | return null;
87 | }
88 | };
89 |
90 | return (
91 |
98 | {sortedSections.map((section) => (
99 |
{renderSection(section.id)}
100 | ))}
101 |
102 | );
103 | };
104 |
105 | export default LeftRightTemplate;
106 |
--------------------------------------------------------------------------------
/src/components/templates/ModernTemplate.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BaseInfo from "../preview/BaseInfo";
3 | import ExperienceSection from "../preview/ExperienceSection";
4 | import EducationSection from "../preview/EducationSection";
5 | import SkillSection from "../preview/SkillPanel";
6 | import ProjectSection from "../preview/ProjectSection";
7 | import CustomSection from "../preview/CustomSection";
8 | import { ResumeData } from "@/types/resume";
9 | import { ResumeTemplate } from "@/types/template";
10 |
11 | interface ModernTemplateProps {
12 | data: ResumeData;
13 | template: ResumeTemplate;
14 | }
15 |
16 | const ModernTemplate: React.FC = ({ data, template }) => {
17 | const { colorScheme } = template;
18 | const enabledSections = data.menuSections.filter(
19 | (section) => section.enabled
20 | );
21 | const sortedSections = [...enabledSections].sort((a, b) => a.order - b.order);
22 |
23 | const renderSection = (sectionId: string) => {
24 | switch (sectionId) {
25 | case "basic":
26 | return (
27 |
32 | );
33 | case "experience":
34 | return (
35 |
39 | );
40 | case "education":
41 | return (
42 |
46 | );
47 | case "skills":
48 | return (
49 |
53 | );
54 | case "projects":
55 | return (
56 |
60 | );
61 | default:
62 | if (sectionId in data.customData) {
63 | const sectionTitle = data.menuSections.find(s => s.id === sectionId)?.title || sectionId;
64 | return (
65 |
72 | );
73 | }
74 | return null;
75 | }
76 | };
77 |
78 | const basicSection = sortedSections.find((section) => section.id === "basic");
79 | const otherSections = sortedSections.filter(
80 | (section) => section.id !== "basic"
81 | );
82 |
83 | return (
84 |
85 |
93 | {basicSection && renderSection(basicSection.id)}
94 |
95 |
96 |
103 | {otherSections.map((section) => (
104 |
{renderSection(section.id)}
105 | ))}
106 |
107 |
108 | );
109 | };
110 |
111 | export default ModernTemplate;
112 |
--------------------------------------------------------------------------------
/src/components/templates/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ClassicTemplate from "./ClassicTemplate";
3 | import ModernTemplate from "./ModernTemplate";
4 | import LeftRightTemplate from "./LeftRightTemplate";
5 | import TimelineTemplate from "./TimelineTemplate";
6 | import { ResumeData } from "@/types/resume";
7 | import { ResumeTemplate } from "@/types/template";
8 |
9 | interface TemplateProps {
10 | data: ResumeData;
11 | template: ResumeTemplate;
12 | }
13 |
14 | const ResumeTemplateComponent: React.FC = ({
15 | data,
16 | template
17 | }) => {
18 | const renderTemplate = () => {
19 | switch (template.layout) {
20 | case "modern":
21 | return ;
22 | case "left-right":
23 | return ;
24 | case "timeline":
25 | return ;
26 | default:
27 | return ;
28 | }
29 | };
30 |
31 | return renderTemplate();
32 | };
33 |
34 | export default ResumeTemplateComponent;
35 |
--------------------------------------------------------------------------------
/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
5 | import { ChevronDown } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Accordion = AccordionPrimitive.Root
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ))
21 | AccordionItem.displayName = "AccordionItem"
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180",
32 | className
33 | )}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ))
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ))
55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
56 |
57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
58 |
--------------------------------------------------------------------------------
/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/src/components/ui/calendar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { ChevronLeft, ChevronRight } from "lucide-react"
5 | import { DayPicker } from "react-day-picker"
6 |
7 | import { cn } from "@/lib/utils"
8 | import { buttonVariants } from "@/components/ui/button"
9 |
10 | export type CalendarProps = React.ComponentProps
11 |
12 | function Calendar({
13 | className,
14 | classNames,
15 | showOutsideDays = true,
16 | ...props
17 | }: CalendarProps) {
18 | return (
19 | ,
58 | IconRight: ({ ...props }) => ,
59 | }}
60 | {...props}
61 | />
62 | )
63 | }
64 | Calendar.displayName = "Calendar"
65 |
66 | export { Calendar }
67 |
--------------------------------------------------------------------------------
/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ))
18 | Card.displayName = "Card"
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ))
30 | CardHeader.displayName = "CardHeader"
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ))
45 | CardTitle.displayName = "CardTitle"
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | CardDescription.displayName = "CardDescription"
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ))
65 | CardContent.displayName = "CardContent"
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ))
77 | CardFooter.displayName = "CardFooter"
78 |
79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
80 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Drawer = ({
9 | shouldScaleBackground = true,
10 | ...props
11 | }: React.ComponentProps) => (
12 |
16 | )
17 | Drawer.displayName = "Drawer"
18 |
19 | const DrawerTrigger = DrawerPrimitive.Trigger
20 |
21 | const DrawerPortal = DrawerPrimitive.Portal
22 |
23 | const DrawerClose = DrawerPrimitive.Close
24 |
25 | const DrawerOverlay = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
34 | ))
35 | DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
36 |
37 | const DrawerContent = React.forwardRef<
38 | React.ElementRef,
39 | React.ComponentPropsWithoutRef
40 | >(({ className, children, ...props }, ref) => (
41 |
42 |
43 |
51 |
52 | {children}
53 |
54 |
55 | ))
56 | DrawerContent.displayName = "DrawerContent"
57 |
58 | const DrawerHeader = ({
59 | className,
60 | ...props
61 | }: React.HTMLAttributes) => (
62 |
66 | )
67 | DrawerHeader.displayName = "DrawerHeader"
68 |
69 | const DrawerFooter = ({
70 | className,
71 | ...props
72 | }: React.HTMLAttributes) => (
73 |
77 | )
78 | DrawerFooter.displayName = "DrawerFooter"
79 |
80 | const DrawerTitle = React.forwardRef<
81 | React.ElementRef,
82 | React.ComponentPropsWithoutRef
83 | >(({ className, ...props }, ref) => (
84 |
92 | ))
93 | DrawerTitle.displayName = DrawerPrimitive.Title.displayName
94 |
95 | const DrawerDescription = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, ...props }, ref) => (
99 |
104 | ))
105 | DrawerDescription.displayName = DrawerPrimitive.Description.displayName
106 |
107 | export {
108 | Drawer,
109 | DrawerPortal,
110 | DrawerOverlay,
111 | DrawerTrigger,
112 | DrawerClose,
113 | DrawerContent,
114 | DrawerHeader,
115 | DrawerFooter,
116 | DrawerTitle,
117 | DrawerDescription,
118 | }
119 |
--------------------------------------------------------------------------------
/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const HoverCard = HoverCardPrimitive.Root
9 |
10 | const HoverCardTrigger = HoverCardPrimitive.Trigger
11 |
12 | const HoverCardContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
26 | ))
27 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
28 |
29 | export { HoverCard, HoverCardTrigger, HoverCardContent }
30 |
--------------------------------------------------------------------------------
/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | )
18 | }
19 | )
20 | Input.displayName = "Input"
21 |
22 | export { Input }
23 |
--------------------------------------------------------------------------------
/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/src/components/ui/resizable.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { GripVertical } from "lucide-react"
4 | import * as ResizablePrimitive from "react-resizable-panels"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ResizablePanelGroup = ({
9 | className,
10 | ...props
11 | }: React.ComponentProps) => (
12 |
19 | )
20 |
21 | const ResizablePanel = ResizablePrimitive.Panel
22 |
23 | const ResizableHandle = ({
24 | withHandle,
25 | className,
26 | ...props
27 | }: React.ComponentProps & {
28 | withHandle?: boolean
29 | }) => (
30 | div]:rotate-90",
33 | className
34 | )}
35 | {...props}
36 | >
37 | {withHandle && (
38 |
39 |
40 |
41 | )}
42 |
43 | )
44 |
45 | export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
46 |
--------------------------------------------------------------------------------
/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/src/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SliderPrimitive from "@radix-ui/react-slider"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 |
24 |
25 | ))
26 | Slider.displayName = SliderPrimitive.Root.displayName
27 |
28 | export { Slider }
29 |
--------------------------------------------------------------------------------
/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SwitchPrimitives from "@radix-ui/react-switch"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ))
27 | Switch.displayName = SwitchPrimitives.Root.displayName
28 |
29 | export { Switch }
30 |
--------------------------------------------------------------------------------
/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TabsPrimitive from "@radix-ui/react-tabs"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Tabs = TabsPrimitive.Root
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | TabsList.displayName = TabsPrimitive.List.displayName
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ))
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ))
53 | TabsContent.displayName = TabsPrimitive.Content.displayName
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent }
56 |
--------------------------------------------------------------------------------
/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/src/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/src/config/ai.ts:
--------------------------------------------------------------------------------
1 | import { AIModelType } from "@/store/useAIConfigStore";
2 |
3 | export interface AIModelConfig {
4 | url: (endpoint: string) => string;
5 | requiresModelId: boolean;
6 | defaultModel?: string;
7 | headers: (apiKey: string) => Record;
8 | }
9 |
10 | export const AI_MODEL_CONFIGS: Record = {
11 | doubao: {
12 | url: (endpoint: string) => "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
13 | requiresModelId: true,
14 | headers: (apiKey: string) => ({
15 | "Content-Type": "application/json",
16 | Authorization: `Bearer ${apiKey}`,
17 | }),
18 | },
19 | deepseek: {
20 | url: (endpoint: string) => "https://api.deepseek.com/v1/chat/completions",
21 | requiresModelId: false,
22 | defaultModel: "deepseek-chat",
23 | headers: (apiKey: string) => ({
24 | "Content-Type": "application/json",
25 | Authorization: `Bearer ${apiKey}`,
26 | }),
27 | },
28 | openai: {
29 | url: (endpoint: string) => `${endpoint}/chat/completions`,
30 | requiresModelId: true,
31 | headers: (apiKey: string) => ({
32 | "Content-Type": "application/json",
33 | Authorization: `Bearer ${apiKey}`,
34 | }),
35 | }
36 | };
37 |
--------------------------------------------------------------------------------
/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import { BasicFieldType } from "@/types/resume";
2 | import { ResumeTemplate } from "@/types/template";
3 | export const DEFAULT_FIELD_ORDER: BasicFieldType[] = [
4 | { id: "1", key: "name", label: "姓名", type: "text", visible: true },
5 |
6 | { id: "2", key: "title", label: "职位", type: "text", visible: true },
7 | {
8 | id: "3",
9 | key: "employementStatus",
10 | label: "状态",
11 | type: "text",
12 | visible: true
13 | },
14 | { id: "4", key: "birthDate", label: "生日", type: "date", visible: true },
15 | { id: "5", key: "email", label: "邮箱", type: "text", visible: true },
16 | { id: "6", key: "phone", label: "电话", type: "text", visible: true },
17 | { id: "7", key: "location", label: "所在地", type: "text", visible: true }
18 | ];
19 |
20 | export const DEFAULT_TEMPLATES: ResumeTemplate[] = [
21 | {
22 | id: "classic",
23 | name: "经典模板",
24 | description: "传统简约的简历布局,适合大多数求职场景",
25 | thumbnail: "classic",
26 | layout: "classic",
27 | colorScheme: {
28 | primary: "#000000",
29 | secondary: "#4b5563",
30 | background: "#ffffff",
31 | text: "#212529"
32 | },
33 | spacing: {
34 | sectionGap: 24,
35 | itemGap: 16,
36 | contentPadding: 32
37 | },
38 | basic: {
39 | layout: "center"
40 | }
41 | },
42 | {
43 | id: "modern",
44 | name: "两栏布局",
45 | description: "经典两栏,突出个人特色",
46 | thumbnail: "modern",
47 | layout: "modern",
48 | colorScheme: {
49 | primary: "#000000",
50 | secondary: "#6b7280",
51 | background: "#ffffff",
52 | text: "#212529"
53 | },
54 | spacing: {
55 | sectionGap: 20,
56 | itemGap: 20,
57 | contentPadding: 1
58 | },
59 | basic: {
60 | layout: "center"
61 | }
62 | },
63 | {
64 | id: "left-right",
65 | name: "模块标题背景色",
66 | description: "模块标题背景鲜明,突出美观特色",
67 | thumbnail: "leftRight",
68 | layout: "left-right",
69 | colorScheme: {
70 | primary: "#000000",
71 | secondary: "#9ca3af",
72 | background: "#ffffff",
73 | text: "#212529"
74 | },
75 | spacing: {
76 | sectionGap: 24,
77 | itemGap: 16,
78 | contentPadding: 32
79 | },
80 | basic: {
81 | layout: "left"
82 | }
83 | },
84 | {
85 | id: "timeline",
86 | name: "时间线风格",
87 | description: "时间线布局,突出经历的时间顺序",
88 | thumbnail: "timeline",
89 | layout: "timeline",
90 | colorScheme: {
91 | primary: "#18181b",
92 | secondary: "#64748b",
93 | background: "#ffffff",
94 | text: "#212529"
95 | },
96 | spacing: {
97 | sectionGap: 1,
98 | itemGap: 12,
99 | contentPadding: 24
100 | },
101 | basic: {
102 | layout: "right"
103 | }
104 | }
105 | ];
106 |
107 | export const GITHUB_REPO_URL = "https://github.com/JOYCEQL/magic-resume";
108 |
--------------------------------------------------------------------------------
/src/config/templates.ts:
--------------------------------------------------------------------------------
1 | import { TemplateConfig } from "@/types/template";
2 |
3 | export const templateConfigs: Record = {
4 | default: {
5 | sectionTitle: {
6 | styles: {
7 | fontSize: 18,
8 | },
9 | },
10 | },
11 | classic: {
12 | sectionTitle: {
13 | className: "border-b pb-2",
14 | styles: {
15 | fontSize: 18,
16 | borderColor: "var(--theme-color)",
17 | },
18 | },
19 | },
20 | modern: {
21 | sectionTitle: {
22 | className: "font-semibold mb-2 uppercase tracking-wider",
23 | styles: {
24 | fontSize: 18,
25 | },
26 | },
27 | },
28 | "left-right": {
29 | sectionTitle: {
30 | className: "pl-1 flex items-center",
31 | styles: {
32 | fontSize: 18,
33 | backgroundColor: "var(--theme-color)",
34 | opacity: 0.1,
35 | color: "var(--theme-color)",
36 | borderLeftWidth: "3px",
37 | borderLeftStyle: "solid",
38 | borderLeftColor: "var(--theme-color)",
39 | },
40 | },
41 | },
42 | };
43 |
--------------------------------------------------------------------------------
/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | const MOBILE_BREAKPOINT = 768
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(undefined)
7 |
8 | React.useEffect(() => {
9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10 | const onChange = () => {
11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12 | }
13 | mql.addEventListener("change", onChange)
14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15 | return () => mql.removeEventListener("change", onChange)
16 | }, [])
17 |
18 | return !!isMobile
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/useGrammarCheck.ts:
--------------------------------------------------------------------------------
1 | import { useGrammarStore } from "@/store/useGrammarStore";
2 |
3 | export interface GrammarError {
4 | text: string;
5 | message: string;
6 | type: "spelling" | "grammar";
7 | suggestions: string[];
8 | }
9 |
10 | export const useGrammarCheck = () => {
11 | const {
12 | errors,
13 | isChecking,
14 | selectedErrorIndex,
15 | checkGrammar,
16 | clearErrors,
17 | selectError,
18 | } = useGrammarStore();
19 |
20 | return {
21 | errors,
22 | isChecking,
23 | selectedErrorIndex,
24 | checkGrammar,
25 | clearErrors,
26 | selectError,
27 | };
28 | };
29 |
--------------------------------------------------------------------------------
/src/i18n/config.ts:
--------------------------------------------------------------------------------
1 | export const locales = ["zh", "en"] as const;
2 | export type Locale = (typeof locales)[number];
3 |
4 | export const defaultLocale: Locale = "zh";
5 |
6 | export const localeNames: Record = {
7 | zh: "中文",
8 | en: "English",
9 | };
10 |
--------------------------------------------------------------------------------
/src/i18n/db.ts:
--------------------------------------------------------------------------------
1 | import { cookies } from "next/headers";
2 | import { defaultLocale } from "./config";
3 |
4 | const COOKIE_NAME = "NEXT_LOCALE";
5 |
6 | export async function getUserLocale() {
7 | return cookies().get(COOKIE_NAME)?.value || defaultLocale;
8 | }
9 |
10 | export async function setUserLocale(locale: string) {
11 | cookies().set(COOKIE_NAME, locale);
12 | }
13 |
--------------------------------------------------------------------------------
/src/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from "next-intl/server";
2 | import { defaultLocale, locales } from "./config";
3 | import { getUserLocale } from "./db";
4 |
5 | export default getRequestConfig(async ({ requestLocale }) => {
6 | // Read from potential `[locale]` segment
7 | let locale = await requestLocale;
8 |
9 | if (!locale) {
10 | // The user is logged in
11 | locale = await getUserLocale();
12 | }
13 |
14 | // Ensure that the incoming locale is valid
15 | if (!locales.includes(locale as any)) {
16 | locale = defaultLocale;
17 | }
18 |
19 | return {
20 | locale,
21 | messages: (await import(`./locales/${locale}.json`)).default,
22 | };
23 | });
24 |
--------------------------------------------------------------------------------
/src/i18n/routing.public.ts:
--------------------------------------------------------------------------------
1 | import { createNavigation } from "next-intl/navigation";
2 | import { defineRouting } from "next-intl/routing";
3 | import { defaultLocale, locales } from "./config";
4 |
5 | export const routing = defineRouting({
6 | locales,
7 | defaultLocale,
8 | });
9 |
10 | export const { Link, usePathname } = createNavigation(routing);
11 |
--------------------------------------------------------------------------------
/src/lib/getChangelog.ts:
--------------------------------------------------------------------------------
1 | export interface TimelineEntry {
2 | date: string;
3 | sections: {
4 | title: string;
5 | items: string[];
6 | }[];
7 | }
8 |
9 | export function getChangelog(): TimelineEntry[] {
10 | return [
11 | {
12 | date: "2025-03-15",
13 | sections: [
14 | {
15 | title: "修复",
16 | items: ["修复多余分割线引起的导出PDF 400 错误"]
17 | },
18 | {
19 | title: "优化",
20 | items: ["项目链接过长时的样式"]
21 | }
22 | ]
23 | },
24 | {
25 | date: "2025-03-07",
26 | sections: [
27 | {
28 | title: "新增",
29 | items: ["工作台 Dock 栏支持复制简历", "仪表盘简历模板支持预览大图"]
30 | },
31 | {
32 | title: "优化",
33 | items: ["服务端导出PDF 速度优化"]
34 | }
35 | ]
36 | }
37 | ];
38 | }
39 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from "next-intl/middleware";
2 | import { routing } from "./i18n/routing.public";
3 |
4 | export default createMiddleware(routing);
5 |
6 | export const config = {
7 | matcher: ["/", "/(zh|en)/:path*"]
8 | };
9 |
--------------------------------------------------------------------------------
/src/store/useAIConfigStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { persist } from "zustand/middleware";
3 |
4 | export type AIModelType = "doubao" | "deepseek" | "openai";
5 |
6 | interface AIConfigState {
7 | selectedModel: AIModelType;
8 | doubaoApiKey: string;
9 | doubaoModelId: string;
10 | deepseekApiKey: string;
11 | deepseekModelId: string;
12 | openaiApiKey: string;
13 | openaiModelId: string;
14 | openaiApiEndpoint: string;
15 | setSelectedModel: (model: AIModelType) => void;
16 | setDoubaoApiKey: (apiKey: string) => void;
17 | setDoubaoModelId: (modelId: string) => void;
18 | setDeepseekApiKey: (apiKey: string) => void;
19 | setDeepseekModelId: (modelId: string) => void;
20 | setOpenaiApiKey: (apiKey: string) => void;
21 | setOpenaiModelId: (modelId: string) => void;
22 | setOpenaiApiEndpoint: (endpoint: string) => void;
23 | }
24 |
25 | export const useAIConfigStore = create()(
26 | persist(
27 | (set) => ({
28 | selectedModel: "doubao",
29 | doubaoApiKey: "",
30 | doubaoModelId: "",
31 | deepseekApiKey: "",
32 | deepseekModelId: "",
33 | openaiApiKey: "",
34 | openaiModelId: "",
35 | openaiApiEndpoint: "",
36 | setSelectedModel: (model: AIModelType) => set({ selectedModel: model }),
37 | setDoubaoApiKey: (apiKey: string) => set({ doubaoApiKey: apiKey }),
38 | setDoubaoModelId: (modelId: string) => set({ doubaoModelId: modelId }),
39 | setDeepseekApiKey: (apiKey: string) => set({ deepseekApiKey: apiKey }),
40 | setDeepseekModelId: (modelId: string) => set({ deepseekModelId: modelId }),
41 | setOpenaiApiKey: (apiKey: string) => set({ openaiApiKey: apiKey }),
42 | setOpenaiModelId: (modelId: string) => set({ openaiModelId: modelId }),
43 | setOpenaiApiEndpoint: (endpoint: string) => set({ openaiApiEndpoint: endpoint })
44 | }),
45 | {
46 | name: "ai-config-storage"
47 | }
48 | )
49 | );
50 |
--------------------------------------------------------------------------------
/src/styles/tiptap.scss:
--------------------------------------------------------------------------------
1 | .tiptap {
2 | min-height: 300px;
3 | border-radius: 0 0 4px 4px;
4 | ul,
5 | ol {
6 | list-style-type: disc;
7 | padding: 0 1rem;
8 |
9 | li p {
10 | margin-top: 0.25em;
11 | margin-bottom: 0.25em;
12 | }
13 | }
14 | ol {
15 | list-style-type: decimal;
16 | padding: 1rem;
17 |
18 | li p {
19 | margin-top: 0.25em;
20 | margin-bottom: 0.25em;
21 | }
22 | }
23 | /* Heading styles */
24 | h1,
25 | h2,
26 | h3,
27 | h4,
28 | h5,
29 | h6 {
30 | line-height: 1.1;
31 | margin-top: 2.5rem;
32 | text-wrap: pretty;
33 | }
34 |
35 | h1,
36 | h2 {
37 | margin-top: 3.5rem;
38 | margin-bottom: 1.5rem;
39 | }
40 |
41 | h1 {
42 | font-size: 1.4rem;
43 | }
44 |
45 | h2 {
46 | font-size: 1.2rem;
47 | }
48 |
49 | h3 {
50 | font-size: 1.1rem;
51 | }
52 |
53 | h4,
54 | h5,
55 | h6 {
56 | font-size: 1rem;
57 | }
58 |
59 | code {
60 | background-color: rgba(27, 31, 35, 0.05);
61 | border-radius: 0.4rem;
62 | color: black;
63 | font-size: 0.85rem;
64 | padding: 0.25em 0.3em;
65 | }
66 |
67 | pre {
68 | background: black;
69 | border-radius: 0.5rem;
70 | color: white;
71 | font-family: "JetBrainsMono", monospace;
72 | margin: 1.5rem 0;
73 | padding: 0.75rem 1rem;
74 |
75 | code {
76 | background: none;
77 | color: inherit;
78 | font-size: 0.8rem;
79 | padding: 0;
80 | }
81 | }
82 |
83 | blockquote {
84 | border-left: 3px solid gray;
85 | margin: 1.5rem 0;
86 | padding-left: 1rem;
87 | }
88 |
89 | hr {
90 | border: none;
91 | border-top: 1px solid gray;
92 | margin: 2rem 0;
93 | }
94 | }
95 |
96 | .control-group {
97 | border: 1px solid #d1d5db;
98 | border-bottom: none;
99 | background-color: #403d39;
100 | border-radius: 4px 4px 0 0;
101 | overflow: hidden;
102 | .button-group {
103 | display: flex;
104 | flex-wrap: wrap;
105 | gap: 0.25rem;
106 | padding: 4px;
107 |
108 | .lucide {
109 | padding: 4px 6px;
110 | cursor: pointer;
111 | color: #fff;
112 | width: 28px;
113 | height: 28px;
114 | &.is-active {
115 | font-weight: 700;
116 | color: #2ec4b6;
117 | }
118 | &:hover {
119 | border-radius: 4px;
120 | background-color: #e26d5c;
121 | }
122 | }
123 | }
124 | }
125 | [contenteditable]:focus {
126 | outline: none;
127 | }
128 |
--------------------------------------------------------------------------------
/src/theme/themeConfig.ts:
--------------------------------------------------------------------------------
1 | export const getThemeConfig = () => ({
2 | bg: "dark:bg-black bg-gray-50",
3 | sidebar: "dark:bg-zinc-900/50 bg-white",
4 | text: "dark:text-white text-gray-900",
5 | textSecondary: "dark:text-zinc-400 text-gray-500",
6 | border: "dark:border-zinc-800 border-gray-200",
7 | card: "dark:bg-zinc-800/50 bg-white",
8 | hover: "dark:hover:bg-zinc-800 hover:bg-gray-100",
9 | input: "dark:bg-zinc-800/50 dark:border-zinc-700 bg-white border-gray-200",
10 | button: "dark:bg-zinc-800 bg-white",
11 | buttonPrimary: "dark:bg-indigo-500 bg-primary",
12 | preview: "dark:bg-zinc-900 bg-white"
13 | });
14 |
15 | export type ThemeConfig = ReturnType;
16 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | showDirectoryPicker(
4 | options?: FilePickerOptions
5 | ): Promise;
6 | }
7 | }
8 |
9 | interface FilePickerOptions {
10 | multiple?: boolean;
11 | mode?: "read" | "readwrite";
12 | }
13 |
14 | export {};
15 |
--------------------------------------------------------------------------------
/src/types/template.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from "react";
2 |
3 | export interface ResumeTemplate {
4 | id: string;
5 | name: string;
6 | description: string;
7 | thumbnail: string;
8 | layout: "classic" | "modern" | "left-right" | "professional" | "timeline";
9 | colorScheme: {
10 | primary: string;
11 | secondary: string;
12 | background: string;
13 | text: string;
14 | };
15 | spacing: {
16 | sectionGap: number;
17 | itemGap: number;
18 | contentPadding: number;
19 | };
20 | basic: {
21 | layout?: "left" | "center" | "right";
22 | };
23 | }
24 |
25 | export interface TemplateConfig {
26 | sectionTitle: {
27 | className?: string;
28 | styles: CSSProperties;
29 | };
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/fileSystem.ts:
--------------------------------------------------------------------------------
1 | // 存储文件句柄和配置信息
2 | const DB_NAME = "FileHandleDB";
3 | const HANDLE_STORE = "handles";
4 | const CONFIG_STORE = "config";
5 | const DB_VERSION = 2;
6 |
7 | let db: IDBDatabase | null = null;
8 |
9 | const initDB = (): Promise => {
10 | return new Promise((resolve, reject) => {
11 | if (db) {
12 | resolve();
13 | return;
14 | }
15 |
16 | const request = indexedDB.open(DB_NAME, DB_VERSION);
17 |
18 | request.onerror = () => reject(request.error);
19 | request.onsuccess = () => {
20 | db = request.result;
21 | resolve();
22 | };
23 |
24 | request.onupgradeneeded = (event) => {
25 | const db = (event.target as IDBOpenDBRequest).result;
26 | if (!db.objectStoreNames.contains(HANDLE_STORE)) {
27 | db.createObjectStore(HANDLE_STORE);
28 | }
29 | if (!db.objectStoreNames.contains(CONFIG_STORE)) {
30 | db.createObjectStore(CONFIG_STORE);
31 | }
32 | };
33 | });
34 | };
35 |
36 | export const storeFileHandle = async (
37 | key: string,
38 | handle: FileSystemHandle
39 | ): Promise => {
40 | await initDB();
41 | if (!db) throw new Error("Database not initialized");
42 |
43 | return new Promise((resolve, reject) => {
44 | const transaction = db.transaction(HANDLE_STORE, "readwrite");
45 | const store = transaction.objectStore(HANDLE_STORE);
46 | const request = store.put(handle, key);
47 |
48 | request.onerror = () => reject(request.error);
49 | request.onsuccess = () => resolve();
50 | });
51 | };
52 |
53 | export const getFileHandle = async (
54 | key: string
55 | ): Promise => {
56 | await initDB();
57 | if (!db) throw new Error("Database not initialized");
58 |
59 | return new Promise((resolve, reject) => {
60 | const transaction = db.transaction(HANDLE_STORE, "readonly");
61 | const store = transaction.objectStore(HANDLE_STORE);
62 | const request = store.get(key);
63 |
64 | request.onerror = () => reject(request.error);
65 | request.onsuccess = () => resolve(request.result);
66 | });
67 | };
68 |
69 | export const storeConfig = async (key: string, value: any): Promise => {
70 | await initDB();
71 | if (!db) throw new Error("Database not initialized");
72 |
73 | return new Promise((resolve, reject) => {
74 | const transaction = db.transaction(CONFIG_STORE, "readwrite");
75 | const store = transaction.objectStore(CONFIG_STORE);
76 | const request = store.put(value, key);
77 |
78 | request.onerror = () => reject(request.error);
79 | request.onsuccess = () => resolve();
80 | });
81 | };
82 |
83 | export const getConfig = async (key: string): Promise => {
84 | await initDB();
85 | if (!db) throw new Error("Database not initialized");
86 |
87 | return new Promise((resolve, reject) => {
88 | const transaction = db.transaction(CONFIG_STORE, "readonly");
89 | const store = transaction.objectStore(CONFIG_STORE);
90 | const request = store.get(key);
91 |
92 | request.onerror = () => reject(request.error);
93 | request.onsuccess = () => resolve(request.result);
94 | });
95 | };
96 |
97 | export const verifyPermission = async (
98 | handle: FileSystemHandle,
99 | mode: FileSystemPermissionMode = "readwrite"
100 | ): Promise => {
101 | if (!handle) {
102 | return false;
103 | }
104 |
105 | const options = { mode };
106 |
107 | // 检查当前权限
108 | if ((await handle.queryPermission(options)) === "granted") {
109 | return true;
110 | }
111 |
112 | // 请求权限
113 | if ((await handle.requestPermission(options)) === "granted") {
114 | return true;
115 | }
116 |
117 | return false;
118 | };
119 |
--------------------------------------------------------------------------------
/src/utils/imageUtils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 用Canvas调整图片尺寸和质量,返回压缩后的base64图片数据
3 | */
4 | export const compressImage = (
5 | file: File,
6 | maxWidth = 800,
7 | maxHeight = 800,
8 | quality = 0.7
9 | ): Promise => {
10 | return new Promise((resolve, reject) => {
11 | const reader = new FileReader();
12 | reader.readAsDataURL(file);
13 | reader.onload = (event) => {
14 | const img = new Image();
15 | img.src = event.target?.result as string;
16 | img.onload = () => {
17 | let width = img.width;
18 | let height = img.height;
19 | if (width > height) {
20 | if (width > maxWidth) {
21 | height = Math.round((height * maxWidth) / width);
22 | width = maxWidth;
23 | }
24 | } else {
25 | if (height > maxHeight) {
26 | width = Math.round((width * maxHeight) / height);
27 | height = maxHeight;
28 | }
29 | }
30 |
31 | const canvas = document.createElement("canvas");
32 | canvas.width = width;
33 | canvas.height = height;
34 | const ctx = canvas.getContext("2d");
35 | if (!ctx) {
36 | reject(new Error("无法创建Canvas上下文"));
37 | return;
38 | }
39 | ctx.drawImage(img, 0, 0, width, height);
40 |
41 | const dataUrl = canvas.toDataURL(file.type || "image/jpeg", quality);
42 | resolve(dataUrl);
43 | };
44 | img.onerror = () => {
45 | reject(new Error("图片加载失败"));
46 | };
47 | };
48 | reader.onerror = () => {
49 | reject(new Error("文件读取失败"));
50 | };
51 | });
52 | };
53 |
54 | /**
55 | * 估算base64图片数据的实际大小(字节)
56 | * base64编码会将3字节的数据编码为4字节,所以实际大小约为base64字符串长度的3/4
57 | */
58 | export const estimateBase64Size = (base64String: string): number => {
59 | const base64Data = base64String.split(",")[1] || base64String;
60 | return Math.round((base64Data.length * 3) / 4);
61 | };
62 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @description Converts all images in the HTML element to base64 encoded data
3 | * @param element - The HTML element to convert. This is cloned and modified
4 | * @returns Converted HTML string.
5 | */
6 | export async function convertImagesToBase64(
7 | element: HTMLElement
8 | ): Promise {
9 | const clone = element.cloneNode(true) as HTMLElement;
10 | const images = clone.getElementsByTagName("img");
11 |
12 | await Promise.all(
13 | Array.from(images).map(async (img) => {
14 | try {
15 | const response = await fetch(img.src);
16 | const blob = await response.blob();
17 | return new Promise((resolve, reject) => {
18 | const reader = new FileReader();
19 | reader.onloadend = () => {
20 | img.src = reader.result as string;
21 | resolve();
22 | };
23 | reader.onerror = reject;
24 | reader.readAsDataURL(blob);
25 | });
26 | } catch (error) {
27 | console.error("Error converting image:", error);
28 | }
29 | })
30 | );
31 |
32 | return clone.innerHTML;
33 | }
34 |
35 | const baseUrl = "";
36 | const apiKey = "";
37 |
38 | export const openAIRequest = async (prompt: string) => {
39 | const response = await fetch(`${baseUrl}/chat/completions`, {
40 | method: "POST",
41 | headers: {
42 | "Content-Type": "application/json",
43 | Authorization: `Bearer ${apiKey}`,
44 | },
45 | body: JSON.stringify({
46 | model: "gpt-3.5-turbo",
47 | messages: [{ role: "user", content: prompt }],
48 | }),
49 | });
50 |
51 | if (!response.ok) {
52 | throw new Error(`OpenAI API request failed: ${response.statusText}`);
53 | }
54 |
55 | const data = await response.json();
56 | return data.choices[0].message.content.trim();
57 | };
58 |
--------------------------------------------------------------------------------
/src/utils/uuid.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from "uuid";
2 |
3 | export const generateUUID = (): string => {
4 | return uuidv4();
5 | };
6 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { subtle } from "crypto";
2 | import type { Config } from "tailwindcss";
3 |
4 | const config = {
5 | darkMode: ["class"],
6 | content: [
7 | "./pages/**/*.{ts,tsx}",
8 | "./components/**/*.{ts,tsx}",
9 | "./app/**/*.{ts,tsx}",
10 | "./src/**/*.{ts,tsx}"
11 | ],
12 | prefix: "",
13 | theme: {
14 | container: {
15 | center: true,
16 | padding: "2rem",
17 | screens: {
18 | "2xl": "1400px"
19 | }
20 | },
21 | extend: {
22 | colors: {
23 | baseFont: "#212529",
24 | subtitleFont: "#212529",
25 | border: "hsl(var(--border))",
26 | input: "hsl(var(--input))",
27 | ring: "hsl(var(--ring))",
28 | background: "hsl(var(--background))",
29 | foreground: "hsl(var(--foreground))",
30 | primary: {
31 | DEFAULT: "hsl(var(--primary))",
32 | foreground: "hsl(var(--primary-foreground))"
33 | },
34 | secondary: {
35 | DEFAULT: "hsl(var(--secondary))",
36 | foreground: "hsl(var(--secondary-foreground))"
37 | },
38 | destructive: {
39 | DEFAULT: "hsl(var(--destructive))",
40 | foreground: "hsl(var(--destructive-foreground))"
41 | },
42 | muted: {
43 | DEFAULT: "hsl(var(--muted))",
44 | foreground: "hsl(var(--muted-foreground))"
45 | },
46 | accent: {
47 | DEFAULT: "hsl(var(--accent))",
48 | foreground: "hsl(var(--accent-foreground))"
49 | },
50 | popover: {
51 | DEFAULT: "hsl(var(--popover))",
52 | foreground: "hsl(var(--popover-foreground))"
53 | },
54 | card: {
55 | DEFAULT: "hsl(var(--card))",
56 | foreground: "hsl(var(--card-foreground))"
57 | },
58 | sidebar: {
59 | DEFAULT: "hsl(var(--sidebar-background))",
60 | foreground: "hsl(var(--sidebar-foreground))",
61 | primary: "hsl(var(--sidebar-primary))",
62 | "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
63 | accent: "hsl(var(--sidebar-accent))",
64 | "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
65 | border: "hsl(var(--sidebar-border))",
66 | ring: "hsl(var(--sidebar-ring))"
67 | },
68 | chart: {
69 | "1": "hsl(var(--chart-1))",
70 | "2": "hsl(var(--chart-2))",
71 | "3": "hsl(var(--chart-3))",
72 | "4": "hsl(var(--chart-4))",
73 | "5": "hsl(var(--chart-5))"
74 | }
75 | },
76 | borderRadius: {
77 | lg: "var(--radius)",
78 | md: "calc(var(--radius) - 2px)",
79 | sm: "calc(var(--radius) - 4px)"
80 | },
81 | keyframes: {
82 | "accordion-down": {
83 | from: {
84 | height: "0"
85 | },
86 | to: {
87 | height: "var(--radix-accordion-content-height)"
88 | }
89 | },
90 | "accordion-up": {
91 | from: {
92 | height: "var(--radix-accordion-content-height)"
93 | },
94 | to: {
95 | height: "0"
96 | }
97 | },
98 | "fade-in": {
99 | "0%": {
100 | opacity: "0",
101 | transform: "scale(0.95)"
102 | },
103 | "100%": {
104 | opacity: "1",
105 | transform: "scale(1)"
106 | }
107 | },
108 | "fade-out": {
109 | "0%": {
110 | opacity: "1",
111 | transform: "scale(1)"
112 | },
113 | "100%": {
114 | opacity: "0",
115 | transform: "scale(0.98)"
116 | }
117 | }
118 | },
119 | animation: {
120 | "accordion-down": "accordion-down 0.2s ease-out",
121 | "accordion-up": "accordion-up 0.2s ease-out",
122 | "fade-in": "fade-in 0.4s cubic-bezier(0.16, 1, 0.3, 1)",
123 | "fade-out": "fade-out 0.4s cubic-bezier(0.16, 1, 0.3, 1)"
124 | }
125 | }
126 | },
127 | colors: {
128 | primary: "262.1 83.3% 57.8%"
129 | },
130 | plugins: [require("tailwindcss-animate")]
131 | } satisfies Config;
132 |
133 | export default config;
134 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"],
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------