├── .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 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | ![Next.js](https://img.shields.io/badge/Next.js-14.0-black) 7 | ![Framer Motion](https://img.shields.io/badge/Framer_Motion-10.0-purple) 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 | ![782shots_so](https://github.com/user-attachments/assets/dda52f82-10eb-4f8d-a643-a11c3c4da35f) 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 | [![Deploy with Vercel](https://vercel.com/button)](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 | [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | ![Next.js](https://img.shields.io/badge/Next.js-14.0-black) 7 | ![Framer Motion](https://img.shields.io/badge/Framer_Motion-10.0-purple) 8 | 9 | 简体中文 | [English](./README.en-US.md) 10 | 11 |
12 | 13 | Magic Resume 是一个现代化的在线简历编辑器,让创建专业简历变得简单有趣。基于 Next.js 和 Motion 构建,支持实时预览和自定义主题。 14 | 15 | ## 📸 项目截图 16 | 17 | ![782shots_so](https://github.com/user-attachments/assets/d59f7582-799c-468d-becf-59ee6453acfd) 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 | [![使用 Vercel 部署](https://vercel.com/button)](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 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 导出 11 | 12 | 13 | 多种导出格式 14 | 15 | 16 | 17 | 支持多种格式导出,方便分享和备份 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | PDF 62 | 63 | 64 | 65 | 66 | 67 | 68 | JSON 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /public/features/svg/grammar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | AI 11 | 12 | 13 | 语法检查 14 | 15 | 16 | 17 | 18 | ! 19 | 发现 2 个问题 20 | 21 | 22 | 23 | 24 | AI已检测到以下语法和拼写问题 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ! 34 | 35 | 36 | 应为"前端框架",用词错误 37 | 38 | 39 | 40 | 定位 41 | 42 | 43 | 44 | 45 | 46 | 建议修改: 47 | 48 | 前端 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ! 59 | 60 | 61 | 应为"工程化工具",错别字 62 | 63 | 64 | 65 | 定位 66 | 67 | 68 | 69 | 70 | 71 | 建议修改: 72 | 73 | 工程化 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /public/features/svg/local-storage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 安全 11 | 12 | 13 | 本地存储 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 本地硬盘 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 简历 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /public/features/svg/polish.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | AI 11 | 12 | 13 | 智能润色 14 | 15 | 16 | 17 | AI已为您优化内容,提升表达力和专业度 18 | 19 | 20 | 21 | 22 | 23 | • 原始内容 24 | 25 | 26 | 27 | 28 | 29 |
30 |
我负责了一个前端监控项目。这个项目使用了Vue和Element UI。它可以监控错误和性能。我还加了一些第三方的工具。
31 |
项目特点:
32 |
• 可以看到错误日志
33 |
• 有图表展示
34 |
• 支持报警功能
35 |
• 可以和其他系统集成
36 |
37 |
38 |
39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | • 润色后的内容 49 | 50 | 51 | 52 | 53 | 54 |
55 |
主导开发企业级前端监控解决方案,基于Vue框架与Element UI组件库构建,实现了全面的错误追踪与性能分析功能,并成功整合多种第三方工具提升系统能力。
56 |
核心成果:
57 |
构建完整错误日志系统,支持详细追踪与分析
58 |
设计直观数据可视化,提供实时监控面板
59 |
实现智能告警机制,支持多渠道通知
60 |
开发灵活API接口,无缝对接企业现有系统
61 |
62 |
63 |
64 |
65 |
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 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 29 | 35 | 36 | 37 | 44 | 50 | 51 | 52 | 53 | 57 | -------------------------------------------------------------------------------- /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 |
43 |
44 | 45 |
46 |
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 | 23 | DeepSeek 24 | 29 | 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 | 23 | Doubao 24 | 28 | 32 | 36 | 40 | 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 | 23 | OpenAi 24 | 26 | 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 | 21 | 22 | 23 | 24 | 25 | 26 | ), 27 | tooltip: "左对齐", 28 | }, 29 | { 30 | value: "center", 31 | icon: ( 32 | 39 | 40 | 47 | 48 | 49 | 50 | ), 51 | tooltip: "居中", 52 | }, 53 | { 54 | value: "right", 55 | icon: ( 56 | 63 | 64 | 65 | 66 | 67 | 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 |
8 |
9 |
10 |

{t("footer.copyright")}

11 |
12 |
13 |
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
{children}
; 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 |
61 |
62 |
63 | Resume Editor 72 |
73 |
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 |
31 | {children} 32 |
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 |
46 |
{children}
47 |
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 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {/* 背景圆角矩形 */} 56 | 65 | 66 | {/* 字母 R */} 67 | 75 | 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 | Selected 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 |