diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..a3a76b5f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +!README.md +.dockerignore +.DS_Store +.editorconfig +.eslint* +.git +.github +.gitignore +.husky +.nx +.prettier* +.vscode +*.env* +*.md +compose*.yml +dist +Dockerfile +node_modules +Thumbs.db +tmp \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ff9b6fc9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +node_modules diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..71ab5655 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,62 @@ +{ + "root": true, + "ignorePatterns": ["**/*"], + "plugins": ["@nx"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "extends": ["plugin:prettier/recommended"], + "plugins": ["simple-import-sort", "unused-imports"], + "rules": { + // simple-import-sort + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + + // unused-imports + "unused-imports/no-unused-imports": "error", + "unused-imports/no-unused-vars": [ + "warn", + { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", + "argsIgnorePattern": "^_" + } + ], + + // nx + "@nx/enforce-module-boundaries": [ + "error", + { + "allowCircularSelfDependency": true, + "enforceBuildableLibDependency": true, + "allow": [], + "depConstraints": [ + { + "sourceTag": "*", + "onlyDependOnLibsWithTags": ["*"] + } + ] + } + ] + } + }, + { + "files": ["*.ts", "*.tsx"], + "extends": ["plugin:@nx/typescript"], + "rules": { + // typescript-eslint + "@typescript-eslint/no-unused-vars": "off" + } + }, + { + "files": ["*.js", "*.jsx"], + "extends": ["plugin:@nx/javascript"], + "rules": { + // eslint + "no-console": "warn", + "no-unused-vars": "off" + } + } + ] +} diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml new file mode 100644 index 00000000..8e7cb80f --- /dev/null +++ b/.github/workflows/publish-docker-image.yml @@ -0,0 +1,139 @@ +name: Publish Docker Image + +on: + workflow_dispatch: + +env: + IMAGE: amruthpillai/reactive-resume + +jobs: + build: + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.version.outputs.version }} + + strategy: + fail-fast: false + matrix: + platform: + - linux/amd64 + - linux/arm64 + + steps: + - name: Checkout Repository + uses: actions/checkout@v4.1.1 + + - name: Extract version from package.json + id: version + run: | + echo "version=$(jq -r '.version' package.json)" >> "$GITHUB_OUTPUT" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3.0.0 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + + - name: Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Login to GitHub Container Registery + uses: docker/login-action@v3.0.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Extract Docker Metadata + id: meta + uses: docker/metadata-action@v5.0.0 + with: + images: ${{ env.IMAGE }} + tags: type=semver,pattern={{version}},prefix=v,value=${{ steps.version.outputs.version }} + + - name: Build and Push by Digest + uses: docker/build-push-action@v5.0.0 + id: build + with: + context: . + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + build-args: | + NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }} + + - name: Export Digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload Digest + uses: actions/upload-artifact@v3 + with: + name: digests + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + + needs: + - build + + steps: + - name: Checkout Repository + uses: actions/checkout@v4.1.1 + + - name: Download Digest + uses: actions/download-artifact@v3.0.0 + with: + name: digests + path: /tmp/digests + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.0.0 + + - name: Extract Docker Metadata + id: meta + uses: docker/metadata-action@v5.0.0 + with: + images: ${{ env.IMAGE }} + tags: type=semver,pattern={{version}},prefix=v,value=${{ needs.build.outputs.version }} + + - name: Login to Docker Hub + uses: docker/login-action@v3.0.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Login to GitHub Container Registery + uses: docker/login-action@v3.0.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ github.token }} + + - name: Create Docker Manifest List and Push + working-directory: /tmp/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.IMAGE }}@sha256:%s ' *) + + - name: Inspect Image + run: | + docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} + + - name: Update Repository Description + uses: peter-evans/dockerhub-description@v3 + with: + repository: ${{ github.repository }} + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ae109aa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Compiled Output +dist +tmp +/out-tsc + +# Project Dependencies +node_modules + +# IDEs and Editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# Miscellaneous +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +# Generated Files +.nx +stats.html + +# Environment Variables +*.env* \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 00000000..61681a81 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +pnpm exec commitlint --edit $1 diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..83252e62 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +auto-install-peers=true +enable-pre-post-scripts=true +strict-peer-dependencies=false \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..95234b76 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +/dist +/coverage +/.nx/cache +stats.html +pnpm-lock.yaml \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..de753c53 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "printWidth": 100 +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..6a302fe5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "nrwl.angular-console", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "firsttris.vscode-jest-runner" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..11346747 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "files.associations": { + "compose-dev.yml": "dockercompose", + ".github/workflows/*.yml": "github-actions-workflow" + }, + "yaml.schemas": { + "https://json.schemastore.org/github-workflow.json": "file:///Users/amruth/Projects/Reactive-Resume/.github/workflows/publish-docker-image.yml" + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d220c33f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +### What’s changed from v3 to v4? + +**The entire app has been rebuilt and reimagined from the ground up.** + +The **user interface** has been greatly streamlined to prioritise your content and resume. The design of templates has also undergone a major overhaul. Previously, we utilised TailwindCSS for creating templates, but now you can rely on CSS (styled-components) to build any design you prefer. With this change, I hope to offer a **much wider variety of templates** compared to the previous version. + +When it comes to features, there are many to mention, but some highlights include the **ability to use your own OpenAI API key** (stored on your browser) and leverage GPTs to enhance your resume writing skills. With this, you can improve your writing, correct spelling and grammar, and even adjust the tone of the text to be more confident or casual. + +When you make your resume publicly available, you are provided with a link that you can share with potential recruiters and employers. This change allows you to **track the number of views or downloads your resume has received**, so you can stay informed about when someone has checked out your resume. + +When it comes to **security**, you now have the option to protect your account with **two-factor authentication**. This means that whenever you log in to Reactive Resume, you will also need to enter a one-time code generated on your phone. This additional step ensures that only you have access to your account. + +From a **design** perspective, the motivation behind this is to ensure that Reactive Resume is taken more seriously and not perceived as just another subpar side-project, which is often associated with free software. My goal is to demonstrate that this is not the case, and that **free and open source software can be just as good**, if not better, than paid alternatives. + +From a **self-hosting perspective**, it has never been simpler. Instead of running two separate services on your Docker (one for the client and one for the server) and struggling to establish communication between them, now you only need to pull a single image. Additionally, there are a few dependent services available on Docker (such as Postgres, Redis, Minio etc.) that you can also pull and have them all working together seamlessly. + +I'm excited for you to try out the app, as I've spent months building it to perfection. If you enjoy the experience of building your resume using the app, please consider supporting by [becoming a GitHub Sponsor](https://github.com/sponsors/AmruthPillai). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..a2f578d5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# --- Base Image --- +FROM node:bullseye-slim AS base +WORKDIR /app + +ARG NX_CLOUD_ACCESS_TOKEN + +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" + +RUN corepack enable + +RUN apt update && apt install -y build-essential --no-install-recommends + +# --- Build Image --- +FROM base AS build + +ENV PUPPETEER_SKIP_DOWNLOAD true +ENV NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_ACCESS_TOKEN + +COPY .npmrc package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile + +COPY . . +RUN pnpm prisma generate + +RUN pnpm build + +# --- Release Image --- +FROM base AS release + +RUN apt update && apt install -y dumb-init --no-install-recommends + +COPY --chown=node:node --from=build /app/dist ./dist +COPY --chown=node:node --from=build /app/.npmrc /app/package.json /app/pnpm-lock.yaml ./ + +ENV PUPPETEER_SKIP_DOWNLOAD true + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm add --global husky +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile + +# Copy Prisma Generated Client +COPY --chown=node:node --from=build ./app/node_modules/.pnpm/@prisma+client* ./node_modules/.pnpm/ +COPY --chown=node:node --from=build ./app/node_modules/@prisma/client ./node_modules/@prisma/client + +# Copy Prisma Schema & Migrations +COPY --chown=node:node --from=build ./app/tools/prisma ./tools/prisma + +ENV NODE_ENV production + +CMD [ "dumb-init", "pnpm", "start" ] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..f5d45f2a --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,20 @@ +Copyright (c) 2023 Amruth Pillai + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..ac753405 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +![Reactive Resume](https://res.cloudinary.com/amruth-pillai/image/upload/v1699180255/reactive-resume/readme/banner_zvieca.png) + +![App Version](https://img.shields.io/github/package-json/version/AmruthPillai/Reactive-Resume/v4?label=version) +[![Docker Pulls](https://img.shields.io/docker/pulls/amruthpillai/reactive-resume)](https://hub.docker.com/repository/docker/amruthpillai/reactive-resume) +[![GitHub Sponsors](https://img.shields.io/github/sponsors/AmruthPillai)](https://github.com/sponsors/AmruthPillai) + +# Reactive Resume + +A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume. + +### [Go to App](https://rxresu.me/) | [Docs](https://docs.rxresu.me/) + +## Description + +Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume. With zero user tracking or advertising, your privacy is a top priority. The platform is extremely user-friendly and can be self-hosted in less than 30 seconds if you wish to own your data completely. + +It's available in multiple languages and comes packed with features such as real-time editing, dozens of templates, drag-and-drop customisation, and integration with OpenAI for enhancing your writing. + +You can share a personalised link of your resume to potential employers, track its views or downloads, and customise your page layout by dragging-and-dropping sections. The platform also supports various font options and provides dozens of templates to choose from. And yes, there's even a dark mode for a more comfortable viewing experience. + +Start creating your standout resume with Reactive Resume today! + +## Screenshots + +TODO: Add screenshots of major sections of the app + +## Features + +- **Free, forever** and open-source +- No telemetry, user tracking or advertising +- You can self-host the application in less then 30 seconds +- **Available in 5+ languages**, and counting ([help add your language here](https://translate.rxresu.me/)) +- Use your email address (or a throw-away address, no problem) to create an account +- You can also sign in with your GitHub or Google account, and even set up two-factor authentication for extra security +- Create as many resumes as you like under a single account, optimising each resume for every job application based on it's description for a higher ATS score +- **Bring your own OpenAI API key** and unlock features such as improving your writing, fixing spelling and grammar or changing the tone of your text in one-click +- Create single page resumes or a resume that spans multiple pages easily +- Mix-and-match your resume and make it yours by picking any colour +- Customise your page layout as you like just by dragging-and-dropping sections +- **Dozens of templates** to choose from, ranging from professional to modern to swanky +- Supports printing resumes in A4 or Letter page formats +- Create your resume with any font hosted by [Google Fonts](https://fonts.google.com/), and a special inclusion of [Computer Modern](https://tug.org/FontCatalogue/computermodern/) to make your resumes look like they're made in LaTeX +- **Share a personalised link of your resume** to companies or recruiters for them to get the latest updates and as a bonus, you can track how many times your resume has been viewed or downloaded since it's creation +- Built with state-of-the-art (at the moment) and dependable technologies with the simplest and most human-readable code on all of GitHub +- **MIT License**, so do what you like with the code as long as you credit the original author +- And finally yes, there's a dark mode too 🌓 + +## Built With + +- Postgres (primary database) +- DigitalOcean (infrastructure) +- Prisma ORM, which frees you to switch to any other relational database with a few minor changes in the code +- Redis (for caching, session storage and resume statistics) +- Minio (for object storage: to store avatars, resume PDFs and previews) +- Browserless (for headless chrome, to print PDFs and generate previews) +- Optional: An SMTP Server (to send password recovery emails) +- Optional: Sentry (for error tracing and performance monitoring) +- Optional: GitHub/Google OAuth Token (for quickly authenticating users) + +## Star History + + + + + + Star History Chart + + + +## Frequently Asked Questions + +
+ Who are you, and why did you build Reactive Resume? + + I'm Amruth Pillai, just another run-off-the-mill developer working at Elara Digital GmbH in Berlin, Germany. I'm married to my beautiful and insanely supportive wife who has helped me in more ways than one in seeing this project to it's fruition. I am originally from Bengaluru, India where I was a developer at Postman (the API testing tool) for a short while. My hobbies include eating, living and breathing code. + +Back in my university days, I designed a really cool dark mode resume (link on my website) using Figma and I had a line of friends and strangers asking me to design their resume for them. + +While I could have charged everyone a hefty sum and retired even before I began, I decided to build the first version of Reactive Resume in 2019. Since then, it's gone through multiple iterations as I've learned a lot of better coding practices over the years. + +At the time of writing, Reactive Resume is probably one of the only handful of resume builders out there available to the world for free and without an annoying paywall at the end. While being free is often associated with software that's not of good quality, I strive to prove them wrong and build a product that people love using and are benefitted by it. + +My dream has always been to build something that at least a handful people use on a daily basis, and I'm extremely proud to say that Reactive Resume, over it's years of development, has **helped over half a million people build their resume**, and I hope it only increases from here and reaches more people who are in need of a good resume to kickstart their career but can't afford to pay for one. + +
+ +
+ How much does it cost to run Reactive Resume? + +It's not much honestly. DigitalOcean has graciously sponsored their infrastructure to allow me to host Reactive Resume on their platform. There's a small fee I pay to dependent services, to send emails for example, and the most of it goes to managing the domain and other related apps (documentation). + +I've spent countless hours and sleepless nights building the application though, and I honestly do not expect anything in return but to hear from you on how the app has helped you with your career. + +But if you do feel like supporting the developer and the future development of Reactive Resume, please donate (_only if you have some extra money lying around_) to me on my [GitHub Sponsors page](https://github.com/sponsors/AmruthPillai/). You can choose to donate one-time or sponsor a recurring donation. + +
+ +
+ Other than donating, how can I support you? + +**If you speak a language other than English**, sign up to be a translator on Crowdin, our translation management service. You can help translate the product to your language and share it among your community. Even if the language is already translated, it helps to sign up as you would be notified when there are new phrases to be translated. + +**If you work in the media, are an influencer or have lots of friends**, share the app with your circles and let them know so it can reach the people who need it the most. I’m also [open for interviews](mailto:hello@amruthpillai.com), although that’s wishful thinking. If you do mention Reactive Resume on your blog, let me know so that I can link back to you here. + +**If you found a bug or have an idea for a feature**, raise an issue on GitHub or shoot me a message and let me know what you’d like to see. I can’t promise that it’ll be done soon, but juggling work, life and open-source, I’ll definitely get to it when I can. + +
+ +
+ How does the OpenAI Integration work? How can I trust you with my API key? + +You should **absolutely not** trust me with your OpenAI API key. + +OpenAI has been a game-changer for all of us. I cannot tell you how much ChatGPT has helped me in my everyday work and with the development of Reactive Resume. It only makes sense that you leverage what AI has to offer and let it help you build the perfect resume. + +While most applications out there charge you a fee to use their AI services (rightfully so, because it isn’t cheap), you can choose to enter your own OpenAI API key on the Settings page (under OpenAI Integration). The key is stored in your browser’s local storage, which means that if you uninstall your browser, or even clear your data, the key is gone with it. All requests made to OpenAI are also sent directly to their service and does not hit the app servers at all. + +The policy behind “Bring your own Key” (BYOK) is [still being discussed](https://community.openai.com/t/openais-bring-your-own-key-policy/14538/46) and probably might change over a period of time, but while it’s available, I would keep the feature on the app. + +You are free to turn off all AI features (and not be aware of it’s existence) simply by not adding a key in the Settings page and still make use of all of the useful features that Reactive Resume has to offer. I would even suggest you to take the extra step of using ChatGPT to write your content, and simply copy it over to Reactive Resume. + +
+ +## License + +Reactive Resume is packaged and distributed using the [MIT License](/LICENSE.md) which allows for commercial use, distribution, modification and private use provided that all copies of the software contain the same license and copyright. + +By the community, for the community. +A passion project by Amruth Pillai diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..009f19ed --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 4.x.x | :white_check_mark: | +| 3.x.x | :x: | +| 2.x.x | :x: | +| 1.x.x | :x: | + +## Reporting a Vulnerability + +Please raise an issue on GitHub to report any security vulnerabilities in the app. If the vulnerability is potentially lethal, send me an email about it on hello@amruthpillai.com. diff --git a/apps/client-e2e/.eslintrc.json b/apps/client-e2e/.eslintrc.json new file mode 100644 index 00000000..696cb8b1 --- /dev/null +++ b/apps/client-e2e/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": ["plugin:cypress/recommended", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/client-e2e/cypress.config.ts b/apps/client-e2e/cypress.config.ts new file mode 100644 index 00000000..6a2a41db --- /dev/null +++ b/apps/client-e2e/cypress.config.ts @@ -0,0 +1,8 @@ +import { nxE2EPreset } from "@nx/cypress/plugins/cypress-preset"; +import { defineConfig } from "cypress"; + +export default defineConfig({ + e2e: nxE2EPreset(__dirname, { + bundler: "vite", + }), +}); diff --git a/apps/client-e2e/project.json b/apps/client-e2e/project.json new file mode 100644 index 00000000..c9d15467 --- /dev/null +++ b/apps/client-e2e/project.json @@ -0,0 +1,33 @@ +{ + "name": "client-e2e", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/client-e2e/src", + "projectType": "application", + "targets": { + "e2e": { + "executor": "@nx/cypress:cypress", + "options": { + "cypressConfig": "apps/client-e2e/cypress.config.ts", + "devServerTarget": "client:serve:development", + "testingType": "e2e" + }, + "configurations": { + "production": { + "devServerTarget": "client:serve:production" + }, + "ci": { + "devServerTarget": "client:serve-static" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/client-e2e/**/*.{js,ts}"] + } + } + }, + "tags": ["frontend"], + "implicitDependencies": ["client"] +} diff --git a/apps/client-e2e/src/e2e/app.cy.ts b/apps/client-e2e/src/e2e/app.cy.ts new file mode 100644 index 00000000..799878ad --- /dev/null +++ b/apps/client-e2e/src/e2e/app.cy.ts @@ -0,0 +1,13 @@ +import { getGreeting } from "../support/app.po"; + +describe("client", () => { + beforeEach(() => cy.visit("/")); + + it("should display welcome message", () => { + // Custom command example, see `../support/commands.ts` file + cy.login("my-email@something.com", "myPassword"); + + // Function helper example, see `../support/app.po.ts` file + getGreeting().contains("Welcome client"); + }); +}); diff --git a/apps/client-e2e/src/fixtures/example.json b/apps/client-e2e/src/fixtures/example.json new file mode 100644 index 00000000..294cbed6 --- /dev/null +++ b/apps/client-e2e/src/fixtures/example.json @@ -0,0 +1,4 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io" +} diff --git a/apps/client-e2e/src/support/app.po.ts b/apps/client-e2e/src/support/app.po.ts new file mode 100644 index 00000000..a4135822 --- /dev/null +++ b/apps/client-e2e/src/support/app.po.ts @@ -0,0 +1 @@ +export const getGreeting = () => cy.get("h1"); diff --git a/apps/client-e2e/src/support/commands.ts b/apps/client-e2e/src/support/commands.ts new file mode 100644 index 00000000..ac470cb0 --- /dev/null +++ b/apps/client-e2e/src/support/commands.ts @@ -0,0 +1,33 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** + +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace Cypress { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface Chainable { + login(email: string, password: string): void; + } +} +// +// -- This is a parent command -- +Cypress.Commands.add("login", (email, password) => { + console.log("Custom command example: Login", email, password); +}); +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/apps/client-e2e/src/support/e2e.ts b/apps/client-e2e/src/support/e2e.ts new file mode 100644 index 00000000..413b0ecf --- /dev/null +++ b/apps/client-e2e/src/support/e2e.ts @@ -0,0 +1,17 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import "./commands"; diff --git a/apps/client-e2e/tsconfig.json b/apps/client-e2e/tsconfig.json new file mode 100644 index 00000000..cc509a73 --- /dev/null +++ b/apps/client-e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "sourceMap": false, + "outDir": "../../dist/out-tsc", + "allowJs": true, + "types": ["cypress", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.js", "cypress.config.ts"] +} diff --git a/apps/client/.eslintrc.json b/apps/client/.eslintrc.json new file mode 100644 index 00000000..d48293bc --- /dev/null +++ b/apps/client/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "extends": [ + "plugin:tailwindcss/recommended", + "plugin:@tanstack/eslint-plugin-query/recommended" + ], + "settings": { + "tailwindcss": { + "callees": ["cn", "clsx", "cva"], + "config": "tailwind.config.js" + } + }, + "rules": { + // react-hooks + "react-hooks/exhaustive-deps": "off" + } + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/apps/client/index.html b/apps/client/index.html new file mode 100644 index 00000000..6a93c019 --- /dev/null +++ b/apps/client/index.html @@ -0,0 +1,43 @@ + + + + + + + Reactive Resume - A free and open-source resume builder + + + + + + + + + + + + + + + + + +
+ + + + + diff --git a/apps/client/postcss.config.js b/apps/client/postcss.config.js new file mode 100644 index 00000000..d3571b0a --- /dev/null +++ b/apps/client/postcss.config.js @@ -0,0 +1,10 @@ +const { join } = require("path"); + +module.exports = { + plugins: { + "postcss-import": {}, + "tailwindcss/nesting": {}, + tailwindcss: { config: join(__dirname, "tailwind.config.js") }, + autoprefixer: {}, + }, +}; diff --git a/apps/client/project.json b/apps/client/project.json new file mode 100644 index 00000000..e73be670 --- /dev/null +++ b/apps/client/project.json @@ -0,0 +1,80 @@ +{ + "name": "client", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/client/src", + "projectType": "application", + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "dependsOn": ["^build"], + "options": { + "outputPath": "dist/apps/client" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "client:build", + "proxyConfig": "apps/client/proxy.conf.json" + }, + "configurations": { + "development": { + "buildTarget": "client:build:development", + "hmr": true + }, + "production": { + "buildTarget": "client:build:production", + "hmr": false + } + } + }, + "preview": { + "executor": "@nx/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "client:build" + }, + "configurations": { + "development": { + "buildTarget": "client:build:development" + }, + "production": { + "buildTarget": "client:build:production" + } + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "passWithNoTests": true, + "reportsDirectory": "../../coverage/apps/client" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["apps/client/**/*.{ts,tsx,js,jsx}"] + } + }, + "serve-static": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "client:build" + } + } + }, + "tags": ["frontend"] +} diff --git a/apps/client/proxy.conf.json b/apps/client/proxy.conf.json new file mode 100644 index 00000000..63dd6275 --- /dev/null +++ b/apps/client/proxy.conf.json @@ -0,0 +1,6 @@ +{ + "/api": { + "target": "http://localhost:3000", + "secure": false + } +} diff --git a/apps/client/public/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg b/apps/client/public/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg new file mode 100644 index 00000000..c61b4bd9 Binary files /dev/null and b/apps/client/public/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg differ diff --git a/apps/client/public/brand-logos/dark/amazon.svg b/apps/client/public/brand-logos/dark/amazon.svg new file mode 100644 index 00000000..85bc7c6e --- /dev/null +++ b/apps/client/public/brand-logos/dark/amazon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/dark/google.svg b/apps/client/public/brand-logos/dark/google.svg new file mode 100644 index 00000000..1cff235b --- /dev/null +++ b/apps/client/public/brand-logos/dark/google.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/dark/postman.svg b/apps/client/public/brand-logos/dark/postman.svg new file mode 100644 index 00000000..c62085c5 --- /dev/null +++ b/apps/client/public/brand-logos/dark/postman.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/dark/twilio.svg b/apps/client/public/brand-logos/dark/twilio.svg new file mode 100644 index 00000000..cb8b5215 --- /dev/null +++ b/apps/client/public/brand-logos/dark/twilio.svg @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/dark/zalando.svg b/apps/client/public/brand-logos/dark/zalando.svg new file mode 100644 index 00000000..bc4ee450 --- /dev/null +++ b/apps/client/public/brand-logos/dark/zalando.svg @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/light/amazon.svg b/apps/client/public/brand-logos/light/amazon.svg new file mode 100644 index 00000000..f1a324c7 --- /dev/null +++ b/apps/client/public/brand-logos/light/amazon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/light/google.svg b/apps/client/public/brand-logos/light/google.svg new file mode 100644 index 00000000..731b2e2c --- /dev/null +++ b/apps/client/public/brand-logos/light/google.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/light/postman.svg b/apps/client/public/brand-logos/light/postman.svg new file mode 100644 index 00000000..f6057e0b --- /dev/null +++ b/apps/client/public/brand-logos/light/postman.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/light/twilio.svg b/apps/client/public/brand-logos/light/twilio.svg new file mode 100644 index 00000000..ed0eb3ca --- /dev/null +++ b/apps/client/public/brand-logos/light/twilio.svg @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/brand-logos/light/zalando.svg b/apps/client/public/brand-logos/light/zalando.svg new file mode 100644 index 00000000..11651021 --- /dev/null +++ b/apps/client/public/brand-logos/light/zalando.svg @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/apps/client/public/favicon.ico b/apps/client/public/favicon.ico new file mode 100644 index 00000000..ef0a8a37 Binary files /dev/null and b/apps/client/public/favicon.ico differ diff --git a/apps/client/public/icon/dark.svg b/apps/client/public/icon/dark.svg new file mode 100644 index 00000000..1709463f --- /dev/null +++ b/apps/client/public/icon/dark.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/apps/client/public/icon/light.svg b/apps/client/public/icon/light.svg new file mode 100644 index 00000000..8208f4eb --- /dev/null +++ b/apps/client/public/icon/light.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/apps/client/public/logo/dark.svg b/apps/client/public/logo/dark.svg new file mode 100644 index 00000000..03bf4190 --- /dev/null +++ b/apps/client/public/logo/dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/logo/light.svg b/apps/client/public/logo/light.svg new file mode 100644 index 00000000..6a7ffcae --- /dev/null +++ b/apps/client/public/logo/light.svg @@ -0,0 +1,18 @@ + + + + + + + + + \ No newline at end of file diff --git a/apps/client/public/screenshots/builder.png b/apps/client/public/screenshots/builder.png new file mode 100644 index 00000000..902c0e9c Binary files /dev/null and b/apps/client/public/screenshots/builder.png differ diff --git a/apps/client/public/scripts/initialize-theme.js b/apps/client/public/scripts/initialize-theme.js new file mode 100644 index 00000000..7d7e5d08 --- /dev/null +++ b/apps/client/public/scripts/initialize-theme.js @@ -0,0 +1,14 @@ +(function initializeTheme() { + try { + if ( + localStorage.theme === "dark" || + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } + } catch (_) { + // pass + } +})(); diff --git a/apps/client/src/assets/.gitkeep b/apps/client/src/assets/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/apps/client/src/components/ai-actions.tsx b/apps/client/src/components/ai-actions.tsx new file mode 100644 index 00000000..583843f3 --- /dev/null +++ b/apps/client/src/components/ai-actions.tsx @@ -0,0 +1,121 @@ +import { + CaretDown, + ChatTeardropText, + CircleNotch, + Exam, + MagicWand, + PenNib, +} from "@phosphor-icons/react"; +import { + Badge, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { useState } from "react"; + +import { changeTone } from "../services/openai/change-tone"; +import { fixGrammar } from "../services/openai/fix-grammar"; +import { improveWriting } from "../services/openai/improve-writing"; +import { useOpenAiStore } from "../stores/openai"; + +type Action = "improve" | "fix" | "tone"; +type Mood = "casual" | "professional" | "confident" | "friendly"; + +type Props = { + value: string; + onChange: (value: string) => void; + className?: string; +}; + +export const AiActions = ({ value, onChange, className }: Props) => { + const [loading, setLoading] = useState(false); + const aiEnabled = useOpenAiStore((state) => !!state.apiKey); + + if (!aiEnabled) return null; + + const onClick = async (action: Action, mood?: Mood) => { + setLoading(action); + let result = value; + + // await new Promise((resolve) => setTimeout(resolve, 2000)); + + if (action === "improve") result = await improveWriting(value); + if (action === "fix") result = await fixGrammar(value); + if (action === "tone" && mood) result = await changeTone(value, mood); + + onChange("Result" + result); + + setLoading(false); + }; + + return ( +
+
+ + + AI + +
+ + + + + + + + + + + onClick("tone", "casual")}> + + 🙂 + + Casual + + onClick("tone", "professional")}> + + 💼 + + Professional + + onClick("tone", "confident")}> + + 😎 + + Confident + + onClick("tone", "friendly")}> + + 😊 + + Friendly + + + +
+ ); +}; diff --git a/apps/client/src/components/copyright.tsx b/apps/client/src/components/copyright.tsx new file mode 100644 index 00000000..477d87e5 --- /dev/null +++ b/apps/client/src/components/copyright.tsx @@ -0,0 +1,34 @@ +import { cn } from "@reactive-resume/utils"; + +type Props = { + className?: string; +}; + +export const Copyright = ({ className }: Props) => ( +
+ + Licensed under{" "} + + MIT + + + By the community, for the community. + + A passion project by{" "} + + Amruth Pillai + + + + Reactive Resume v{appVersion} +
+); diff --git a/apps/client/src/components/icon.tsx b/apps/client/src/components/icon.tsx new file mode 100644 index 00000000..99df43db --- /dev/null +++ b/apps/client/src/components/icon.tsx @@ -0,0 +1,32 @@ +import { useTheme } from "@reactive-resume/hooks"; +import { cn } from "@reactive-resume/utils"; + +type Props = { + size?: number; + className?: string; +}; + +export const Icon = ({ size = 32, className }: Props) => { + const { isDarkMode } = useTheme(); + + let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + + switch (isDarkMode) { + case false: + src = "/icon/dark.svg"; + break; + case true: + src = "/icon/light.svg"; + break; + } + + return ( + Reactive Resume + ); +}; diff --git a/apps/client/src/components/logo.tsx b/apps/client/src/components/logo.tsx new file mode 100644 index 00000000..9b799c86 --- /dev/null +++ b/apps/client/src/components/logo.tsx @@ -0,0 +1,32 @@ +import { useTheme } from "@reactive-resume/hooks"; +import { cn } from "@reactive-resume/utils"; + +type Props = { + size?: number; + className?: string; +}; + +export const Logo = ({ size = 32, className }: Props) => { + const { isDarkMode } = useTheme(); + + let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + + switch (isDarkMode) { + case false: + src = "/logo/light.svg"; + break; + case true: + src = "/logo/dark.svg"; + break; + } + + return ( + Reactive Resume + ); +}; diff --git a/apps/client/src/components/theme-switch.tsx b/apps/client/src/components/theme-switch.tsx new file mode 100644 index 00000000..c23d6655 --- /dev/null +++ b/apps/client/src/components/theme-switch.tsx @@ -0,0 +1,33 @@ +import { CloudSun, Moon, Sun } from "@phosphor-icons/react"; +import { useTheme } from "@reactive-resume/hooks"; +import { Button } from "@reactive-resume/ui"; +import { motion, Variants } from "framer-motion"; +import { useMemo } from "react"; + +type Props = { + size?: number; +}; + +export const ThemeSwitch = ({ size = 20 }: Props) => { + const { theme, toggleTheme } = useTheme(); + + const variants: Variants = useMemo(() => { + return { + light: { x: 0 }, + system: { x: size * -1 }, + dark: { x: size * -2 }, + }; + }, [size]); + + return ( + + ); +}; diff --git a/apps/client/src/components/user-avatar.tsx b/apps/client/src/components/user-avatar.tsx new file mode 100644 index 00000000..1c26a9e0 --- /dev/null +++ b/apps/client/src/components/user-avatar.tsx @@ -0,0 +1,40 @@ +import { getInitials } from "@reactive-resume/utils"; + +import { useUser } from "../services/user"; + +type Props = { + size?: number; + className?: string; +}; + +export const UserAvatar = ({ size = 36, className }: Props) => { + const { user } = useUser(); + + if (!user) return null; + + let picture: React.ReactNode = null; + + if (!user.picture) { + const initials = getInitials(user.name); + + picture = ( +
+ {initials} +
+ ); + } else { + picture = ( + {user.name} + ); + } + + return
{picture}
; +}; diff --git a/apps/client/src/components/user-options.tsx b/apps/client/src/components/user-options.tsx new file mode 100644 index 00000000..28451cab --- /dev/null +++ b/apps/client/src/components/user-options.tsx @@ -0,0 +1,38 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + KeyboardShortcut, +} from "@reactive-resume/ui"; +import { useNavigate } from "react-router-dom"; + +import { useLogout } from "../services/auth"; + +type Props = { + children: React.ReactNode; +}; + +export const UserOptions = ({ children }: Props) => { + const navigate = useNavigate(); + const { logout } = useLogout(); + + return ( + + {children} + + + navigate("/dashboard/settings")}> + Settings + ⇧S + + + logout()}> + Logout + ⇧Q + + + + ); +}; diff --git a/apps/client/src/constants/colors.ts b/apps/client/src/constants/colors.ts new file mode 100644 index 00000000..5062d39d --- /dev/null +++ b/apps/client/src/constants/colors.ts @@ -0,0 +1,20 @@ +export const colors: string[] = [ + "#78716c", // stone-500 + "#ef4444", // red-500 + "#f97316", // orange-500 + "#f59e0b", // amber-500 + "#eab308", // yellow-500 + "#84cc16", // lime-500 + "#22c55e", // green-500 + "#10b981", // emerald-500 + "#14b8a6", // teal-500 + "#06b6d4", // cyan-500 + "#0ea5e9", // sky-500 + "#3b82f6", // blue-500 + "#6366f1", // indigo-500 + "#8b5cf6", // violet-500 + "#a855f7", // purple-500 + "#d946ef", // fuchsia-500 + "#ec4899", // pink-500 + "#f43f5e", // rose-500 +]; diff --git a/apps/client/src/constants/parallax-tilt.ts b/apps/client/src/constants/parallax-tilt.ts new file mode 100644 index 00000000..301a5006 --- /dev/null +++ b/apps/client/src/constants/parallax-tilt.ts @@ -0,0 +1,11 @@ +import { ReactParallaxTiltProps } from "react-parallax-tilt"; + +export const defaultTiltProps: ReactParallaxTiltProps = { + scale: 1.05, + tiltMaxAngleX: 8, + tiltMaxAngleY: 8, + perspective: 1400, + glareEnable: true, + glareMaxOpacity: 0.1, + glareColor: "#fafafa", +}; diff --git a/apps/client/src/constants/query-keys.ts b/apps/client/src/constants/query-keys.ts new file mode 100644 index 00000000..7a4037d9 --- /dev/null +++ b/apps/client/src/constants/query-keys.ts @@ -0,0 +1,7 @@ +import { QueryKey } from "@tanstack/react-query"; + +export const USER_KEY: QueryKey = ["user"]; + +export const RESUME_KEY: QueryKey = ["resume"]; +export const RESUMES_KEY: QueryKey = ["resumes"]; +export const RESUME_PREVIEW_KEY: QueryKey = ["resume", "preview"]; diff --git a/apps/client/src/hooks/use-toast.ts b/apps/client/src/hooks/use-toast.ts new file mode 100644 index 00000000..31bd22e8 --- /dev/null +++ b/apps/client/src/hooks/use-toast.ts @@ -0,0 +1,177 @@ +import { createId } from "@paralleldrive/cuid2"; +import { ToastActionElement, ToastProps } from "@reactive-resume/ui"; +import { useEffect, useState } from "react"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 5000; + +type ToasterToast = ToastProps & { + id: string; + icon?: React.ReactNode; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +const listeners: Array<(state: State) => void> = []; + +let memoryState: State = { toasts: [] }; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +type Toast = Omit; + +function toast({ ...props }: Toast) { + const id = createId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = useState(memoryState); + + useEffect(() => { + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + + if (index > -1) listeners.splice(index, 1); + }; + }, [state]); + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { toast, useToast }; diff --git a/apps/client/src/libs/axios.ts b/apps/client/src/libs/axios.ts new file mode 100644 index 00000000..3961b439 --- /dev/null +++ b/apps/client/src/libs/axios.ts @@ -0,0 +1,53 @@ +import { deepSearchAndParseDates } from "@reactive-resume/utils"; +import _axios from "axios"; +import createAuthRefreshInterceptor from "axios-auth-refresh"; +import { redirect } from "react-router-dom"; + +import { USER_KEY } from "../constants/query-keys"; +import { refresh } from "../services/auth/refresh"; +import { queryClient } from "./query-client"; + +export type ServerError = { + statusCode: number; + message: string; + error: string; +}; + +export const axios = _axios.create({ baseURL: "/api", withCredentials: true }); + +// Intercept responses to transform ISO dates to JS date objects +axios.interceptors.response.use((response) => { + const transformedResponse = deepSearchAndParseDates(response.data, ["createdAt", "updatedAt"]); + return { ...response, data: transformedResponse }; +}); + +// Create another instance to handle failed refresh tokens +// Reference: https://github.com/Flyrell/axios-auth-refresh/issues/191 +const axiosForRefresh = _axios.create({ baseURL: "/api", withCredentials: true }); + +// Interceptor to handle expired access token errors +const handleAuthError = async () => { + try { + await refresh(axiosForRefresh); + + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } +}; + +// Interceptor to handle expired refresh token errors +const handleRefreshError = async () => { + try { + queryClient.invalidateQueries({ queryKey: USER_KEY }); + redirect("/auth/login"); + + return Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } +}; + +// Intercept responses to check for 401 and 403 errors, refresh token and retry the request +createAuthRefreshInterceptor(axios, handleAuthError, { statusCodes: [401, 403] }); +createAuthRefreshInterceptor(axiosForRefresh, handleRefreshError); diff --git a/apps/client/src/libs/dayjs.ts b/apps/client/src/libs/dayjs.ts new file mode 100644 index 00000000..104215f3 --- /dev/null +++ b/apps/client/src/libs/dayjs.ts @@ -0,0 +1,6 @@ +import dayjs from "dayjs"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import relativeTime from "dayjs/plugin/relativeTime"; + +dayjs.extend(localizedFormat); +dayjs.extend(relativeTime); diff --git a/apps/client/src/libs/query-client.ts b/apps/client/src/libs/query-client.ts new file mode 100644 index 00000000..a0afb5d2 --- /dev/null +++ b/apps/client/src/libs/query-client.ts @@ -0,0 +1,16 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + queries: { + retry: false, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + staleTime: 1000 * 60, // 1 minute + }, + }, +}); diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx new file mode 100644 index 00000000..44424378 --- /dev/null +++ b/apps/client/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import * as ReactDOM from "react-dom/client"; +import { RouterProvider } from "react-router-dom"; + +import { router } from "./router"; + +const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); + +root.render( + + + , +); diff --git a/apps/client/src/pages/auth/_components/social-auth.tsx b/apps/client/src/pages/auth/_components/social-auth.tsx new file mode 100644 index 00000000..efb51c70 --- /dev/null +++ b/apps/client/src/pages/auth/_components/social-auth.tsx @@ -0,0 +1,20 @@ +import { GithubLogo, GoogleLogo } from "@phosphor-icons/react"; +import { Button } from "@reactive-resume/ui"; + +export const SocialAuth = () => ( + +); diff --git a/apps/client/src/pages/auth/backup-otp/page.tsx b/apps/client/src/pages/auth/backup-otp/page.tsx new file mode 100644 index 00000000..ea6bcd75 --- /dev/null +++ b/apps/client/src/pages/auth/backup-otp/page.tsx @@ -0,0 +1,102 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Warning } from "@phosphor-icons/react"; +import { twoFactorBackupSchema } from "@reactive-resume/dto"; +import { usePasswordToggle } from "@reactive-resume/hooks"; +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { z } from "zod"; + +import { toast } from "@/client/hooks/use-toast"; +import { useBackupOtp } from "@/client/services/auth"; + +type FormValues = z.infer; + +export const BackupOtpPage = () => { + const navigate = useNavigate(); + const { backupOtp, loading } = useBackupOtp(); + + const formRef = useRef(null); + usePasswordToggle(formRef); + + const form = useForm({ + resolver: zodResolver(twoFactorBackupSchema), + defaultValues: { code: "" }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await backupOtp(data); + + navigate("/dashboard"); + } catch (error) { + form.reset(); + + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to sign in", + description: message, + }); + } + } + }; + + return ( +
+
+

Use your backup code

+
+ Enter one of the 10 backup codes you saved when you enabled two-factor authentication. +
+
+ +
+
+ + ( + + Backup Code + + + + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/auth/forgot-password/page.tsx b/apps/client/src/pages/auth/forgot-password/page.tsx new file mode 100644 index 00000000..64e61b4c --- /dev/null +++ b/apps/client/src/pages/auth/forgot-password/page.tsx @@ -0,0 +1,105 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Warning } from "@phosphor-icons/react"; +import { forgotPasswordSchema } from "@reactive-resume/dto"; +import { + Alert, + AlertDescription, + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { toast } from "@/client/hooks/use-toast"; +import { useForgotPassword } from "@/client/services/auth"; + +type FormValues = z.infer; + +export const ForgotPasswordPage = () => { + const [submitted, setSubmitted] = useState(false); + const { forgotPassword, loading } = useForgotPassword(); + + const form = useForm({ + resolver: zodResolver(forgotPasswordSchema), + defaultValues: { email: "" }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await forgotPassword(data); + setSubmitted(true); + form.reset(); + } catch (error) { + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to send your password recovery email", + description: message, + }); + } + } + }; + + if (submitted) { + return ( +
+
+

You've got mail!

+ + + A password reset link should have been sent to your inbox, if an account existed with + the email you provided. + + +
+
+ ); + } + + return ( +
+
+

Forgot your password?

+
+ Enter your email address and we will send you a link to reset your password if the account + exists. +
+
+ +
+
+ + ( + + Email + + + + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/auth/layout.tsx b/apps/client/src/pages/auth/layout.tsx new file mode 100644 index 00000000..64fc22b6 --- /dev/null +++ b/apps/client/src/pages/auth/layout.tsx @@ -0,0 +1,58 @@ +import { useMemo } from "react"; +import { Link, matchRoutes, Outlet, useLocation } from "react-router-dom"; + +import { Logo } from "@/client/components/logo"; + +import { SocialAuth } from "./_components/social-auth"; + +const authRoutes = [{ path: "/auth/login" }, { path: "/auth/register" }]; + +export const AuthLayout = () => { + const location = useLocation(); + + const isAuthRoute = useMemo(() => matchRoutes(authRoutes, location) !== null, [location]); + + return ( +
+
+ + + + + + + {isAuthRoute && ( + <> +
+
+ or continue with +
+
+ + + + )} +
+ + +
+ ); +}; diff --git a/apps/client/src/pages/auth/login/page.tsx b/apps/client/src/pages/auth/login/page.tsx new file mode 100644 index 00000000..c2a20248 --- /dev/null +++ b/apps/client/src/pages/auth/login/page.tsx @@ -0,0 +1,126 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowRight, Warning } from "@phosphor-icons/react"; +import { loginSchema } from "@reactive-resume/dto"; +import { usePasswordToggle } from "@reactive-resume/hooks"; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { Link } from "react-router-dom"; +import { z } from "zod"; + +import { useToast } from "@/client/hooks/use-toast"; +import { useLogin } from "@/client/services/auth"; + +type FormValues = z.infer; + +export const LoginPage = () => { + const { toast } = useToast(); + const { login, loading } = useLogin(); + + const formRef = useRef(null); + usePasswordToggle(formRef); + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { identifier: "", password: "" }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await login(data); + } catch (error) { + form.reset(); + + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to sign in", + description: message, + }); + } + } + }; + + return ( +
+
+

Sign in to your account

+
+ Don't have an account? + +
+
+ +
+
+ + ( + + Email + + + + You can also enter your username. + + + )} + /> + + ( + + Password + + + + + Hold Ctrl to display your password + temporarily. + + + + )} + /> + +
+ + + +
+ + +
+
+ ); +}; diff --git a/apps/client/src/pages/auth/register/page.tsx b/apps/client/src/pages/auth/register/page.tsx new file mode 100644 index 00000000..bcd6ba5f --- /dev/null +++ b/apps/client/src/pages/auth/register/page.tsx @@ -0,0 +1,155 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowRight, Warning } from "@phosphor-icons/react"; +import { registerSchema } from "@reactive-resume/dto"; +import { usePasswordToggle } from "@reactive-resume/hooks"; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate } from "react-router-dom"; +import { z } from "zod"; + +import { toast } from "@/client/hooks/use-toast"; +import { useRegister } from "@/client/services/auth"; + +type FormValues = z.infer; + +export const RegisterPage = () => { + const navigate = useNavigate(); + const { register, loading } = useRegister(); + + const formRef = useRef(null); + usePasswordToggle(formRef); + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + name: "", + username: "", + email: "", + password: "", + language: "en", + }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await register(data); + + navigate("/auth/verify-email"); + } catch (error) { + form.reset(); + + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to sign up", + description: message, + }); + } + } + }; + + return ( +
+
+

Create a new account

+
+ Already have an account? + +
+
+ +
+
+ + ( + + Name + + + + + + )} + /> + + ( + + Username + + + + + + )} + /> + + ( + + Email + + + + + + )} + /> + + ( + + Password + + + + + Hold Ctrl to display your password + temporarily. + + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/auth/reset-password/page.tsx b/apps/client/src/pages/auth/reset-password/page.tsx new file mode 100644 index 00000000..993fd800 --- /dev/null +++ b/apps/client/src/pages/auth/reset-password/page.tsx @@ -0,0 +1,110 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Warning } from "@phosphor-icons/react"; +import { resetPasswordSchema } from "@reactive-resume/dto"; +import { usePasswordToggle } from "@reactive-resume/hooks"; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useEffect, useRef } from "react"; +import { useForm } from "react-hook-form"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { z } from "zod"; + +import { toast } from "@/client/hooks/use-toast"; +import { useResetPassword } from "@/client/services/auth"; + +type FormValues = z.infer; + +export const ResetPasswordPage = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token") || ""; + + const { resetPassword, loading } = useResetPassword(); + + const formRef = useRef(null); + usePasswordToggle(formRef); + + const form = useForm({ + resolver: zodResolver(resetPasswordSchema), + defaultValues: { token, password: "" }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await resetPassword(data); + + navigate("/auth/login"); + } catch (error) { + form.reset(); + + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to reset your password", + description: message, + }); + } + } + }; + + // Redirect the user to the forgot password page if the token is not present. + useEffect(() => { + if (!token) navigate("/auth/forgot-password"); + }, [token, navigate]); + + return ( +
+
+

Reset your password

+
+ Enter a new password below, and make sure it's secure. +
+
+ +
+
+ + ( + + Password + + + + + Hold Ctrl to display your password + temporarily. + + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/auth/verify-email/page.tsx b/apps/client/src/pages/auth/verify-email/page.tsx new file mode 100644 index 00000000..4ce5b325 --- /dev/null +++ b/apps/client/src/pages/auth/verify-email/page.tsx @@ -0,0 +1,80 @@ +import { ArrowRight, Info, SealCheck, Warning } from "@phosphor-icons/react"; +import { Alert, AlertDescription, AlertTitle, Button } from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useEffect } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; + +import { useToast } from "@/client/hooks/use-toast"; +import { queryClient } from "@/client/libs/query-client"; +import { useVerifyEmail } from "@/client/services/auth"; + +export const VerifyEmailPage = () => { + const { toast } = useToast(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + + const { verifyEmail, loading } = useVerifyEmail(); + + useEffect(() => { + const handleVerifyEmail = async (token: string) => { + try { + await verifyEmail({ token }); + await queryClient.invalidateQueries({ queryKey: ["user"] }); + + toast({ + variant: "success", + icon: , + title: "Your email address has been verified successfully.", + }); + + navigate("/dashboard/resumes", { replace: true }); + } catch (error) { + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to verify your email address", + description: message, + }); + } + } + }; + + if (!token) return; + + handleVerifyEmail(token); + }, [token, navigate, verifyEmail]); + + return ( +
+
+

Verify your email address

+

+ You should have received an email from Reactive Resume with a link to + verify your account. +

+
+ + + + + Please note that this step is completely optional. + + + We verify your email address only to ensure that we can send you a password reset link in + case you forget your password. + + + + +
+ ); +}; diff --git a/apps/client/src/pages/auth/verify-otp/page.tsx b/apps/client/src/pages/auth/verify-otp/page.tsx new file mode 100644 index 00000000..3a43ecf0 --- /dev/null +++ b/apps/client/src/pages/auth/verify-otp/page.tsx @@ -0,0 +1,104 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { ArrowRight, Warning } from "@phosphor-icons/react"; +import { twoFactorSchema } from "@reactive-resume/dto"; +import { usePasswordToggle } from "@reactive-resume/hooks"; +import { + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; +import { Link, useNavigate } from "react-router-dom"; +import { z } from "zod"; + +import { toast } from "@/client/hooks/use-toast"; +import { useVerifyOtp } from "@/client/services/auth"; + +type FormValues = z.infer; + +export const VerifyOtpPage = () => { + const navigate = useNavigate(); + const { verifyOtp, loading } = useVerifyOtp(); + + const formRef = useRef(null); + usePasswordToggle(formRef); + + const form = useForm({ + resolver: zodResolver(twoFactorSchema), + defaultValues: { code: "" }, + }); + + const onSubmit = async (data: FormValues) => { + try { + await verifyOtp(data); + + navigate("/dashboard"); + } catch (error) { + form.reset(); + + if (error instanceof AxiosError) { + const message = error.response?.data.message || error.message; + + toast({ + variant: "error", + icon: , + title: "An error occurred while trying to sign in", + description: message, + }); + } + } + }; + + return ( +
+
+

Two Step Verification

+
+ + Enter the one-time password provided by your authenticator app below. + + +
+
+ +
+
+ + ( + + One-Time Password + + + + + + )} + /> + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/_components/header.tsx b/apps/client/src/pages/builder/_components/header.tsx new file mode 100644 index 00000000..e7e78d9d --- /dev/null +++ b/apps/client/src/pages/builder/_components/header.tsx @@ -0,0 +1,59 @@ +import { HouseSimple, SidebarSimple } from "@phosphor-icons/react"; +import { useBreakpoint } from "@reactive-resume/hooks"; +import { Button } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { Link } from "react-router-dom"; + +import { useBuilderStore } from "@/client/stores/builder"; +import { useResumeStore } from "@/client/stores/resume"; + +export const BuilderHeader = () => { + const { isDesktop } = useBreakpoint(); + const defaultPanelSize = isDesktop ? 25 : 0; + + const toggle = useBuilderStore((state) => state.toggle); + const title = useResumeStore((state) => state.resume.title); + const isDragging = useBuilderStore( + (state) => state.panel.left.isDragging || state.panel.right.isDragging, + ); + const leftPanelSize = useBuilderStore( + (state) => state.panel.left.ref?.getSize() ?? defaultPanelSize, + ); + const rightPanelSize = useBuilderStore( + (state) => state.panel.right.ref?.getSize() ?? defaultPanelSize, + ); + + const onToggle = (side: "left" | "right") => toggle(side); + + return ( +
+
+ + +
+ + + {"/"} + +

{title}

+
+ + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/_components/toolbar.tsx b/apps/client/src/pages/builder/_components/toolbar.tsx new file mode 100644 index 00000000..85860366 --- /dev/null +++ b/apps/client/src/pages/builder/_components/toolbar.tsx @@ -0,0 +1,167 @@ +import { + ArrowClockwise, + ArrowCounterClockwise, + CircleNotch, + ClockClockwise, + CubeFocus, + DownloadSimple, + Hash, + LineSegment, + LinkSimple, + MagnifyingGlassMinus, + MagnifyingGlassPlus, +} from "@phosphor-icons/react"; +import { Button, Separator, Toggle, Tooltip } from "@reactive-resume/ui"; +import { motion } from "framer-motion"; + +import { usePrintResume } from "@/client/services/resume"; +import { useBuilderStore } from "@/client/stores/builder"; +import { useResumeStore, useTemporalResumeStore } from "@/client/stores/resume"; + +export const BuilderToolbar = () => { + const setValue = useResumeStore((state) => state.setValue); + const undo = useTemporalResumeStore((state) => state.undo); + const redo = useTemporalResumeStore((state) => state.redo); + const transformRef = useBuilderStore((state) => state.transform.ref); + + const id = useResumeStore((state) => state.resume.id); + const isPublic = useResumeStore((state) => state.resume.visibility === "public"); + const pageOptions = useResumeStore((state) => state.resume.data.metadata.page.options); + + const { printResume, loading } = usePrintResume(); + + const onPrint = async () => { + const { url } = await printResume({ id }); + + const openInNewTab = (url: string) => { + const win = window.open(url, "_blank"); + if (win) win.focus(); + }; + + openInNewTab(url); + }; + + return ( + +
+ {/* Undo */} + + + + + {/* Redo */} + + + + + + + {/* Zoom In */} + + + + + {/* Zoom Out */} + + + + + + + + + {/* Center Artboard */} + + + + + + + {/* Toggle Page Break Line */} + + { + setValue("metadata.page.options.breakLine", pressed); + }} + > + + + + + {/* Toggle Page Numbers */} + + { + setValue("metadata.page.options.pageNumbers", pressed); + }} + > + + + + + + + {/* Copy Link to Resume */} + + + + + {/* Download PDF */} + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/layout.tsx b/apps/client/src/pages/builder/layout.tsx new file mode 100644 index 00000000..1711f3ac --- /dev/null +++ b/apps/client/src/pages/builder/layout.tsx @@ -0,0 +1,100 @@ +import { useBreakpoint } from "@reactive-resume/hooks"; +import { Panel, PanelGroup, PanelResizeHandle, Sheet, SheetContent } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { Outlet } from "react-router-dom"; + +import { useBuilderStore } from "@/client/stores/builder"; + +import { BuilderHeader } from "./_components/header"; +import { BuilderToolbar } from "./_components/toolbar"; +import { LeftSidebar } from "./sidebars/left"; +import { RightSidebar } from "./sidebars/right"; + +const OutletSlot = () => ( + <> + + +
+ +
+ + + +); + +export const BuilderLayout = () => { + const { isDesktop } = useBreakpoint(); + + const panel = useBuilderStore((state) => state.panel); + const sheet = useBuilderStore((state) => state.sheet); + + const onOpenAutoFocus = (event: Event) => event.preventDefault(); + + if (isDesktop) { + return ( +
+ + + + + + + + + + + + + +
+ ); + } + + return ( +
+ + + + + + + + + + + + + +
+ ); +}; diff --git a/apps/client/src/pages/builder/page.tsx b/apps/client/src/pages/builder/page.tsx new file mode 100644 index 00000000..427cb2a5 --- /dev/null +++ b/apps/client/src/pages/builder/page.tsx @@ -0,0 +1,98 @@ +import { ResumeDto } from "@reactive-resume/dto"; +import { SectionKey } from "@reactive-resume/schema"; +import { + Artboard, + PageBreakLine, + PageGrid, + PageNumber, + PageWrapper, + Rhyhorn, +} from "@reactive-resume/templates"; +import { pageSizeMap } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { useMemo } from "react"; +import { Helmet } from "react-helmet-async"; +import { LoaderFunction, redirect } from "react-router-dom"; +import { ReactZoomPanPinchRef, TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; + +import { queryClient } from "@/client/libs/query-client"; +import { findResumeById } from "@/client/services/resume"; +import { useBuilderStore } from "@/client/stores/builder"; +import { useResumeStore } from "@/client/stores/resume"; + +export const BuilderPage = () => { + const title = useResumeStore((state) => state.resume.title); + const resume = useResumeStore((state) => state.resume.data); + const setTransformRef = useBuilderStore((state) => state.transform.setRef); + + const { pageHeight, showBreakLine, showPageNumbers } = useMemo(() => { + const { format, options } = resume.metadata.page; + + return { + pageHeight: pageSizeMap[format].height, + showBreakLine: options.breakLine, + showPageNumbers: options.pageNumbers, + }; + }, [resume.metadata.page]); + + return ( + <> + + {title} - Reactive Resume + + + setTransformRef(ref)} + > + + + + {resume.metadata.layout.map((columns, pageIndex) => ( + + + + {showPageNumbers && Page {pageIndex + 1}} + + + + {showBreakLine && } + + + + ))} + + + + + + ); +}; + +export const builderLoader: LoaderFunction = async ({ params }) => { + try { + const id = params.id as string; + + const resume = await queryClient.fetchQuery({ + queryKey: ["resume", { id }], + queryFn: () => findResumeById({ id }), + }); + + useResumeStore.setState({ resume }); + useResumeStore.temporal.getState().clear(); + + return resume; + } catch (error) { + return redirect("/dashboard"); + } +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx new file mode 100644 index 00000000..0c9d6ffc --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx @@ -0,0 +1,112 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { awardSchema, defaultAward } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = awardSchema; + +type FormValues = z.infer; + +export const AwardsDialog = () => { + const form = useForm({ + defaultValues: defaultAward, + resolver: zodResolver(formSchema), + }); + + return ( + id="awards" form={form} defaultValues={defaultAward}> +
+ ( + + Title + + + + + + )} + /> + + ( + + Awarder + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/certifications.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/certifications.tsx new file mode 100644 index 00000000..d6aab4bb --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/certifications.tsx @@ -0,0 +1,112 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { certificationSchema, defaultCertification } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = certificationSchema; + +type FormValues = z.infer; + +export const CertificationsDialog = () => { + const form = useForm({ + defaultValues: defaultCertification, + resolver: zodResolver(formSchema), + }); + + return ( + id="certifications" form={form} defaultValues={defaultCertification}> +
+ ( + + Name + + + + + + )} + /> + + ( + + Issuer + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/custom-section.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/custom-section.tsx new file mode 100644 index 00000000..9076761d --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/custom-section.tsx @@ -0,0 +1,195 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { X } from "@phosphor-icons/react"; +import { CustomSection, customSectionSchema, defaultCustomSection } from "@reactive-resume/schema"; +import { + Badge, + BadgeInput, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, + Slider, +} from "@reactive-resume/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; +import { DialogName, useDialog } from "@/client/stores/dialog"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = customSectionSchema; + +type FormValues = z.infer; + +export const CustomSectionDialog = () => { + const { payload } = useDialog("custom"); + + const form = useForm({ + defaultValues: defaultCustomSection, + resolver: zodResolver(formSchema), + }); + + if (!payload) return null; + + return ( + + form={form} + id={payload.id as DialogName} + defaultValues={defaultCustomSection} + > +
+ ( + + Name + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Level + +
+ field.onChange(value[0])} + /> + + {field.value} +
+
+ +
+ )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> + + ( +
+ + Keywords + + + + + You can add multiple keywords by separating them with a comma. + + + + +
+ + {field.value.map((item, index) => ( + + { + field.onChange(field.value.filter((v) => item !== v)); + }} + > + {item} + + + + ))} + +
+
+ )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/education.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/education.tsx new file mode 100644 index 00000000..5241a9e1 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/education.tsx @@ -0,0 +1,140 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultEducation, educationSchema } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = educationSchema; + +type FormValues = z.infer; + +export const EducationDialog = () => { + const form = useForm({ + defaultValues: defaultEducation, + resolver: zodResolver(formSchema), + }); + + return ( + id="education" form={form} defaultValues={defaultEducation}> +
+ ( + + Institution + + + + + + )} + /> + + ( + + Type of Study + + + + + + )} + /> + + ( + + Area of Study + + + + + + )} + /> + + ( + + Score + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/experience.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/experience.tsx new file mode 100644 index 00000000..8e20a3b5 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/experience.tsx @@ -0,0 +1,126 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultExperience, experienceSchema } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = experienceSchema; + +type FormValues = z.infer; + +export const ExperienceDialog = () => { + const form = useForm({ + defaultValues: defaultExperience, + resolver: zodResolver(formSchema), + }); + + return ( + id="experience" form={form} defaultValues={defaultExperience}> +
+ ( + + Company + + + + + + )} + /> + + ( + + Position + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Location + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/interests.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/interests.tsx new file mode 100644 index 00000000..5336af29 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/interests.tsx @@ -0,0 +1,93 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { X } from "@phosphor-icons/react"; +import { defaultInterest, interestSchema } from "@reactive-resume/schema"; +import { + Badge, + BadgeInput, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { SectionDialog } from "../sections/shared/section-dialog"; + +const formSchema = interestSchema; + +type FormValues = z.infer; + +export const InterestsDialog = () => { + const form = useForm({ + defaultValues: defaultInterest, + resolver: zodResolver(formSchema), + }); + + return ( + id="interests" form={form} defaultValues={defaultInterest}> +
+ ( + + Name + + + + + + )} + /> + + ( +
+ + Keywords + + + + + You can add multiple keywords by separating them with a comma. + + + + +
+ + {field.value.map((item, index) => ( + + { + field.onChange(field.value.filter((v) => item !== v)); + }} + > + {item} + + + + ))} + +
+
+ )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/languages.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/languages.tsx new file mode 100644 index 00000000..32292008 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/languages.tsx @@ -0,0 +1,85 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultLanguage, languageSchema } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Slider, +} from "@reactive-resume/ui"; +import { getCEFRLevel } from "@reactive-resume/utils"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { SectionDialog } from "../sections/shared/section-dialog"; + +const formSchema = languageSchema; + +type FormValues = z.infer; + +export const LanguagesDialog = () => { + const form = useForm({ + defaultValues: defaultLanguage, + resolver: zodResolver(formSchema), + }); + + return ( + id="languages" form={form} defaultValues={defaultLanguage}> +
+ ( + + Name + + + + + + )} + /> + + ( + + Fluency + + + + + + )} + /> + + ( + + Fluency (CEFR) + +
+ field.onChange(value[0])} + /> + + {getCEFRLevel(field.value)} +
+
+ +
+ )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx new file mode 100644 index 00000000..8d081555 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx @@ -0,0 +1,112 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultProfile, profileSchema } from "@reactive-resume/schema"; +import { + Avatar, + AvatarImage, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = profileSchema; + +type FormValues = z.infer; + +export const ProfilesDialog = () => { + const form = useForm({ + defaultValues: defaultProfile, + resolver: zodResolver(formSchema), + }); + + return ( + id="profiles" form={form} defaultValues={defaultProfile}> +
+ ( + + Network + + + + + + )} + /> + + ( + + Username + + + + + + )} + /> + + ( + + URL + + + + + + )} + /> + + ( + + Icon + +
+ + {field.value && ( + + )} + + +
+
+ + + Powered by{" "} + + Simple Icons + + +
+ )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx new file mode 100644 index 00000000..1cd25260 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx @@ -0,0 +1,160 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { X } from "@phosphor-icons/react"; +import { defaultProject, projectSchema } from "@reactive-resume/schema"; +import { + Badge, + BadgeInput, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = projectSchema; + +type FormValues = z.infer; + +export const ProjectsDialog = () => { + const form = useForm({ + defaultValues: defaultProject, + resolver: zodResolver(formSchema), + }); + + return ( + id="projects" form={form} defaultValues={defaultProject}> +
+ ( + + Name + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> + + ( +
+ + Keywords + + + + + You can add multiple keywords by separating them with a comma. + + + + +
+ + {field.value.map((item, index) => ( + + { + field.onChange(field.value.filter((v) => item !== v)); + }} + > + {item} + + + + ))} + +
+
+ )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/publications.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/publications.tsx new file mode 100644 index 00000000..6116b92b --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/publications.tsx @@ -0,0 +1,112 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultPublication, publicationSchema } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = publicationSchema; + +type FormValues = z.infer; + +export const PublicationsDialog = () => { + const form = useForm({ + defaultValues: defaultPublication, + resolver: zodResolver(formSchema), + }); + + return ( + id="publications" form={form} defaultValues={defaultPublication}> +
+ ( + + Name + + + + + + )} + /> + + ( + + Publisher + + + + + + )} + /> + + ( + + Release Date + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/references.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/references.tsx new file mode 100644 index 00000000..cae554cf --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/references.tsx @@ -0,0 +1,98 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultReference, referenceSchema } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = referenceSchema; + +type FormValues = z.infer; + +export const ReferencesDialog = () => { + const form = useForm({ + defaultValues: defaultReference, + resolver: zodResolver(formSchema), + }); + + return ( + id="references" form={form} defaultValues={defaultReference}> +
+ ( + + Name + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx new file mode 100644 index 00000000..56f5584f --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx @@ -0,0 +1,133 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { X } from "@phosphor-icons/react"; +import { defaultSkill, skillSchema } from "@reactive-resume/schema"; +import { + Badge, + BadgeInput, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Slider, +} from "@reactive-resume/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { SectionDialog } from "../sections/shared/section-dialog"; + +const formSchema = skillSchema; + +type FormValues = z.infer; + +export const SkillsDialog = () => { + const form = useForm({ + defaultValues: defaultSkill, + resolver: zodResolver(formSchema), + }); + + return ( + id="skills" form={form} defaultValues={defaultSkill}> +
+ ( + + Name + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> + + ( + + Level + +
+ field.onChange(value[0])} + /> + + {field.value} +
+
+ +
+ )} + /> + + ( +
+ + Keywords + + + + + You can add multiple keywords by separating them with a comma. + + + + +
+ + {field.value.map((item, index) => ( + + { + field.onChange(field.value.filter((v) => item !== v)); + }} + > + {item} + + + + ))} + +
+
+ )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/dialogs/volunteer.tsx b/apps/client/src/pages/builder/sidebars/left/dialogs/volunteer.tsx new file mode 100644 index 00000000..ae13b39a --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/dialogs/volunteer.tsx @@ -0,0 +1,126 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { defaultVolunteer, volunteerSchema } from "@reactive-resume/schema"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + RichInput, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { AiActions } from "@/client/components/ai-actions"; + +import { SectionDialog } from "../sections/shared/section-dialog"; +import { URLInput } from "../sections/shared/url-input"; + +const formSchema = volunteerSchema; + +type FormValues = z.infer; + +export const VolunteerDialog = () => { + const form = useForm({ + defaultValues: defaultVolunteer, + resolver: zodResolver(formSchema), + }); + + return ( + id="volunteer" form={form} defaultValues={defaultVolunteer}> +
+ ( + + Organization + + + + + + )} + /> + + ( + + Position + + + + + + )} + /> + + ( + + Date + + + + + + )} + /> + + ( + + Location + + + + + + )} + /> + + ( + + Website + + + + + + )} + /> + + ( + + Summary + + field.onChange(value)} + footer={(editor) => ( + + )} + /> + + + + )} + /> +
+ + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/index.tsx b/apps/client/src/pages/builder/sidebars/left/index.tsx new file mode 100644 index 00000000..6fe37d67 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/index.tsx @@ -0,0 +1,193 @@ +import { Plus, PlusCircle } from "@phosphor-icons/react"; +import { + Award, + Certification, + CustomSectionItem, + Education, + Experience, + Interest, + Language, + Profile, + Project, + Publication, + Reference, + Skill, + Volunteer, +} from "@reactive-resume/schema"; +import { Button, ScrollArea, Separator } from "@reactive-resume/ui"; +import { getCEFRLevel } from "@reactive-resume/utils"; +import { Fragment, useRef } from "react"; +import { Link } from "react-router-dom"; + +import { Icon } from "@/client/components/icon"; +import { UserAvatar } from "@/client/components/user-avatar"; +import { UserOptions } from "@/client/components/user-options"; +import { useResumeStore } from "@/client/stores/resume"; + +import { BasicsSection } from "./sections/basics"; +import { SectionBase } from "./sections/shared/section-base"; +import { SectionIcon } from "./sections/shared/section-icon"; +import { SummarySection } from "./sections/summary"; + +export const LeftSidebar = () => { + const containterRef = useRef(null); + + const addSection = useResumeStore((state) => state.addSection); + const customSections = useResumeStore((state) => state.resume.data.sections.custom); + + const scrollIntoView = (selector: string) => { + const section = containterRef.current?.querySelector(selector); + section?.scrollIntoView({ behavior: "smooth" }); + }; + + return ( +
+
+ + +
+ scrollIntoView("#basics")} /> + scrollIntoView("#summary")} /> + scrollIntoView("#profiles")} /> + scrollIntoView("#experience")} /> + scrollIntoView("#education")} /> + scrollIntoView("#awards")} /> + scrollIntoView("#certifications")} /> + scrollIntoView("#interests")} /> + scrollIntoView("#languages")} /> + scrollIntoView("#volunteer")} /> + scrollIntoView("#projects")} /> + scrollIntoView("#publications")} /> + scrollIntoView("#skills")} /> + scrollIntoView("#references")} /> + + } + onClick={() => { + addSection(); + scrollIntoView("& > section:last-of-type"); + }} + /> +
+ + + + +
+ + +
+ + + + + + id="profiles" + title={(item) => item.network} + description={(item) => item.username} + /> + + + id="experience" + title={(item) => item.company} + description={(item) => item.position} + /> + + + id="education" + title={(item) => item.institution} + description={(item) => item.area} + /> + + + id="awards" + title={(item) => item.title} + description={(item) => item.awarder} + /> + + + id="certifications" + title={(item) => item.name} + description={(item) => item.issuer} + /> + + + id="interests" + title={(item) => item.name} + description={(item) => { + if (item.keywords.length > 0) return `${item.keywords.length} keywords`; + }} + /> + + + id="languages" + title={(item) => item.name} + description={(item) => item.fluency || getCEFRLevel(item.fluencyLevel)} + /> + + + id="volunteer" + title={(item) => item.organization} + description={(item) => item.position} + /> + + + id="projects" + title={(item) => item.name} + description={(item) => item.description} + /> + + + id="publications" + title={(item) => item.name} + description={(item) => item.publisher} + /> + + + id="skills" + title={(item) => item.name} + description={(item) => { + if (item.description) return item.description; + if (item.keywords.length > 0) return `${item.keywords.length} keywords`; + }} + /> + + + id="references" + title={(item) => item.name} + description={(item) => item.description} + /> + + {/* Custom Sections */} + {Object.values(customSections).map((section) => ( + + + + + id={`custom.${section.id}`} + title={(item) => item.name} + description={(item) => item.description} + /> + + ))} + + + + +
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/basics.tsx b/apps/client/src/pages/builder/sidebars/left/sections/basics.tsx new file mode 100644 index 00000000..a071fef8 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/basics.tsx @@ -0,0 +1,97 @@ +import { basicsSchema } from "@reactive-resume/schema"; +import { Input, Label } from "@reactive-resume/ui"; + +import { useResumeStore } from "@/client/stores/resume"; + +import { CustomFieldsSection } from "./custom/section"; +import { PictureSection } from "./picture/section"; +import { getSectionIcon } from "./shared/section-icon"; +import { URLInput } from "./shared/url-input"; + +export const BasicsSection = () => { + const setValue = useResumeStore((state) => state.setValue); + const basics = useResumeStore((state) => state.resume.data.basics); + + return ( +
+
+
+ {getSectionIcon("basics")} +

Basics

+
+
+ +
+
+ +
+ +
+ + setValue("basics.name", event.target.value)} + /> +
+ +
+ + setValue("basics.headline", event.target.value)} + /> +
+ +
+ + setValue("basics.email", event.target.value)} + /> +
+ +
+ + setValue("basics.url", value)} + /> +
+ +
+ + setValue("basics.phone", event.target.value)} + /> +
+ +
+ + setValue("basics.location", event.target.value)} + /> +
+ + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/custom/section.tsx b/apps/client/src/pages/builder/sidebars/left/sections/custom/section.tsx new file mode 100644 index 00000000..cf5d61fd --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/custom/section.tsx @@ -0,0 +1,124 @@ +import { createId } from "@paralleldrive/cuid2"; +import { DotsSixVertical, Plus, X } from "@phosphor-icons/react"; +import { CustomField as ICustomField } from "@reactive-resume/schema"; +import { Button, Input } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { AnimatePresence, Reorder, useDragControls } from "framer-motion"; + +import { useResumeStore } from "@/client/stores/resume"; + +type CustomFieldProps = { + field: ICustomField; + onChange: (field: ICustomField) => void; + onRemove: (id: string) => void; +}; + +export const CustomField = ({ field, onChange, onRemove }: CustomFieldProps) => { + const controls = useDragControls(); + + const handleChange = (key: "name" | "value", value: string) => + onChange({ ...field, [key]: value }); + + return ( + +
+ + + handleChange("name", event.target.value)} + /> + + handleChange("value", event.target.value)} + /> + + +
+
+ ); +}; + +type Props = { + className?: string; +}; + +export const CustomFieldsSection = ({ className }: Props) => { + const setValue = useResumeStore((state) => state.setValue); + const customFields = useResumeStore((state) => state.resume.data.basics.customFields); + + const onAddCustomField = () => { + setValue("basics.customFields", [...customFields, { id: createId(), name: "", value: "" }]); + }; + + const onChangeCustomField = (field: ICustomField) => { + const index = customFields.findIndex((item) => item.id === field.id); + const newCustomFields = JSON.parse(JSON.stringify(customFields)) as ICustomField[]; + newCustomFields[index] = field; + + setValue("basics.customFields", newCustomFields); + }; + + const onReorderCustomFields = (values: ICustomField[]) => { + setValue("basics.customFields", values); + }; + + const onRemoveCustomField = (id: string) => { + setValue( + "basics.customFields", + customFields.filter((field) => field.id !== id), + ); + }; + + return ( +
+ + + {customFields.map((field) => ( + + ))} + + + + +
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/picture/options.tsx b/apps/client/src/pages/builder/sidebars/left/sections/picture/options.tsx new file mode 100644 index 00000000..50f254e8 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/picture/options.tsx @@ -0,0 +1,219 @@ +import { + AspectRatio, + Checkbox, + Input, + Label, + ToggleGroup, + ToggleGroupItem, + Tooltip, +} from "@reactive-resume/ui"; +import { useMemo } from "react"; + +import { useResumeStore } from "@/client/stores/resume"; + +// Aspect Ratio Helpers +const stringToRatioMap = { + square: 1, + portrait: 0.75, + horizontal: 1.33, +} as const; + +const ratioToStringMap = { + "1": "square", + "0.75": "portrait", + "1.33": "horizontal", +} as const; + +type AspectRatio = keyof typeof stringToRatioMap; + +// Border Radius Helpers +const stringToBorderRadiusMap = { + square: 0, + rounded: 6, + circle: 9999, +}; + +const borderRadiusToStringMap = { + "0": "square", + "6": "rounded", + "9999": "circle", +}; + +type BorderRadius = keyof typeof stringToBorderRadiusMap; + +export const PictureOptions = () => { + const setValue = useResumeStore((state) => state.setValue); + const picture = useResumeStore((state) => state.resume.data.basics.picture); + + const aspectRatio = useMemo(() => { + const ratio = picture.aspectRatio?.toString() as keyof typeof ratioToStringMap; + return ratioToStringMap[ratio]; + }, [picture.aspectRatio]); + + const onAspectRatioChange = (value: AspectRatio) => { + if (!value) return; + setValue("basics.picture.aspectRatio", stringToRatioMap[value]); + }; + + const borderRadius = useMemo(() => { + const radius = picture.borderRadius?.toString() as keyof typeof borderRadiusToStringMap; + return borderRadiusToStringMap[radius]; + }, [picture.borderRadius]); + + const onBorderRadiusChange = (value: BorderRadius) => { + if (!value) return; + setValue("basics.picture.borderRadius", stringToBorderRadiusMap[value]); + }; + + return ( +
+
+ + { + setValue("basics.picture.size", event.target.valueAsNumber); + }} + /> +
+ +
+ +
+ + + +
+ + + + + +
+ + + + + +
+ + + + + { + setValue("basics.picture.aspectRatio", event.target.valueAsNumber ?? 0); + }} + /> +
+
+ +
+ +
+ + + +
+ + + + + +
+ + + + + +
+ + + + + { + setValue("basics.picture.borderRadius", event.target.valueAsNumber ?? 0); + }} + /> +
+
+ +
+
+
+ +
+
+
+ { + setValue("basics.picture.effects.hidden", checked); + }} + /> + +
+ +
+ { + setValue("basics.picture.effects.border", checked); + }} + /> + +
+ +
+ { + setValue("basics.picture.effects.grayscale", checked); + }} + /> + +
+
+
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/picture/section.tsx b/apps/client/src/pages/builder/sidebars/left/sections/picture/section.tsx new file mode 100644 index 00000000..4c5b9459 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/picture/section.tsx @@ -0,0 +1,102 @@ +import { Aperture, UploadSimple } from "@phosphor-icons/react"; +import { + Avatar, + AvatarFallback, + AvatarImage, + buttonVariants, + Input, + Label, + Popover, + PopoverContent, + PopoverTrigger, +} from "@reactive-resume/ui"; +import { cn, getInitials } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { useMemo, useRef } from "react"; +import { z } from "zod"; + +import { useUploadImage } from "@/client/services/storage"; +import { useResumeStore } from "@/client/stores/resume"; + +import { PictureOptions } from "./options"; + +export const PictureSection = () => { + const inputRef = useRef(null); + const { uploadImage, loading } = useUploadImage(); + + const setValue = useResumeStore((state) => state.setValue); + const name = useResumeStore((state) => state.resume.data.basics.name); + const picture = useResumeStore((state) => state.resume.data.basics.picture); + + const isValidUrl = useMemo(() => z.string().url().safeParse(picture.url).success, [picture.url]); + + const onSelectImage = async (event: React.ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + const file = event.target.files[0]; + const response = await uploadImage(file); + const url = response.data; + + setValue("basics.picture.url", url); + } + }; + + return ( +
+ + {isValidUrl && } + {getInitials(name)} + + +
+ +
+ setValue("basics.picture.url", event.target.value)} + /> + + + {/* Show options button if picture exists */} + {isValidUrl && ( + + + + + + + + + + + )} + + {/* Show upload button if picture doesn't exist, else show remove button to delete picture */} + {!isValidUrl && ( + <> + + + inputRef.current?.click()} + className={cn(buttonVariants({ size: "icon", variant: "ghost" }))} + > + + + + )} + +
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/shared/section-base.tsx b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-base.tsx new file mode 100644 index 00000000..d31a4623 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-base.tsx @@ -0,0 +1,146 @@ +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToParentElement } from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { Plus } from "@phosphor-icons/react"; +import { SectionItem, SectionKey, SectionWithItem } from "@reactive-resume/schema"; +import { Button } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import get from "lodash.get"; + +import { useDialog } from "@/client/stores/dialog"; +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "./section-icon"; +import { SectionListItem } from "./section-list-item"; +import { SectionOptions } from "./section-options"; + +type Props = { + id: SectionKey; + title: (item: T) => string; + description?: (item: T) => string | undefined; +}; + +export const SectionBase = ({ id, title, description }: Props) => { + const { open } = useDialog(id); + + const setValue = useResumeStore((state) => state.setValue); + const section = useResumeStore((state) => + get(state.resume.data.sections, id), + ) as SectionWithItem; + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + if (!section) return null; + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (!over) return; + + if (active.id !== over.id) { + const oldIndex = section.items.findIndex((item) => item.id === active.id); + const newIndex = section.items.findIndex((item) => item.id === over.id); + + const sortedList = arrayMove(section.items as T[], oldIndex, newIndex); + setValue(`sections.${id}.items`, sortedList); + } + }; + + const onCreate = () => open("create", { id }); + const onUpdate = (item: T) => open("update", { id, item }); + const onDuplicate = (item: T) => open("duplicate", { id, item }); + const onDelete = (item: T) => open("delete", { id, item }); + + const onToggleVisibility = (index: number) => { + const visible = get(section, `items[${index}].visible`, true); + setValue(`sections.${id}.items[${index}].visible`, !visible); + }; + + return ( + +
+
+ {getSectionIcon(id)} + +

{section.name}

+
+ +
+ +
+
+ +
+ {section.items.length === 0 && ( + + )} + + + + + {section.items.map((item, index) => ( + onUpdate(item as T)} + onDelete={() => onDelete(item as T)} + onDuplicate={() => onDuplicate(item as T)} + onToggleVisibility={() => onToggleVisibility(index)} + /> + ))} + + + +
+ + {section.items.length > 0 && ( +
+ +
+ )} +
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/shared/section-dialog.tsx b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-dialog.tsx new file mode 100644 index 00000000..8246f501 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-dialog.tsx @@ -0,0 +1,169 @@ +import { createId } from "@paralleldrive/cuid2"; +import { CopySimple, PencilSimple, Plus } from "@phosphor-icons/react"; +import { SectionItem, SectionWithItem } from "@reactive-resume/schema"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + Form, +} from "@reactive-resume/ui"; +import { produce } from "immer"; +import get from "lodash.get"; +import { useEffect, useMemo } from "react"; +import { UseFormReturn } from "react-hook-form"; + +import { DialogName, useDialog } from "@/client/stores/dialog"; +import { useResumeStore } from "@/client/stores/resume"; + +type Props = { + id: DialogName; + form: UseFormReturn; + defaultValues: T; + children: React.ReactNode; +}; + +export const SectionDialog = ({ + id, + form, + defaultValues, + children, +}: Props) => { + const { isOpen, mode, close, payload } = useDialog(id); + const setValue = useResumeStore((state) => state.setValue); + const section = useResumeStore((state) => { + if (!id) return null; + return get(state.resume.data.sections, id); + }) as SectionWithItem | null; + const name = useMemo(() => section?.name ?? "", [section?.name]); + + const isCreate = mode === "create"; + const isUpdate = mode === "update"; + const isDelete = mode === "delete"; + const isDuplicate = mode === "duplicate"; + + useEffect(() => { + if (isOpen) onReset(); + }, [isOpen, payload]); + + const onSubmit = async (values: T) => { + if (!section) return; + + if (isCreate || isDuplicate) { + setValue( + `sections.${id}.items`, + produce(section.items, (draft: T[]): void => { + draft.push({ ...values, id: createId() }); + }), + ); + } + + if (isUpdate) { + if (!payload.item?.id) return; + + setValue( + `sections.${id}.items`, + produce(section.items, (draft: T[]): void => { + const index = draft.findIndex((item) => item.id === payload.item?.id); + if (index === -1) return; + draft[index] = values; + }), + ); + } + + if (isDelete) { + if (!payload.item?.id) return; + + setValue( + `sections.${id}.items`, + produce(section.items, (draft: T[]): void => { + const index = draft.findIndex((item) => item.id === payload.item?.id); + if (index === -1) return; + draft.splice(index, 1); + }), + ); + } + + close(); + }; + + const onReset = () => { + if (isCreate) form.reset({ ...defaultValues, id: createId() } as T); + if (isUpdate) form.reset({ ...defaultValues, ...payload.item }); + if (isDuplicate) form.reset({ ...payload.item, id: createId() } as T); + if (isDelete) form.reset({ ...defaultValues, ...payload.item }); + }; + + if (isDelete) { + return ( + + +
+ + + Are you sure you want to delete this {name}? + + This action can be reverted by clicking on the undo button in the floating + toolbar. + + + + + Cancel + + + Delete + + +
+ +
+
+ ); + } + + return ( + + +
+ + + +
+ {isCreate && } + {isUpdate && } + {isDuplicate && } +

+ {isCreate && `Create a new ${name}`} + {isUpdate && `Update an existing ${name}`} + {isDuplicate && `Duplicate an existing ${name}`} +

+
+
+
+ + {children} + + + + +
+ +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/shared/section-icon.tsx b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-icon.tsx new file mode 100644 index 00000000..f3d71c0e --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-icon.tsx @@ -0,0 +1,79 @@ +import { + Article, + Books, + Briefcase, + Certificate, + CompassTool, + GameController, + GraduationCap, + HandHeart, + IconProps, + Medal, + PuzzlePiece, + ShareNetwork, + Translate, + User, + Users, +} from "@phosphor-icons/react"; +import { defaultSection, SectionKey, SectionWithItem } from "@reactive-resume/schema"; +import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui"; +import get from "lodash.get"; + +import { useResumeStore } from "@/client/stores/resume"; + +export const getSectionIcon = (id: SectionKey, props: IconProps = {}) => { + switch (id) { + // Left Sidebar + case "basics": + return ; + case "summary": + return
; + case "awards": + return ; + case "profiles": + return ; + case "experience": + return ; + case "education": + return ; + case "certifications": + return ; + case "interests": + return ; + case "languages": + return ; + case "volunteer": + return ; + case "projects": + return ; + case "publications": + return ; + case "skills": + return ; + case "references": + return ; + + default: + return null; + } +}; + +type SectionIconProps = ButtonProps & { + id: SectionKey; + name?: string; + icon?: React.ReactNode; +}; + +export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => { + const section = useResumeStore((state) => + get(state.resume.data.sections, id, defaultSection), + ) as SectionWithItem; + + return ( + + + + ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/shared/section-list-item.tsx b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-list-item.tsx new file mode 100644 index 00000000..1fb6aa5a --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-list-item.tsx @@ -0,0 +1,103 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { CopySimple, DotsSixVertical, PencilSimple, TrashSimple } from "@phosphor-icons/react"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { motion } from "framer-motion"; + +export type SectionListItemProps = { + id: string; + title: string; + visible?: boolean; + description?: string; + + // Callbacks + onUpdate?: () => void; + onDuplicate?: () => void; + onDelete?: () => void; + onToggleVisibility?: () => void; +}; + +export const SectionListItem = ({ + id, + title, + description, + visible = true, + onUpdate, + onDuplicate, + onDelete, + onToggleVisibility, +}: SectionListItemProps) => { + const { setNodeRef, transform, transition, attributes, listeners, isDragging } = useSortable({ + id, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + opacity: isDragging ? 0.5 : undefined, + zIndex: isDragging ? 100 : undefined, + transition, + }; + + return ( + +
+ {/* Drag Handle */} +
+ +
+ + {/* List Item */} + + +
+

{title}

+ {description &&

{description}

} +
+
+ + + Visible + + + + Edit + + + + Copy + + + + Remove + + +
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/shared/section-options.tsx b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-options.tsx new file mode 100644 index 00000000..7e054e58 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/shared/section-options.tsx @@ -0,0 +1,132 @@ +import { + ArrowCounterClockwise, + Broom, + Columns, + DotsThreeVertical, + Eye, + EyeSlash, + PencilSimple, + Plus, + TrashSimple, +} from "@phosphor-icons/react"; +import { defaultSections, SectionKey, SectionWithItem } from "@reactive-resume/schema"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + Input, +} from "@reactive-resume/ui"; +import get from "lodash.get"; +import { useMemo } from "react"; + +import { useDialog } from "@/client/stores/dialog"; +import { useResumeStore } from "@/client/stores/resume"; + +type Props = { id: SectionKey }; + +export const SectionOptions = ({ id }: Props) => { + const { open } = useDialog(id); + const setValue = useResumeStore((state) => state.setValue); + const removeSection = useResumeStore((state) => state.removeSection); + + const originalName = get(defaultSections, `${id}.name`, "") as SectionWithItem; + const section = useResumeStore((state) => get(state.resume.data.sections, id)) as SectionWithItem; + + const hasItems = useMemo(() => "items" in section, [section]); + const isCustomSection = useMemo(() => id.startsWith("custom"), [id]); + + const onCreate = () => open("create", { id }); + const toggleVisibility = () => setValue(`sections.${id}.visible`, !section.visible); + const onResetName = () => setValue(`sections.${id}.name`, originalName); + const onChangeColumns = (value: string) => setValue(`sections.${id}.columns`, Number(value)); + const onResetItems = () => setValue(`sections.${id}.items`, []); + const onRemove = () => removeSection(id); + + return ( + + + + + + {hasItems && ( + <> + + + Add a new item + + + + + )} + + + + {section.visible ? : } + {section.visible ? "Hide" : "Show"} + + + + + Rename + + +
+ { + setValue(`sections.${id}.name`, event.target.value); + }} + /> + +
+
+
+ + + + Columns + + + + 1 Column + 2 Columns + 3 Columns + 4 Columns + 5 Columns + + + +
+ + + + Reset + + + + + Remove + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/left/sections/shared/url-input.tsx b/apps/client/src/pages/builder/sidebars/left/sections/shared/url-input.tsx new file mode 100644 index 00000000..19cea250 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/shared/url-input.tsx @@ -0,0 +1,50 @@ +import { Tag } from "@phosphor-icons/react"; +import { URL, urlSchema } from "@reactive-resume/schema"; +import { Button, Input, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui"; +import { forwardRef, useMemo } from "react"; + +interface Props { + id?: string; + value: URL; + placeholder?: string; + onChange: (value: URL) => void; +} + +export const URLInput = forwardRef( + ({ id, value, placeholder, onChange }, ref) => { + const hasError = useMemo(() => urlSchema.safeParse(value).success === false, [value]); + + return ( + <> +
+ onChange({ ...value, href: event.target.value })} + /> + + + + + + + onChange({ ...value, label: event.target.value })} + /> + + +
+ + {hasError && URL must start with https://} + + ); + }, +); diff --git a/apps/client/src/pages/builder/sidebars/left/sections/summary.tsx b/apps/client/src/pages/builder/sidebars/left/sections/summary.tsx new file mode 100644 index 00000000..f2dd18d5 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/left/sections/summary.tsx @@ -0,0 +1,41 @@ +import { defaultSections } from "@reactive-resume/schema"; +import { RichInput } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; + +import { AiActions } from "@/client/components/ai-actions"; +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "./shared/section-icon"; +import { SectionOptions } from "./shared/section-options"; + +export const SummarySection = () => { + const setValue = useResumeStore((state) => state.setValue); + const section = useResumeStore( + (state) => state.resume.data.sections.summary ?? defaultSections.summary, + ); + + return ( +
+
+
+ {getSectionIcon("summary")} +

{section.name}

+
+ +
+ +
+
+ +
+ setValue("sections.summary.content", value)} + footer={(editor) => ( + + )} + /> +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/index.tsx b/apps/client/src/pages/builder/sidebars/right/index.tsx new file mode 100644 index 00000000..c8d6d436 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/index.tsx @@ -0,0 +1,72 @@ +import { ScrollArea, Separator } from "@reactive-resume/ui"; +import { useRef } from "react"; + +import { Copyright } from "@/client/components/copyright"; +import { ThemeSwitch } from "@/client/components/theme-switch"; + +import { ExportSection } from "./sections/export"; +import { InformationSection } from "./sections/information"; +import { LayoutSection } from "./sections/layout"; +import { PageSection } from "./sections/page"; +import { SharingSection } from "./sections/sharing"; +import { StatisticsSection } from "./sections/statistics"; +import { TemplateSection } from "./sections/template"; +import { ThemeSection } from "./sections/theme"; +import { TypographySection } from "./sections/typography"; +import { SectionIcon } from "./shared/section-icon"; + +export const RightSidebar = () => { + const containterRef = useRef(null); + + const scrollIntoView = (selector: string) => { + const section = containterRef.current?.querySelector(selector); + section?.scrollIntoView({ behavior: "smooth" }); + }; + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ scrollIntoView("#template")} /> + scrollIntoView("#layout")} /> + scrollIntoView("#typography")} + /> + scrollIntoView("#theme")} /> + scrollIntoView("#page")} /> + scrollIntoView("#sharing")} /> +
+ + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/export.tsx b/apps/client/src/pages/builder/sidebars/right/sections/export.tsx new file mode 100644 index 00000000..c1523a7e --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/export.tsx @@ -0,0 +1,89 @@ +import { CircleNotch, FileJs, FilePdf } from "@phosphor-icons/react"; +import { buttonVariants, Card, CardContent, CardDescription, CardTitle } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { saveAs } from "file-saver"; + +import { useToast } from "@/client/hooks/use-toast"; +import { usePrintResume } from "@/client/services/resume/print"; +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const ExportSection = () => { + const { toast } = useToast(); + const { printResume, loading } = usePrintResume(); + + const onJsonExport = () => { + const { resume } = useResumeStore.getState(); + const filename = `reactive_resume-${resume.id}.json`; + const resumeJSON = JSON.stringify(resume.data, null, 2); + + saveAs(new Blob([resumeJSON], { type: "application/json" }), filename); + + toast({ + variant: "success", + title: "A JSON snapshot of your resume has been successfully exported.", + }); + }; + + const onPdfExport = async () => { + const { resume } = useResumeStore.getState(); + const { url } = await printResume({ id: resume.id }); + + const openInNewTab = (url: string) => { + const win = window.open(url, "_blank"); + if (win) win.focus(); + }; + + openInNewTab(url); + }; + + return ( +
+
+
+ {getSectionIcon("export")} +

Export

+
+
+ +
+ + + + JSON + + Download a JSON snapshot of your resume. This file can be used to import your resume + in the future, or can even be shared with others to collaborate. + + + + + + {loading ? : } + + + PDF + + Download a PDF of your resume. This file can be used to print your resume, send it to + recruiters, or upload on job portals. + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/information.tsx b/apps/client/src/pages/builder/sidebars/right/sections/information.tsx new file mode 100644 index 00000000..4fa3e3ce --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/information.tsx @@ -0,0 +1,129 @@ +import { Book, EnvelopeSimpleOpen, GithubLogo, HandHeart } from "@phosphor-icons/react"; +import { + buttonVariants, + Card, + CardContent, + CardDescription, + CardFooter, + CardTitle, +} from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; + +import { getSectionIcon } from "../shared/section-icon"; + +const DonateCard = () => ( + + + Support the app by donating what you can! + +

+ I built Reactive Resume mostly by myself during my spare time, with a lot of help from + other great open-source contributors. +

+ +

+ If you like the app and want to support keeping it free forever, please donate whatever + you can afford to give. +

+
+
+ + + + Donate to Reactive Resume + + +
+); + +const IssuesCard = () => ( + + + Found a bug, or have an idea for a new feature? + +

I'm sure the app is not perfect, but I'd like for it to be.

+ +

+ If you faced any issues while creating your resume, or have an idea that would help you + and other users in creating your resume more easily, drop an issue on the repository or + send me an email about it. +

+
+
+ + + + Raise an issue + + + + + Send me a message + + +
+); + +const DocumentationCard = () => ( + + + Don't know where to begin? Hit the docs! + +

+ The community has spent a lot of time writing the documentation for Reactive Resume, and + I'm sure it will help you get started with the app. +

+ +

+ There are also a lot of examples to help you get started, and features that you might not + know about which could help you build your perfect resume. +

+
+
+ + + + Documentation + + +
+); + +export const InformationSection = () => { + return ( +
+
+
+ {getSectionIcon("information")} +

Information

+
+
+ +
+ + + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/layout.tsx b/apps/client/src/pages/builder/sidebars/right/sections/layout.tsx new file mode 100644 index 00000000..d0e8b4f4 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/layout.tsx @@ -0,0 +1,269 @@ +import { + closestCenter, + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { ArrowCounterClockwise, DotsSixVertical, Plus, TrashSimple } from "@phosphor-icons/react"; +import { defaultMetadata } from "@reactive-resume/schema"; +import { Button, Portal, Tooltip } from "@reactive-resume/ui"; +import { + cn, + LayoutLocator, + moveItemInLayout, + parseLayoutLocator, + SortablePayload, +} from "@reactive-resume/utils"; +import get from "lodash.get"; +import { useState } from "react"; + +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +type ColumnProps = { + id: string; + name: string; + items: string[]; +}; + +const Column = ({ id, name, items }: ColumnProps) => { + const { setNodeRef } = useDroppable({ id }); + + return ( + +
+
+ +
+

{name}

+ +
+ {items.map((section) => ( + + ))} +
+
+
+ + ); +}; + +type SortableSectionProps = { + id: string; +}; + +const SortableSection = ({ id }: SortableSectionProps) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + + const style = { + transition, + opacity: isDragging ? 0.5 : 1, + transform: CSS.Translate.toString(transform), + }; + + return ( +
+
+
+ ); +}; + +type SectionProps = { + id: string; + isDragging?: boolean; +}; + +const Section = ({ id, isDragging = false }: SectionProps) => { + const name = useResumeStore((state) => + get(state.resume.data.sections, `${id}.name`, id), + ) as string; + + return ( +
+
+ +

{name}

+
+
+ ); +}; + +export const LayoutSection = () => { + const setValue = useResumeStore((state) => state.setValue); + const layout = useResumeStore((state) => state.resume.data.metadata.layout); + + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const onDragStart = ({ active }: DragStartEvent) => { + setActiveId(active.id as string); + }; + + const onDragCancel = () => { + setActiveId(null); + }; + + const onDragEvent = ({ active, over }: DragOverEvent | DragEndEvent) => { + if (!over || !active.data.current) return; + + const currentPayload = active.data.current.sortable as SortablePayload | null; + const current = parseLayoutLocator(currentPayload); + + if (active.id === over.id) return; + + if (!over.data.current) { + const [page, column] = (over.id as string).split(".").map(Number); + const target = { page, column, section: 0 } as LayoutLocator; + + const newLayout = moveItemInLayout(current, target, layout); + setValue("metadata.layout", newLayout); + + return; + } + + const targetPayload = over.data.current.sortable as SortablePayload | null; + const target = parseLayoutLocator(targetPayload); + + const newLayout = moveItemInLayout(current, target, layout); + setValue("metadata.layout", newLayout); + }; + + const onDragEnd = (event: DragEndEvent) => { + onDragEvent(event); + setActiveId(null); + }; + + const onAddPage = () => { + const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][]; + + layoutCopy.push([[], []]); + + setValue("metadata.layout", layoutCopy); + }; + + const onRemovePage = (page: number) => { + const layoutCopy = JSON.parse(JSON.stringify(layout)) as string[][][]; + + layoutCopy[0][0].push(...layoutCopy[page][0]); // Main + layoutCopy[0][1].push(...layoutCopy[page][1]); // Sidebar + + layoutCopy.splice(page, 1); + + setValue("metadata.layout", layoutCopy); + }; + + const onResetLayout = () => { + const layoutCopy = JSON.parse(JSON.stringify(defaultMetadata.layout)) as string[][][]; + + // Loop through all pages and columns, and get any sections that start with "custom." + // These should be appended to the first page of the new layout. + const customSections: string[] = []; + + layout.forEach((page) => { + page.forEach((column) => { + customSections.push(...column.filter((section) => section.startsWith("custom."))); + }); + }); + + if (customSections.length > 0) layoutCopy[0][0].push(...customSections); + + setValue("metadata.layout", layoutCopy); + }; + + return ( +
+
+
+ {getSectionIcon("layout")} +

Layout

+
+ + + + +
+ +
+ {/* Pages */} + + {layout.map((page, pageIndex) => { + const mainIndex = `${pageIndex}.0`; + const sidebarIndex = `${pageIndex}.1`; + + const main = page[0]; + const sidebar = page[1]; + + return ( +
+
+

Page {pageIndex + 1}

+ + {pageIndex !== 0 && ( + + )} +
+ +
+ + +
+
+ ); + })} + + + {activeId &&
} + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/page.tsx b/apps/client/src/pages/builder/sidebars/right/sections/page.tsx new file mode 100644 index 00000000..9e218966 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/page.tsx @@ -0,0 +1,97 @@ +import { + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Slider, + Switch, +} from "@reactive-resume/ui"; + +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const PageSection = () => { + const setValue = useResumeStore((state) => state.setValue); + const page = useResumeStore((state) => state.resume.data.metadata.page); + + return ( +
+
+
+ {getSectionIcon("page")} +

Page

+
+
+ +
+
+ + +
+ +
+ +
+ { + setValue("metadata.page.margin", value[0]); + }} + /> + + {page.margin} +
+
+ +
+ + +
+
+ { + setValue("metadata.page.options.breakLine", checked); + }} + /> + +
+
+ +
+
+ { + setValue("metadata.page.options.pageNumbers", checked); + }} + /> + +
+
+
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/sharing.tsx b/apps/client/src/pages/builder/sidebars/right/sections/sharing.tsx new file mode 100644 index 00000000..41b6c209 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/sharing.tsx @@ -0,0 +1,90 @@ +import { CopySimple } from "@phosphor-icons/react"; +import { Button, Input, Label, Switch, Tooltip } from "@reactive-resume/ui"; +import { AnimatePresence, motion } from "framer-motion"; + +import { useToast } from "@/client/hooks/use-toast"; +import { useUser } from "@/client/services/user"; +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const SharingSection = () => { + const { user } = useUser(); + const { toast } = useToast(); + const username = user?.username; + + const setValue = useResumeStore((state) => state.setValue); + const slug = useResumeStore((state) => state.resume.slug); + const isPublic = useResumeStore((state) => state.resume.visibility === "public"); + + // Constants + const url = `${window.location.origin}/${username}/${slug}`; + + const onCopy = async () => { + await navigator.clipboard.writeText(url); + + toast({ + variant: "success", + title: "A link has been copied to your clipboard.", + description: + "Anyone with this link can view and download the resume. Share it on your profile or with recruiters.", + }); + }; + + return ( +
+
+
+ {getSectionIcon("sharing")} +

Sharing

+
+
+ +
+
+
+ { + setValue("visibility", checked ? "public" : "private"); + }} + /> +
+ +
+
+
+ + + {isPublic && ( + + + +
+ + + + + +
+
+ )} +
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/statistics.tsx b/apps/client/src/pages/builder/sidebars/right/sections/statistics.tsx new file mode 100644 index 00000000..9651bc0d --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/statistics.tsx @@ -0,0 +1,65 @@ +import { Info } from "@phosphor-icons/react"; +import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; + +import { useResumeStatistics } from "@/client/services/resume"; +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const StatisticsSection = () => { + const id = useResumeStore((state) => state.resume.id); + const isPublic = useResumeStore((state) => state.resume.visibility === "public"); + + const { statistics } = useResumeStatistics(id, isPublic); + + return ( +
+
+
+ {getSectionIcon("statistics")} +

Statistics

+
+
+ +
+ + {!isPublic && ( + + + + + Statistics are available only for public resumes. + + + You can track the number of views your resume has received, or how many people + have downloaded the resume by enabling public sharing. + + + + )} + + +
+

+ {statistics?.views ?? 0} +

+

Views

+
+ +
+

+ {statistics?.downloads ?? 0} +

+

Downloads

+
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/template.tsx b/apps/client/src/pages/builder/sidebars/right/sections/template.tsx new file mode 100644 index 00000000..1f50618e --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/template.tsx @@ -0,0 +1,42 @@ +import { Button } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; + +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const TemplateSection = () => { + // TODO: Import templates from @reactive-resume/templates + const templateList = ["rhyhorn"]; + + const setValue = useResumeStore((state) => state.setValue); + const currentTemplate = useResumeStore((state) => state.resume.data.metadata.template); + + return ( +
+
+
+ {getSectionIcon("template")} +

Template

+
+
+ +
+ {templateList.map((template) => ( + + ))} +
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/theme.tsx b/apps/client/src/pages/builder/sidebars/right/sections/theme.tsx new file mode 100644 index 00000000..7f654182 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/theme.tsx @@ -0,0 +1,133 @@ +import { Input, Label, Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { HexColorPicker } from "react-colorful"; + +import { colors } from "@/client/constants/colors"; +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +export const ThemeSection = () => { + const setValue = useResumeStore((state) => state.setValue); + const theme = useResumeStore((state) => state.resume.data.metadata.theme); + + return ( +
+
+
+ {getSectionIcon("theme")} +

Theme

+
+
+ +
+
+ {colors.map((color) => ( +
{ + setValue("metadata.theme.primary", color); + }} + className={cn( + "flex h-6 w-6 cursor-pointer items-center justify-center rounded-full ring-primary ring-offset-1 ring-offset-background transition-shadow hover:ring-1", + theme.primary === color && "ring-1", + )} + > +
+
+ ))} +
+ +
+ +
+ + +
+ + + { + setValue("metadata.theme.primary", color); + }} + /> + + + { + setValue("metadata.theme.primary", event.target.value); + }} + /> +
+
+ +
+ +
+ + +
+ + + { + setValue("metadata.theme.background", color); + }} + /> + + + { + setValue("metadata.theme.background", event.target.value); + }} + /> +
+
+ +
+ +
+ + +
+ + + { + setValue("metadata.theme.text", color); + }} + /> + + + { + setValue("metadata.theme.text", event.target.value); + }} + /> +
+
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/sections/typography.tsx b/apps/client/src/pages/builder/sidebars/right/sections/typography.tsx new file mode 100644 index 00000000..ca77439e --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/sections/typography.tsx @@ -0,0 +1,183 @@ +import { Button, Combobox, ComboboxOption, Label, Slider, Switch } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { fonts } from "@reactive-resume/utils"; +import { useCallback, useEffect, useState } from "react"; +import webfontloader from "webfontloader"; + +import { useResumeStore } from "@/client/stores/resume"; + +import { getSectionIcon } from "../shared/section-icon"; + +const fontSuggestions = [ + "Open Sans", + "Merriweather", + "CMU Serif", + "Playfair Display", + "Lato", + "Lora", + "PT Sans", + "PT Serif", + "IBM Plex Sans", + "IBM Plex Serif", +]; + +const families: ComboboxOption[] = fonts.map((font) => ({ + value: font.family, + label: font.family, +})); + +export const TypographySection = () => { + const [subsets, setSubsets] = useState([]); + const [variants, setVariants] = useState([]); + + const setValue = useResumeStore((state) => state.setValue); + const typography = useResumeStore((state) => state.resume.data.metadata.typography); + + const loadFontSuggestions = useCallback(async () => { + fontSuggestions.forEach((font) => { + if (font === "CMU Serif") return; + webfontloader.load({ + events: false, + classes: false, + google: { families: [font], text: font }, + }); + }); + }, [fontSuggestions]); + + useEffect(() => { + loadFontSuggestions(); + }, []); + + useEffect(() => { + const subsets = fonts.find((font) => font.family === typography.font.family)?.subsets ?? []; + setSubsets(subsets.map((subset) => ({ value: subset, label: subset }))); + + const variants = fonts.find((font) => font.family === typography.font.family)?.variants ?? []; + setVariants(variants.map((variant) => ({ value: variant, label: variant }))); + }, [typography.font.family]); + + return ( +
+
+
+ {getSectionIcon("typography")} +

Typography

+
+
+ +
+
+ {fontSuggestions.map((font) => ( + + ))} +
+ +
+ + { + setValue("metadata.typography.font.family", value); + setValue("metadata.typography.font.subset", "latin"); + setValue("metadata.typography.font.variants", ["regular"]); + }} + /> +
+ +
+
+ + { + setValue("metadata.typography.font.subset", value); + }} + /> +
+ +
+ + { + setValue("metadata.typography.font.variants", value); + }} + /> +
+
+ +
+ +
+ { + setValue("metadata.typography.font.size", value[0]); + }} + /> + + {typography.font.size} +
+
+ +
+ +
+ { + setValue("metadata.typography.lineHeight", value[0]); + }} + /> + + {typography.lineHeight} +
+
+ +
+ + +
+
+ { + setValue("metadata.typography.underlineLinks", checked); + }} + /> + +
+
+
+
+
+ ); +}; diff --git a/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx b/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx new file mode 100644 index 00000000..608f7f26 --- /dev/null +++ b/apps/client/src/pages/builder/sidebars/right/shared/section-icon.tsx @@ -0,0 +1,65 @@ +import { + DiamondsFour, + DownloadSimple, + IconProps, + Info, + Layout, + Palette, + ReadCvLogo, + ShareFat, + TextT, + TrendUp, +} from "@phosphor-icons/react"; +import { Button, ButtonProps, Tooltip } from "@reactive-resume/ui"; + +export type MetadataKey = + | "template" + | "layout" + | "typography" + | "theme" + | "page" + | "sharing" + | "statistics" + | "export" + | "information"; + +export const getSectionIcon = (id: MetadataKey, props: IconProps = {}) => { + switch (id) { + // Left Sidebar + case "template": + return ; + case "layout": + return ; + case "typography": + return ; + case "theme": + return ; + case "page": + return ; + case "sharing": + return ; + case "statistics": + return ; + case "export": + return ; + case "information": + return ; + + default: + return null; + } +}; + +type SectionIconProps = ButtonProps & { + id: MetadataKey; + name: string; + icon?: React.ReactNode; +}; + +export const SectionIcon = ({ id, name, icon, ...props }: SectionIconProps) => ( + + + +); diff --git a/apps/client/src/pages/dashboard/_components/sidebar.tsx b/apps/client/src/pages/dashboard/_components/sidebar.tsx new file mode 100644 index 00000000..574e3aac --- /dev/null +++ b/apps/client/src/pages/dashboard/_components/sidebar.tsx @@ -0,0 +1,129 @@ +import { FadersHorizontal, ReadCvLogo } from "@phosphor-icons/react"; +import { Button, KeyboardShortcut, Separator } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { motion } from "framer-motion"; +import { Link, useLocation, useNavigate } from "react-router-dom"; +import useKeyboardShortcut from "use-keyboard-shortcut"; + +import { Copyright } from "@/client/components/copyright"; +import { Icon } from "@/client/components/icon"; +import { UserAvatar } from "@/client/components/user-avatar"; +import { UserOptions } from "@/client/components/user-options"; +import { useUser } from "@/client/services/user"; + +type Props = { + className?: string; +}; + +const ActiveIndicator = ({ className }: Props) => ( + +); + +interface SidebarItem { + path: string; + name: string; + shortcut?: string; + icon: React.ReactNode; +} + +const sidebarItems: SidebarItem[] = [ + { + path: "/dashboard/resumes", + name: "Resumes", + shortcut: "⇧R", + icon: , + }, + { + path: "/dashboard/settings", + name: "Settings", + shortcut: "⇧S", + icon: , + }, +]; + +type SidebarItemProps = SidebarItem & { + onClick?: () => void; +}; + +const SidebarItem = ({ path, name, shortcut, icon, onClick }: SidebarItemProps) => { + const isActive = useLocation().pathname === path; + + return ( + + ); +}; + +type SidebarProps = { + setOpen?: (open: boolean) => void; +}; + +export const Sidebar = ({ setOpen }: SidebarProps) => { + const { user } = useUser(); + const navigate = useNavigate(); + + useKeyboardShortcut(["shift", "r"], () => { + navigate("/dashboard/resumes"); + setOpen?.(false); + }); + + useKeyboardShortcut(["shift", "s"], () => { + navigate("/dashboard/settings"); + setOpen?.(false); + }); + + return ( +
+
+ +
+ + + +
+ {sidebarItems.map((item) => ( + setOpen?.(false)} /> + ))} +
+ +
+ + + + + + + + +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/layout.tsx b/apps/client/src/pages/dashboard/layout.tsx new file mode 100644 index 00000000..ee40b675 --- /dev/null +++ b/apps/client/src/pages/dashboard/layout.tsx @@ -0,0 +1,49 @@ +import { SidebarSimple } from "@phosphor-icons/react"; +import { Button, Sheet, SheetClose, SheetContent, SheetTrigger } from "@reactive-resume/ui"; +import { motion } from "framer-motion"; +import { useState } from "react"; +import { Outlet } from "react-router-dom"; + +import { Sidebar } from "./_components/sidebar"; + +export const DashboardLayout = () => { + const [open, setOpen] = useState(false); + + return ( +
+
+ + + + + + + + + + + + + +
+ + +
+ +
+
+ +
+ +
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx b/apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx new file mode 100644 index 00000000..37c257d4 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_dialogs/import.tsx @@ -0,0 +1,315 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, DownloadSimple, Warning } from "@phosphor-icons/react"; +import { + JsonResume, + JsonResumeParser, + LinkedIn, + LinkedInParser, + ReactiveResumeParser, + ReactiveResumeV3, + ReactiveResumeV3Parser, +} from "@reactive-resume/parser"; +import { ResumeData } from "@reactive-resume/schema"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Label, + ScrollArea, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@reactive-resume/ui"; +import { AnimatePresence } from "framer-motion"; +import { useEffect, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z, ZodError } from "zod"; + +import { useToast } from "@/client/hooks/use-toast"; +import { useImportResume } from "@/client/services/resume/import"; +import { useDialog } from "@/client/stores/dialog"; + +enum ImportType { + "reactive-resume-json" = "reactive-resume-json", + "reactive-resume-v3-json" = "reactive-resume-v3-json", + "json-resume-json" = "json-resume-json", + "linkedin-data-export-zip" = "linkedin-data-export-zip", +} + +const formSchema = z.object({ + file: z.instanceof(File), + type: z.nativeEnum(ImportType), +}); + +type FormValues = z.infer; + +type ValidationResult = + | { + isValid: false; + errors: string; + } + | { + isValid: true; + type: ImportType; + result: ResumeData | ReactiveResumeV3 | LinkedIn | JsonResume; + }; + +export const ImportDialog = () => { + const { toast } = useToast(); + const { isOpen, close } = useDialog("import"); + const { importResume, loading, error: importError } = useImportResume(); + + const [validationResult, setValidationResult] = useState(null); + + const form = useForm({ + resolver: zodResolver(formSchema), + }); + const filetype = form.watch("type"); + + useEffect(() => { + if (isOpen) onReset(); + }, [isOpen]); + + useEffect(() => { + form.reset({ file: undefined, type: filetype }); + setValidationResult(null); + }, [filetype]); + + const accept = useMemo(() => { + if (!filetype) return ""; + if (filetype.includes("json")) return ".json"; + if (filetype.includes("zip")) return ".zip"; + return ""; + }, [filetype]); + + const onValidate = async () => { + const { file, type } = formSchema.parse(form.getValues()); + + try { + if (type === ImportType["reactive-resume-json"]) { + const parser = new ReactiveResumeParser(); + const data = await parser.readFile(file); + const result = parser.validate(data); + + setValidationResult({ isValid: true, type, result }); + } + + if (type === ImportType["reactive-resume-v3-json"]) { + const parser = new ReactiveResumeV3Parser(); + const data = await parser.readFile(file); + const result = parser.validate(data); + + setValidationResult({ isValid: true, type, result }); + } + + if (type === ImportType["json-resume-json"]) { + const parser = new JsonResumeParser(); + const data = await parser.readFile(file); + const result = parser.validate(data); + + setValidationResult({ isValid: true, type, result }); + } + + if (type === ImportType["linkedin-data-export-zip"]) { + const parser = new LinkedInParser(); + const data = await parser.readFile(file); + const result = await parser.validate(data); + + setValidationResult({ isValid: true, type, result }); + } + } catch (error) { + if (error instanceof ZodError) { + setValidationResult({ + isValid: false, + errors: error.toString(), + }); + + toast({ + variant: "error", + icon: , + title: "An error occurred while validating the file.", + }); + } + } + }; + + const onImport = async () => { + const { type } = formSchema.parse(form.getValues()); + + if (!validationResult?.isValid || validationResult.type !== type) return; + + try { + if (type === ImportType["reactive-resume-json"]) { + const parser = new ReactiveResumeParser(); + const data = parser.convert(validationResult.result as ResumeData); + + await importResume({ data }); + } + + if (type === ImportType["reactive-resume-v3-json"]) { + const parser = new ReactiveResumeV3Parser(); + const data = parser.convert(validationResult.result as ReactiveResumeV3); + + await importResume({ data }); + } + + if (type === ImportType["json-resume-json"]) { + const parser = new JsonResumeParser(); + const data = parser.convert(validationResult.result as JsonResume); + + await importResume({ data }); + } + + if (type === ImportType["linkedin-data-export-zip"]) { + const parser = new LinkedInParser(); + const data = parser.convert(validationResult.result as LinkedIn); + + await importResume({ data }); + } + + close(); + } catch (error) { + toast({ + variant: "error", + icon: , + title: "An error occurred while importing your resume.", + description: importError?.message, + }); + } + }; + + const onReset = () => { + form.reset(); + setValidationResult(null); + }; + + return ( + + +
+ + + +
+ +

Import an existing resume

+
+
+ + Upload a file from an external source to parse an existing resume and import it into + Reactive Resume for easier editing. + +
+ + ( + + Type + + + + + + )} + /> + + ( + + File + + { + if (!event.target.files || !event.target.files.length) return; + field.onChange(event.target.files[0]); + }} + /> + + + {accept && Accepts only {accept} files} + + )} + /> + + {validationResult?.isValid === false && validationResult.errors !== undefined && ( +
+ + +
+ {JSON.stringify(validationResult.errors, null, 4)} +
+
+
+ )} + + + + {(!validationResult ?? false) && ( + + )} + + {validationResult !== null && !validationResult.isValid && ( + + )} + + {validationResult !== null && validationResult.isValid && ( + <> + + + + + )} + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx b/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx new file mode 100644 index 00000000..6ff20f98 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_dialogs/resume.tsx @@ -0,0 +1,247 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { MagicWand, Plus } from "@phosphor-icons/react"; +import { createResumeSchema, ResumeDto } from "@reactive-resume/dto"; +import { idSchema } from "@reactive-resume/schema"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + Tooltip, +} from "@reactive-resume/ui"; +import { generateRandomName, kebabCase } from "@reactive-resume/utils"; +import { AxiosError } from "axios"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useToast } from "@/client/hooks/use-toast"; +import { useCreateResume, useDeleteResume, useUpdateResume } from "@/client/services/resume"; +import { useImportResume } from "@/client/services/resume/import"; +import { useDialog } from "@/client/stores/dialog"; + +const formSchema = createResumeSchema.extend({ id: idSchema.optional() }); + +type FormValues = z.infer; + +export const ResumeDialog = () => { + const { toast } = useToast(); + const { isOpen, mode, payload, close } = useDialog("resume"); + + const isCreate = mode === "create"; + const isUpdate = mode === "update"; + const isDelete = mode === "delete"; + const isDuplicate = mode === "duplicate"; + + const { createResume, loading: createLoading } = useCreateResume(); + const { updateResume, loading: updateLoading } = useUpdateResume(); + const { deleteResume, loading: deleteLoading } = useDeleteResume(); + const { importResume: duplicateResume, loading: duplicateLoading } = useImportResume(); + + const loading = createLoading || updateLoading || deleteLoading || duplicateLoading; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { title: "", slug: "" }, + }); + + useEffect(() => { + if (isOpen) onReset(); + }, [isOpen, payload]); + + useEffect(() => { + const slug = kebabCase(form.watch("title")); + form.setValue("slug", slug); + }, [form.watch("title")]); + + const onSubmit = async (values: FormValues) => { + try { + if (isCreate) { + await createResume({ slug: values.slug, title: values.title, visibility: "private" }); + } + + if (isUpdate) { + if (!payload.item?.id) return; + + await updateResume({ + ...payload.item, + title: values.title, + slug: values.slug, + }); + } + + if (isDuplicate) { + if (!payload.item?.id) return; + + await duplicateResume({ + title: values.title, + slug: values.slug, + data: payload.item.data, + }); + } + + if (isDelete) { + if (!payload.item?.id) return; + + await deleteResume({ id: payload.item?.id }); + } + + close(); + } catch (error) { + if (error instanceof AxiosError) { + const message = error.response?.data?.message || error.message; + + toast({ + variant: "error", + title: "An error occurred while trying process your request.", + description: message, + }); + } + } + }; + + const onReset = () => { + if (isCreate) form.reset({ title: "", slug: "" }); + if (isUpdate) + form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug }); + if (isDuplicate) + form.reset({ title: `${payload.item?.title} (Copy)`, slug: `${payload.item?.slug}-copy` }); + if (isDelete) + form.reset({ id: payload.item?.id, title: payload.item?.title, slug: payload.item?.slug }); + }; + + const onGenerateRandomName = () => { + const name = generateRandomName(); + form.setValue("title", name); + form.setValue("slug", kebabCase(name)); + }; + + if (isDelete) { + return ( + + +
+ + + Are you sure you want to delete your resume? + + This action cannot be undone. This will permanently delete your resume and cannot + be recovered. + + + + + Cancel + + + Delete + + +
+ +
+
+ ); + } + + return ( + + +
+ + + +
+ +

+ {isCreate && "Create a new resume"} + {isUpdate && "Update an existing resume"} + {isDuplicate && "Duplicate an existing resume"} +

+
+
+ + {isCreate && "Start building your resume by giving it a name."} + {isUpdate && "Changed your mind about the name? Give it a new one."} + {isDuplicate && "Give your old resume a new name."} + +
+ + ( + + Title + +
+ + + {(isCreate || isDuplicate) && ( + + + + )} +
+
+ + Tip: You can name the resume referring to the position you are applying for. + + +
+ )} + /> + + ( + + Slug + + + + + + )} + /> + + + + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/base-card.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/base-card.tsx new file mode 100644 index 00000000..50ab5566 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/base-card.tsx @@ -0,0 +1,25 @@ +import { Card } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import Tilt from "react-parallax-tilt"; + +import { defaultTiltProps } from "@/client/constants/parallax-tilt"; + +type Props = { + className?: string; + onClick?: () => void; + children?: React.ReactNode; +}; + +export const BaseCard = ({ children, className, onClick }: Props) => ( + + + {children} + + +); diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/create-card.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/create-card.tsx new file mode 100644 index 00000000..d83a754d --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/create-card.tsx @@ -0,0 +1,31 @@ +import { Plus } from "@phosphor-icons/react"; +import { KeyboardShortcut } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; + +import { useDialog } from "@/client/stores/dialog"; + +import { BaseCard } from "./base-card"; + +export const CreateResumeCard = () => { + const { open } = useDialog("resume"); + + return ( + open("create")}> + + +
+

+ Create a new resume + (^N) +

+ +

Start from scratch

+
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/import-card.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/import-card.tsx new file mode 100644 index 00000000..f8430c31 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/import-card.tsx @@ -0,0 +1,31 @@ +import { DownloadSimple } from "@phosphor-icons/react"; +import { KeyboardShortcut } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; + +import { useDialog } from "@/client/stores/dialog"; + +import { BaseCard } from "./base-card"; + +export const ImportResumeCard = () => { + const { open } = useDialog("import"); + + return ( + open("create")}> + + +
+

+ Import an existing resume + (^I) +

+ +

LinkedIn, JSON Resume, etc.

+
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx new file mode 100644 index 00000000..800e0726 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/grid/_components/resume-card.tsx @@ -0,0 +1,121 @@ +import { + CircleNotch, + CopySimple, + FolderOpen, + PencilSimple, + TrashSimple, +} from "@phosphor-icons/react"; +import { ResumeDto } from "@reactive-resume/dto"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import dayjs from "dayjs"; +import { AnimatePresence, motion } from "framer-motion"; +import { useNavigate } from "react-router-dom"; + +import { useResumePreview } from "@/client/services/resume/preview"; +import { useDialog } from "@/client/stores/dialog"; + +import { BaseCard } from "./base-card"; + +type Props = { + resume: ResumeDto; +}; + +export const ResumeCard = ({ resume }: Props) => { + const navigate = useNavigate(); + const { open } = useDialog("resume"); + + const { url, loading } = useResumePreview(resume.id); + + const lastUpdated = dayjs().to(resume.updatedAt); + + const onOpen = () => { + navigate(`/builder/${resume.id}`); + }; + + const onUpdate = () => { + open("update", { id: "resume", item: resume }); + }; + + const onDuplicate = () => { + open("duplicate", { id: "resume", item: resume }); + }; + + const onDelete = () => { + open("delete", { id: "resume", item: resume }); + }; + + return ( + + + + + {loading && ( + + + + )} + + {!loading && url && ( + + )} + + +
+

{resume.title}

+

{`Last updated ${lastUpdated}`}

+
+
+
+ + + + + Open + + + + Rename + + + + Duplicate + + + + + Delete + + +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/grid/index.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/grid/index.tsx new file mode 100644 index 00000000..972ac589 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/grid/index.tsx @@ -0,0 +1,57 @@ +import { sortByDate } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; + +import { useResumes } from "@/client/services/resume"; + +import { BaseCard } from "./_components/base-card"; +import { CreateResumeCard } from "./_components/create-card"; +import { ImportResumeCard } from "./_components/import-card"; +import { ResumeCard } from "./_components/resume-card"; + +export const GridView = () => { + const { resumes, loading } = useResumes(); + + return ( +
+ + + + + + + + + {loading && + [...Array(4)].map((_, i) => ( +
+ +
+ ))} + + {resumes && ( + + {resumes + .sort((a, b) => sortByDate(a, b, "updatedAt")) + .map((resume, index) => ( + + + + ))} + + )} +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/base-item.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/base-item.tsx new file mode 100644 index 00000000..17289bdb --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/base-item.tsx @@ -0,0 +1,30 @@ +import { cn } from "@reactive-resume/utils"; + +type Props = { + title?: React.ReactNode; + description?: React.ReactNode; + start?: React.ReactNode; + end?: React.ReactNode; + className?: string; + onClick?: () => void; +}; + +export const BaseListItem = ({ title, description, start, end, className, onClick }: Props) => ( +
+
+
+
{start}
+

{title}

+

{description}

+
+ + {end &&
{end}
} +
+
+); diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/create-item.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/create-item.tsx new file mode 100644 index 00000000..c43844ce --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/create-item.tsx @@ -0,0 +1,25 @@ +import { Plus } from "@phosphor-icons/react"; +import { ResumeDto } from "@reactive-resume/dto"; +import { KeyboardShortcut } from "@reactive-resume/ui"; + +import { useDialog } from "@/client/stores/dialog"; + +import { BaseListItem } from "./base-item"; + +export const CreateResumeListItem = () => { + const { open } = useDialog("resume"); + + return ( + } + onClick={() => open("create")} + title={ + <> + Create a new resume + (^N) + + } + description="Start building from scratch" + /> + ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/import-item.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/import-item.tsx new file mode 100644 index 00000000..2541b4f3 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/import-item.tsx @@ -0,0 +1,24 @@ +import { DownloadSimple } from "@phosphor-icons/react"; +import { KeyboardShortcut } from "@reactive-resume/ui"; + +import { useDialog } from "@/client/stores/dialog"; + +import { BaseListItem } from "./base-item"; + +export const ImportResumeListItem = () => { + const { open } = useDialog("import"); + + return ( + } + onClick={() => open("create")} + title={ + <> + Import an existing resume + (^I) + + } + description="LinkedIn, JSON Resume, etc." + /> + ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/resume-item.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/resume-item.tsx new file mode 100644 index 00000000..eb0f056e --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/list/_components/resume-item.tsx @@ -0,0 +1,163 @@ +import { + CopySimple, + DotsThreeVertical, + FolderOpen, + PencilSimple, + TrashSimple, +} from "@phosphor-icons/react"; +import { ResumeDto } from "@reactive-resume/dto"; +import { + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@reactive-resume/ui"; +import dayjs from "dayjs"; +import { AnimatePresence, motion } from "framer-motion"; +import { useNavigate } from "react-router-dom"; + +import { useResumePreview } from "@/client/services/resume/preview"; +import { useDialog } from "@/client/stores/dialog"; + +import { BaseListItem } from "./base-item"; + +type Props = { + resume: ResumeDto; +}; + +export const ResumeListItem = ({ resume }: Props) => { + const navigate = useNavigate(); + const { open } = useDialog("resume"); + + const { url } = useResumePreview(resume.id); + + const lastUpdated = dayjs().to(resume.updatedAt); + + const onOpen = () => { + navigate(`/builder/${resume.id}`); + }; + + const onUpdate = () => { + open("update", { id: "resume", item: resume }); + }; + + const onDuplicate = () => { + open("duplicate", { id: "resume", item: resume }); + }; + + const onDelete = () => { + open("delete", { id: "resume", item: resume }); + }; + + const dropdownMenu = ( + + + + + + { + event.stopPropagation(); + onOpen(); + }} + > + + Open + + { + event.stopPropagation(); + onUpdate(); + }} + > + + Rename + + { + event.stopPropagation(); + onDuplicate(); + }} + > + + Duplicate + + + { + event.stopPropagation(); + onDelete(); + }} + > + + Delete + + + + ); + + return ( + + + + + + + + + {url && ( + + )} + + + + + + + + + Open + + + + Rename + + + + Duplicate + + + + + Delete + + + + ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/_layouts/list/index.tsx b/apps/client/src/pages/dashboard/resumes/_layouts/list/index.tsx new file mode 100644 index 00000000..90935a2b --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/_layouts/list/index.tsx @@ -0,0 +1,56 @@ +import { sortByDate } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; + +import { useResumes } from "@/client/services/resume"; + +import { BaseListItem } from "./_components/base-item"; +import { CreateResumeListItem } from "./_components/create-item"; +import { ImportResumeListItem } from "./_components/import-item"; +import { ResumeListItem } from "./_components/resume-item"; + +export const ListView = () => { + const { resumes, loading } = useResumes(); + + return ( +
+ + + + + + + + + {loading && + [...Array(4)].map((_, i) => ( +
+ +
+ ))} + + {resumes && ( + + {resumes + .sort((a, b) => sortByDate(a, b, "updatedAt")) + .map((resume, index) => ( + + + + ))} + + )} +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/resumes/page.tsx b/apps/client/src/pages/dashboard/resumes/page.tsx new file mode 100644 index 00000000..df9d3a54 --- /dev/null +++ b/apps/client/src/pages/dashboard/resumes/page.tsx @@ -0,0 +1,54 @@ +import { List, SquaresFour } from "@phosphor-icons/react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui"; +import { motion } from "framer-motion"; +import { useState } from "react"; +import { Helmet } from "react-helmet-async"; + +import { GridView } from "./_layouts/grid"; +import { ListView } from "./_layouts/list"; + +type Layout = "grid" | "list"; + +export const ResumesPage = () => { + const [layout, setLayout] = useState("grid"); + + return ( + <> + + Resumes - Reactive Resume + + + setLayout(value as Layout)}> +
+ + Resumes + + + + + + Grid + + + + List + + +
+ +
+ + + + + + +
+
+ + ); +}; diff --git a/apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx b/apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx new file mode 100644 index 00000000..2730c759 --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/_dialogs/two-factor.tsx @@ -0,0 +1,264 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { QrCode } from "@phosphor-icons/react"; +import { + Alert, + AlertDescription, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { AxiosError } from "axios"; +import { QRCodeSVG } from "qrcode.react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useToast } from "@/client/hooks/use-toast"; +import { queryClient } from "@/client/libs/query-client"; +import { useDisable2FA, useEnable2FA, useSetup2FA } from "@/client/services/auth"; +import { useDialog } from "@/client/stores/dialog"; + +// We're using the pre-existing "mode" state to determine the stage of 2FA setup the user is in. +// - "create" mode is used to enable 2FA. +// - "update" mode is used to verify 2FA, displaying a QR Code, once enabled. +// - "duplicate" mode is used to display the backup codes after initial verification. +// - "delete" mode is used to disable 2FA. + +const formSchema = z.object({ + uri: z.literal("").or(z.string().optional()), + code: z.literal("").or(z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits long.")), + backupCodes: z.array(z.string()), +}); + +type FormValues = z.infer; + +export const TwoFactorDialog = () => { + const { toast } = useToast(); + const { isOpen, mode, open, close } = useDialog("two-factor"); + + const isCreate = mode === "create"; + const isUpdate = mode === "update"; + const isDelete = mode === "delete"; + const isDuplicate = mode === "duplicate"; + + const { setup2FA, loading: setupLoading } = useSetup2FA(); + const { enable2FA, loading: enableLoading } = useEnable2FA(); + const { disable2FA, loading: disableLoading } = useDisable2FA(); + + const loading = setupLoading || enableLoading || disableLoading; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { uri: "", code: "", backupCodes: [] }, + }); + + // If the user is enabling 2FA, we need to get the QR code URI from the server. + // And display the QR code to the user. + useEffect(() => { + const initialize = async () => { + const data = await setup2FA(); + form.setValue("uri", data.message); + }; + + if (isCreate) initialize(); + }, [isCreate]); + + const onSubmit = async (values: FormValues) => { + if (isCreate) { + open("update"); + } + + if (isUpdate) { + if (!values.code) return; + + try { + const data = await enable2FA({ code: values.code }); + form.setValue("backupCodes", data.backupCodes); + await queryClient.invalidateQueries({ queryKey: ["user"] }); + + open("duplicate"); + } catch (error) { + if (error instanceof AxiosError) { + const message = error.response?.data?.message || error.message; + + toast({ + variant: "error", + title: "An error occurred while trying to enable two-factor authentication.", + description: message, + }); + } + } + } + + if (isDuplicate) { + close(); + } + + if (isDelete) { + const data = await disable2FA(); + toast({ variant: "success", title: data.message }); + await queryClient.invalidateQueries({ queryKey: ["user"] }); + + close(); + } + }; + + if (isDelete) { + return ( + + +
+ + + + Are you sure you want to disable two-factor authentication? + + + If you disable two-factor authentication, you will no longer be required to enter + a verification code when logging in. + + + + + Note: This will make your account less secure. + + + + Cancel + + + Disable + + +
+ +
+
+ ); + } + + return ( + + +
+ + + +
+ +

+ {mode === "create" && "Setup two-factor authentication on your account"} + {mode === "update" && + "Verify that two-factor authentication has been setup correctly"} + {mode === "duplicate" && "Store your backup codes securely"} +

+
+
+ + {isCreate && + "Scan the QR code below with your authenticator app to setup 2FA on your account."} + {isUpdate && + "Enter the 6-digit code from your authenticator app to verify that 2FA has been setup correctly."} + {isDuplicate && "You have enabled two-factor authentication successfully."} + +
+ + {isCreate && ( + ( + + +
+ + +
+
+ + In case you don't have access to your camera, you can also copy-paste this URI + to your authenticator app. + +
+ )} + /> + )} + + {isUpdate && ( + ( + + Code + + + + + + )} + /> + )} + + {isDuplicate && ( + <> + ( + +
+ {field.value.map((code) => ( +

{code}

+ ))} +
+
+ )} + /> + +

+ Please store your backup codes in a secure location. You can use one of these + one-time use codes to login in case you lose access to your authenticator app. +

+ + )} + + + {isCreate && } + {isUpdate && ( + <> + + + + + )} + {isDuplicate && } + + + +
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/settings/_sections/account.tsx b/apps/client/src/pages/dashboard/settings/_sections/account.tsx new file mode 100644 index 00000000..9aa58853 --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/_sections/account.tsx @@ -0,0 +1,231 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Check, UploadSimple, Warning } from "@phosphor-icons/react"; +import { UpdateUserDto, updateUserSchema } from "@reactive-resume/dto"; +import { + Button, + buttonVariants, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { useEffect, useRef } from "react"; +import { useForm } from "react-hook-form"; + +import { UserAvatar } from "@/client/components/user-avatar"; +import { useToast } from "@/client/hooks/use-toast"; +import { useResendVerificationEmail } from "@/client/services/auth"; +import { useUploadImage } from "@/client/services/storage"; +import { useUpdateUser, useUser } from "@/client/services/user"; + +export const AccountSettings = () => { + const { user } = useUser(); + const { toast } = useToast(); + const { updateUser, loading } = useUpdateUser(); + const { uploadImage, loading: isUploading } = useUploadImage(); + const { resendVerificationEmail } = useResendVerificationEmail(); + + const inputRef = useRef(null); + + const form = useForm({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + picture: "", + name: "", + username: "", + email: "", + }, + }); + + useEffect(() => { + user && onReset(); + }, [user]); + + const onReset = () => { + if (!user) return; + + form.reset({ + picture: user.picture ?? "", + name: user.name, + username: user.username, + email: user.email, + }); + }; + + const onSubmit = async (data: UpdateUserDto) => { + if (!user) return; + + // Check if email has changed and display a toast message to confirm the email change + if (user.email !== data.email) { + toast({ + variant: "info", + title: "Check your email for the confirmation link to update your email address.", + }); + } + + await updateUser({ + name: data.name, + email: data.email, + picture: data.picture, + username: data.username, + }); + + form.reset(data); + }; + + const onSelectImage = async (event: React.ChangeEvent) => { + if (event.target.files && event.target.files.length > 0) { + const file = event.target.files[0]; + const response = await uploadImage(file); + const url = response.data; + + await updateUser({ picture: url }); + } + }; + + const onResendVerificationEmail = async () => { + const data = await resendVerificationEmail(); + + toast({ variant: "success", title: data.message }); + }; + + if (!user) return null; + + return ( +
+
+

Account

+

+ Here, you can update your account information such as your profile picture, name and + username. +

+
+ +
+ + ( +
+ + + + Picture + + + + + + + {!user.picture && ( + <> + + + inputRef.current?.click()} + className={cn(buttonVariants({ size: "icon", variant: "ghost" }))} + > + + + + )} +
+ )} + /> + + ( + + Name + + + + + )} + /> + + ( + + Username + + + + {fieldState.error && ( + + {fieldState.error.message} + + )} + + )} + /> + + ( + + Email + + + + + {user.emailVerified ? : } + {user.emailVerified ? "Verified" : "Unverified"} + {!user.emailVerified && ( + + )} + + + )} + /> + + + {form.formState.isDirty && ( + + + + + )} + + + +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/settings/_sections/danger.tsx b/apps/client/src/pages/dashboard/settings/_sections/danger.tsx new file mode 100644 index 00000000..69c33554 --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/_sections/danger.tsx @@ -0,0 +1,96 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + Input, +} from "@reactive-resume/ui"; +import { useForm } from "react-hook-form"; +import { useNavigate } from "react-router-dom"; +import { useCounter } from "usehooks-ts"; +import { z } from "zod"; + +import { useToast } from "@/client/hooks/use-toast"; +import { useLogout } from "@/client/services/auth"; +import { useDeleteUser } from "@/client/services/user"; + +const formSchema = z.object({ + deleteConfirm: z.literal("delete"), +}); + +type FormValues = z.infer; + +export const DangerZoneSettings = () => { + const { toast } = useToast(); + const navigate = useNavigate(); + const { logout } = useLogout(); + const { count, increment } = useCounter(0); + const { deleteUser, loading } = useDeleteUser(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + deleteConfirm: "" as FormValues["deleteConfirm"], + }, + }); + + const onDelete = async () => { + // On the first click, increment the counter + increment(); + + // On the second click, delete the account + if (count === 1) { + await Promise.all([deleteUser, logout]); + + toast({ + variant: "success", + title: "Your account has been deleted successfully.", + }); + + navigate("/"); + } + }; + + return ( +
+
+

Danger Zone

+

+ In this section, you can delete your account and all the data associated to your user, but + please keep in mind that{" "} + this action is irreversible. +

+
+ +
+ + ( + + Delete Account + + + + + Type delete to confirm deleting your account. + + + )} + /> + +
+ +
+ + +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/settings/_sections/openai.tsx b/apps/client/src/pages/dashboard/settings/_sections/openai.tsx new file mode 100644 index 00000000..e364a11e --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/_sections/openai.tsx @@ -0,0 +1,147 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { LockSimple, LockSimpleOpen, TrashSimple } from "@phosphor-icons/react"; +import { + Alert, + Button, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, +} from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useOpenAiStore } from "@/client/stores/openai"; + +const formSchema = z.object({ + apiKey: z + .string() + .regex(/^sk-[a-zA-Z0-9]+$/, "That doesn't look like a valid OpenAI API key.") + .default(""), +}); + +type FormValues = z.infer; + +export const OpenAISettings = () => { + const { apiKey, setApiKey } = useOpenAiStore(); + const isEnabled = !!apiKey; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { apiKey: apiKey ?? "" }, + }); + + const onSubmit = async ({ apiKey }: FormValues) => { + setApiKey(apiKey); + }; + + const onRemove = () => { + setApiKey(null); + form.reset({ apiKey: "" }); + }; + + return ( +
+
+

OpenAI Integration

+

+ You can make use of the OpenAI API to help you generate content, or improve your writing + while composing your resume. +

+
+ +
+

+ You have the option to{" "} + + obtain your own OpenAI API key + + . This key empowers you to leverage the API as you see fit. Alternatively, if you wish to + disable the AI features in Reactive Resume altogether, you can simply remove the key from + your settings. +

+
+ +
+ + ( + + API Key + + + + + + )} + /> + +
+ + + {isEnabled && ( + + )} +
+ + + +
+

+ Your API key is securely stored in the browser's local storage and is only utilized when + making requests to OpenAI via their official SDK. Rest assured that your key is not + transmitted to any external server except when interacting with OpenAI's services. +

+
+ + +
+ Note: + + By utilizing the OpenAI API, you acknowledge and accept the{" "} + + terms of use + {" "} + and{" "} + + privacy policy + {" "} + outlined by OpenAI. Please note that Reactive Resume bears no responsibility for any + improper or unauthorized utilization of the service, and any resulting repercussions or + liabilities solely rest on the user. + +
+
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/settings/_sections/profile.tsx b/apps/client/src/pages/dashboard/settings/_sections/profile.tsx new file mode 100644 index 00000000..5da73c65 --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/_sections/profile.tsx @@ -0,0 +1,138 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTheme } from "@reactive-resume/hooks"; +import { Button } from "@reactive-resume/ui"; +import { Combobox } from "@reactive-resume/ui"; +import { Form, FormDescription, FormField, FormItem, FormLabel } from "@reactive-resume/ui"; +import { cn } from "@reactive-resume/utils"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useUpdateUser, useUser } from "@/client/services/user"; + +const formSchema = z.object({ + theme: z.enum(["system", "light", "dark"]).default("system"), + language: z.string().default("en"), +}); + +type FormValues = z.infer; + +export const ProfileSettings = () => { + const { user } = useUser(); + const { theme, setTheme } = useTheme(); + const { updateUser, loading } = useUpdateUser(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { theme, language: "en" }, + }); + + useEffect(() => { + user && onReset(); + }, [user]); + + const onReset = () => { + if (!user) return; + + form.reset({ theme, language: user.language ?? "en" }); + }; + + const onSubmit = async (data: FormValues) => { + if (!user) return; + + setTheme(data.theme); + + if (user.language !== data.language) { + await updateUser({ language: data.language }); + } + + form.reset(data); + }; + + return ( +
+
+

Profile

+

+ Here, you can update your profile to customize and personalize your experience. +

+
+ +
+ + ( + + Theme +
+ +
+
+ )} + /> + + ( + + Language +
+ English

, + }, + ]} + /> +
+ + + Don't see your language?{" "} + + Help translate the app. + + + +
+ )} + /> + +
+ + +
+ + +
+ ); +}; diff --git a/apps/client/src/pages/dashboard/settings/_sections/security.tsx b/apps/client/src/pages/dashboard/settings/_sections/security.tsx new file mode 100644 index 00000000..97c67eab --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/_sections/security.tsx @@ -0,0 +1,162 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + Input, +} from "@reactive-resume/ui"; +import { AnimatePresence, motion } from "framer-motion"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useToast } from "@/client/hooks/use-toast"; +import { useUpdatePassword } from "@/client/services/auth"; +import { useUser } from "@/client/services/user"; +import { useDialog } from "@/client/stores/dialog"; + +const formSchema = z + .object({ + password: z.string().min(6), + confirmPassword: z.string().min(6), + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "The passwords you entered do not match.", + }); + +type FormValues = z.infer; + +export const SecuritySettings = () => { + const { user } = useUser(); + const { toast } = useToast(); + const { open } = useDialog("two-factor"); + const { updatePassword, loading } = useUpdatePassword(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { password: "", confirmPassword: "" }, + }); + + const onReset = () => { + form.reset({ password: "", confirmPassword: "" }); + }; + + const onSubmit = async (data: FormValues) => { + await updatePassword({ password: data.password }); + + toast({ + variant: "success", + title: "Your password has been updated successfully.", + }); + + onReset(); + }; + + return ( +
+
+

Security

+

+ In this section, you can change your password and enable/disable two-factor + authentication. +

+
+ + + + Password + +
+ + ( + + New Password + + + + + )} + /> + + ( + + Confirm New Password + + + + {fieldState.error && ( + + {fieldState.error?.message} + + )} + + )} + /> + + + {form.formState.isDirty && ( + + + + + )} + + + +
+
+ + + Two-Factor Authentication + + {user?.twoFactorEnabled ? ( +

+ Two-factor authentication is enabled. You will be asked to enter a + code every time you sign in. +

+ ) : ( +

+ Two-factor authentication is currently disabled. You can enable it + by adding an authenticator app to your account. +

+ )} + + {user?.twoFactorEnabled ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/apps/client/src/pages/dashboard/settings/page.tsx b/apps/client/src/pages/dashboard/settings/page.tsx new file mode 100644 index 00000000..99b17e35 --- /dev/null +++ b/apps/client/src/pages/dashboard/settings/page.tsx @@ -0,0 +1,37 @@ +import { Separator } from "@reactive-resume/ui"; +import { motion } from "framer-motion"; +import { Helmet } from "react-helmet-async"; + +import { AccountSettings } from "./_sections/account"; +import { DangerZoneSettings } from "./_sections/danger"; +import { OpenAISettings } from "./_sections/openai"; +import { ProfileSettings } from "./_sections/profile"; +import { SecuritySettings } from "./_sections/security"; + +export const SettingsPage = () => ( + <> + + Settings - Reactive Resume + + +
+ + Settings + + + + + + + + + + + +
+ +); diff --git a/apps/client/src/pages/home/components/footer.tsx b/apps/client/src/pages/home/components/footer.tsx new file mode 100644 index 00000000..c8a1e40f --- /dev/null +++ b/apps/client/src/pages/home/components/footer.tsx @@ -0,0 +1,32 @@ +import { Separator } from "@reactive-resume/ui"; + +import { Copyright } from "@/client/components/copyright"; +import { Logo } from "@/client/components/logo"; +import { ThemeSwitch } from "@/client/components/theme-switch"; + +export const Footer = () => ( +
+ + +
+
+ + +

Reactive Resume

+ +

+ A free and open-source resume builder that simplifies the tasks of creating, updating, and + sharing your resume. +

+ + +
+ +
+
+ +
+
+
+
+); diff --git a/apps/client/src/pages/home/components/header.tsx b/apps/client/src/pages/home/components/header.tsx new file mode 100644 index 00000000..93f74f6e --- /dev/null +++ b/apps/client/src/pages/home/components/header.tsx @@ -0,0 +1,23 @@ +import { motion } from "framer-motion"; +import { Link } from "react-router-dom"; + +import { Logo } from "@/client/components/logo"; + +export const Header = () => ( + +
+
+ + + + +
+
+
+ +); diff --git a/apps/client/src/pages/home/layout.tsx b/apps/client/src/pages/home/layout.tsx new file mode 100644 index 00000000..e934a50f --- /dev/null +++ b/apps/client/src/pages/home/layout.tsx @@ -0,0 +1,12 @@ +import { Outlet } from "react-router-dom"; + +import { Footer } from "./components/footer"; +import { Header } from "./components/header"; + +export const HomeLayout = () => ( + <> +
+ +