refactor(v4.0.0-alpha): beginning of a new era
20
.dockerignore
Normal file
@ -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
|
||||||
12
.editorconfig
Normal file
@ -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
|
||||||
1
.eslintignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
62
.eslintrc.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
139
.github/workflows/publish-docker-image.yml
vendored
Normal file
@ -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 }}
|
||||||
44
.gitignore
vendored
Normal file
@ -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*
|
||||||
4
.husky/commit-msg
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
. "$(dirname "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
pnpm exec commitlint --edit $1
|
||||||
3
.npmrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
auto-install-peers=true
|
||||||
|
enable-pre-post-scripts=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
5
.prettierignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/dist
|
||||||
|
/coverage
|
||||||
|
/.nx/cache
|
||||||
|
stats.html
|
||||||
|
pnpm-lock.yaml
|
||||||
3
.prettierrc
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
8
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"nrwl.angular-console",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"firsttris.vscode-jest-runner"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
.vscode/settings.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
CHANGELOG.md
Normal file
@ -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).
|
||||||
50
Dockerfile
Normal file
@ -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" ]
|
||||||
20
LICENSE.md
Normal file
@ -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.
|
||||||
129
README.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
[](https://hub.docker.com/repository/docker/amruthpillai/reactive-resume)
|
||||||
|
[](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
|
||||||
|
|
||||||
|
<a href="https://star-history.com/#AmruthPillai/Reactive-Resume&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=AmruthPillai/Reactive-Resume&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=AmruthPillai/Reactive-Resume&type=Date" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=AmruthPillai/Reactive-Resume&type=Date" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Frequently Asked Questions
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Who are you, and why did you build Reactive Resume?</strong></summary>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>How much does it cost to run Reactive Resume?</strong></summary>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Other than donating, how can I support you?</strong></summary>
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>How does the OpenAI Integration work? How can I trust you with my API key?</strong></summary>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## 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
|
||||||
14
SECURITY.md
Normal file
@ -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.
|
||||||
10
apps/client-e2e/.eslintrc.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": ["plugin:cypress/recommended", "../../.eslintrc.json"],
|
||||||
|
"ignorePatterns": ["!**/*"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
apps/client-e2e/cypress.config.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { nxE2EPreset } from "@nx/cypress/plugins/cypress-preset";
|
||||||
|
import { defineConfig } from "cypress";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
e2e: nxE2EPreset(__dirname, {
|
||||||
|
bundler: "vite",
|
||||||
|
}),
|
||||||
|
});
|
||||||
33
apps/client-e2e/project.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
13
apps/client-e2e/src/e2e/app.cy.ts
Normal file
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/client-e2e/src/fixtures/example.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Using fixtures to represent data",
|
||||||
|
"email": "hello@cypress.io"
|
||||||
|
}
|
||||||
1
apps/client-e2e/src/support/app.po.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const getGreeting = () => cy.get("h1");
|
||||||
33
apps/client-e2e/src/support/commands.ts
Normal file
@ -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<Subject> {
|
||||||
|
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) => { ... })
|
||||||
17
apps/client-e2e/src/support/e2e.ts
Normal file
@ -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";
|
||||||
10
apps/client-e2e/tsconfig.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
31
apps/client/.eslintrc.json
Normal file
@ -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": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
apps/client/index.html
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<base href="/" />
|
||||||
|
|
||||||
|
<!-- SEO -->
|
||||||
|
<title>Reactive Resume - A free and open-source resume builder</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
|
<!-- White Flash Prevention Script -->
|
||||||
|
<script type="text/javascript" src="/scripts/initialize-theme.js"></script>
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/icon/dark.svg"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/icon/light.svg"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Styles -->
|
||||||
|
<link rel="stylesheet" href="/src/styles/main.css" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-background text-foreground text-sm antialiased print:bg-white print:m-0">
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
apps/client/postcss.config.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const { join } = require("path");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-import": {},
|
||||||
|
"tailwindcss/nesting": {},
|
||||||
|
tailwindcss: { config: join(__dirname, "tailwind.config.js") },
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
80
apps/client/project.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
6
apps/client/proxy.conf.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:3000",
|
||||||
|
"secure": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 363 KiB |
33
apps/client/public/brand-logos/dark/amazon.svg
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<svg width="498" height="151" viewBox="0 0 498 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_177_125)">
|
||||||
|
<path
|
||||||
|
d="M309.08 117.21C280.235 138.472 238.425 149.816 202.427 149.816C151.952 149.816 106.512 131.147 72.1348 100.098C69.4339 97.6559 71.8539 94.3284 75.095 96.2298C112.195 117.815 158.067 130.801 205.452 130.801C237.409 130.801 272.564 124.19 304.889 110.469C309.772 108.395 313.856 113.667 309.08 117.21Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M321.072 103.49C317.399 98.7795 296.699 101.264 287.408 102.366C284.578 102.712 284.146 100.249 286.695 98.477C303.182 86.8739 330.234 90.223 333.389 94.1123C336.543 98.0232 332.567 125.14 317.075 138.083C314.698 140.071 312.429 139.012 313.488 136.376C316.967 127.69 324.767 108.222 321.072 103.49Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M288.057 16.5637V5.28474C288.057 3.57776 289.353 2.43258 290.909 2.43258H341.405C343.025 2.43258 344.322 3.59937 344.322 5.28474V14.9432C344.3 16.5637 342.939 18.6813 340.519 22.0304L314.353 59.3894C324.076 59.1517 334.339 60.5994 343.155 65.5691C345.143 66.6926 345.683 68.3348 345.834 69.9554V81.9906C345.834 83.6328 344.019 85.5558 342.118 84.5619C326.582 76.4159 305.947 75.53 288.77 84.6483C287.019 85.599 285.183 83.6976 285.183 82.0554V70.6252C285.183 68.7886 285.204 65.6555 287.041 62.8682L317.356 19.3943H290.974C289.353 19.3943 288.057 18.2491 288.057 16.5637Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M103.854 86.9387H88.4916C87.0223 86.8307 85.8555 85.7287 85.7474 84.3242V5.47922C85.7474 3.90188 87.0655 2.64865 88.7076 2.64865H103.033C104.524 2.71343 105.713 3.85866 105.821 5.28474V15.5914H106.102C109.84 5.63045 116.862 0.984887 126.326 0.984887C135.941 0.984887 141.948 5.63045 146.269 15.5914C149.986 5.63045 158.434 0.984887 167.488 0.984887C173.927 0.984887 180.971 3.64258 185.271 9.60619C190.132 16.2396 189.138 25.8765 189.138 34.3249L189.117 84.0865C189.117 85.6639 187.799 86.9387 186.157 86.9387H170.815C169.281 86.8307 168.05 85.599 168.05 84.0865V42.298C168.05 38.9705 168.352 30.6733 167.617 27.5186C166.472 22.2249 163.037 20.7339 158.586 20.7339C154.869 20.7339 150.98 23.2188 149.403 27.1945C147.825 31.1703 147.976 37.8253 147.976 42.298V84.0865C147.976 85.6639 146.658 86.9387 145.016 86.9387H129.675C128.119 86.8307 126.909 85.599 126.909 84.0865L126.888 42.298C126.888 33.5039 128.335 20.5611 117.424 20.5611C106.382 20.5611 106.815 33.1797 106.815 42.298V84.0865C106.815 85.6639 105.496 86.9387 103.854 86.9387Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M387.796 0.984887C410.591 0.984887 422.929 20.5611 422.929 45.4527C422.929 69.5016 409.295 88.5808 387.796 88.5808C365.411 88.5808 353.224 69.0046 353.224 44.61C353.224 20.0641 365.562 0.984887 387.796 0.984887ZM387.925 17.0823C376.603 17.0823 375.89 32.5099 375.89 42.1252C375.89 51.762 375.739 72.3322 387.796 72.3322C399.701 72.3322 400.263 55.7378 400.263 45.6255C400.263 38.9705 399.982 31.019 397.973 24.7097C396.244 19.2215 392.809 17.0823 387.925 17.0823Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M452.488 86.9387H437.19C435.656 86.8307 434.424 85.599 434.424 84.0865L434.403 5.21991C434.532 3.77223 435.807 2.64865 437.363 2.64865H451.602C452.942 2.71343 454.044 3.62098 454.346 4.8526V16.9095H454.627C458.927 6.12742 464.955 0.984887 475.565 0.984887C482.457 0.984887 489.177 3.46973 493.499 10.276C497.518 16.5853 497.518 27.1945 497.518 34.8219V84.4539C497.345 85.8367 496.07 86.9387 494.557 86.9387H479.151C477.747 86.8307 476.58 85.7935 476.429 84.4539V41.6282C476.429 33.0069 477.423 20.3882 466.814 20.3882C463.076 20.3882 459.64 22.8947 457.933 26.6976C455.772 31.516 455.491 36.3128 455.491 41.6282V84.0865C455.47 85.6639 454.13 86.9387 452.488 86.9387Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M247.802 49.2772V45.9497C236.696 45.9497 224.963 48.3265 224.963 61.4205C224.963 68.0539 228.399 72.5482 234.297 72.5482C238.619 72.5482 242.487 69.8905 244.928 65.5691C247.953 60.2537 247.802 55.2624 247.802 49.2772ZM263.294 86.7226C262.279 87.6301 260.81 87.6949 259.664 87.0899C254.565 82.8549 253.658 80.8887 250.849 76.8481C242.422 85.4478 236.458 88.019 225.525 88.019C212.604 88.019 202.535 80.046 202.535 64.0782C202.535 51.6108 209.298 43.1191 218.913 38.9705C227.254 35.2973 238.9 34.649 247.802 33.6335V31.6456C247.802 27.994 248.083 23.6725 245.944 20.5179C244.064 17.6873 240.477 16.5205 237.323 16.5205C231.467 16.5205 226.238 19.5239 224.963 25.7468C224.704 27.1297 223.688 28.491 222.305 28.5558L207.396 26.9568C206.143 26.676 204.76 25.6604 205.106 23.7374C208.542 5.67368 224.855 0.228627 239.462 0.228627C246.938 0.228627 256.704 2.2165 262.603 7.87761C270.079 14.8568 269.366 24.1695 269.366 34.3033V58.2442C269.366 65.4394 272.348 68.5941 275.157 72.4834C276.151 73.8663 276.367 75.53 275.114 76.5672C271.981 79.1817 266.406 84.0433 263.338 86.7658L263.294 86.7226Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M46.4006 49.2772V45.9497C35.2944 45.9497 23.5617 48.3265 23.5617 61.4205C23.5617 68.0539 26.9972 72.5482 32.896 72.5482C37.2175 72.5482 41.0852 69.8905 43.5268 65.5691C46.5518 60.2537 46.4006 55.2624 46.4006 49.2772ZM61.893 86.7226C60.8775 87.6301 59.4081 87.6949 58.263 87.0899C53.1637 82.8549 52.2561 80.8887 49.4472 76.8481C41.0203 85.4478 35.0567 88.019 24.1234 88.019C11.2023 88.019 1.1333 80.046 1.1333 64.0782C1.1333 51.6108 7.89638 43.1191 17.5116 38.9705C25.852 35.2973 37.4984 34.649 46.4006 33.6335V31.6456C46.4006 27.994 46.6815 23.6725 44.5423 20.5179C42.6625 17.6873 39.0757 16.5205 35.921 16.5205C30.0655 16.5205 24.8365 19.5239 23.5617 25.7468C23.3024 27.1297 22.2868 28.491 20.904 28.5558L5.99494 26.9568C4.74172 26.676 3.35885 25.6604 3.70456 23.7374C7.14012 5.67368 23.4536 0.228627 38.0602 0.228627C45.5363 0.228627 55.3028 2.2165 61.2015 7.87761C68.6777 14.8568 67.9646 24.1695 67.9646 34.3033V58.2442C67.9646 65.4394 70.9464 68.5941 73.7554 72.4834C74.7493 73.8663 74.9654 75.53 73.7122 76.5672C70.5791 79.1817 65.0044 84.0433 61.9362 86.7658L61.893 86.7226Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_177_125">
|
||||||
|
<rect width="496.978" height="150" fill="#09090b" transform="translate(0.833313 0.0258408)" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
25
apps/client/public/brand-logos/dark/google.svg
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<svg width="452" height="151" viewBox="0 0 452 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_177_106)">
|
||||||
|
<path
|
||||||
|
d="M193.468 78.927C193.468 100.308 176.742 116.063 156.215 116.063C135.689 116.063 118.962 100.308 118.962 78.927C118.962 57.3955 135.689 41.7911 156.215 41.7911C176.742 41.7911 193.468 57.3955 193.468 78.927ZM177.161 78.927C177.161 65.5661 167.467 56.4244 156.215 56.4244C144.964 56.4244 135.27 65.5661 135.27 78.927C135.27 92.1539 144.964 101.429 156.215 101.429C167.467 101.429 177.161 92.1371 177.161 78.927Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M273.835 78.927C273.835 100.308 257.108 116.063 236.582 116.063C216.055 116.063 199.328 100.308 199.328 78.927C199.328 57.4123 216.055 41.7911 236.582 41.7911C257.108 41.7911 273.835 57.3955 273.835 78.927ZM257.527 78.927C257.527 65.5661 247.833 56.4244 236.582 56.4244C225.33 56.4244 215.636 65.5661 215.636 78.927C215.636 92.1539 225.33 101.429 236.582 101.429C247.833 101.429 257.527 92.1371 257.527 78.927Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M350.852 44.0347V110.705C350.852 138.13 334.678 149.331 315.558 149.331C297.559 149.331 286.727 137.293 282.641 127.448L296.839 121.538C299.368 127.582 305.562 134.714 315.541 134.714C327.78 134.714 335.365 127.163 335.365 112.949V107.608H334.796C331.146 112.111 324.114 116.046 315.24 116.046C296.672 116.046 279.661 99.8723 279.661 79.0609C279.661 58.0987 296.672 41.7911 315.24 41.7911C324.097 41.7911 331.129 45.7257 334.796 50.0956H335.365V44.0514H350.852V44.0347ZM336.52 79.0609C336.52 65.9846 327.797 56.4244 316.696 56.4244C305.445 56.4244 296.019 65.9846 296.019 79.0609C296.019 92.0032 305.445 101.429 316.696 101.429C327.797 101.429 336.52 92.0032 336.52 79.0609Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path d="M376.385 4.95665V113.786H360.479V4.95665H376.385Z" fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M438.367 91.1493L451.025 99.5877C446.94 105.632 437.095 116.046 420.084 116.046C398.988 116.046 383.233 99.7384 383.233 78.9102C383.233 56.8263 399.122 41.7744 418.259 41.7744C437.53 41.7744 446.957 57.1109 450.037 65.3986L451.728 69.6179L402.085 90.1782C405.886 97.6288 411.796 101.429 420.084 101.429C428.389 101.429 434.148 97.3442 438.367 91.1493ZM399.407 77.7884L432.591 64.009C430.766 59.3712 425.274 56.1398 418.812 56.1398C410.524 56.1398 398.988 63.4565 399.407 77.7884Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M58.7548 69.2663V53.5112H111.847C112.366 56.257 112.634 59.5051 112.634 63.0211C112.634 74.8417 109.402 89.4583 98.9881 99.8724C88.8586 110.42 75.9163 116.046 58.7715 116.046C26.9935 116.046 0.271729 90.1615 0.271729 58.3834C0.271729 26.6053 26.9935 0.72068 58.7715 0.72068C76.3516 0.72068 88.8753 7.61877 98.2849 16.6097L87.1676 27.7271C80.4202 21.3982 71.2785 16.4758 58.7548 16.4758C35.5491 16.4758 17.3997 35.1776 17.3997 58.3834C17.3997 81.5891 35.5491 100.291 58.7548 100.291C73.8067 100.291 82.3791 94.2467 87.8708 88.755C92.3244 84.3014 95.2544 77.9391 96.4097 69.2495L58.7548 69.2663Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_177_106">
|
||||||
|
<rect width="451.667" height="150" fill="#09090b" transform="translate(0.166702 0.0258408)" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
15
apps/client/public/brand-logos/dark/postman.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
12
apps/client/public/brand-logos/dark/twilio.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="500" height="151" viewBox="0 0 500 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_177_123)">
|
||||||
|
<path
|
||||||
|
d="M72.4341 56.5258C72.4341 65.0258 65.4341 72.0258 56.9341 72.0258C48.4341 72.0258 41.4341 65.0258 41.4341 56.5258C41.4341 48.0258 48.4341 41.0258 56.9341 41.0258C65.4341 41.0258 72.4341 48.0258 72.4341 56.5258ZM56.9341 78.0258C48.4341 78.0258 41.4341 85.0258 41.4341 93.5258C41.4341 102.026 48.4341 109.026 56.9341 109.026C65.4341 109.026 72.4341 102.026 72.4341 93.5258C72.4341 85.0258 65.4341 78.0258 56.9341 78.0258ZM150.434 75.0258C150.434 116.526 116.934 150.026 75.4341 150.026C33.9341 150.026 0.434082 116.526 0.434082 75.0258C0.434082 33.5258 33.9341 0.0258408 75.4341 0.0258408C116.934 0.0258408 150.434 33.5258 150.434 75.0258ZM130.434 75.0258C130.434 44.5258 105.934 20.0258 75.4341 20.0258C44.9341 20.0258 20.4341 44.5258 20.4341 75.0258C20.4341 105.526 44.9341 130.026 75.4341 130.026C105.934 130.026 130.434 105.526 130.434 75.0258ZM93.9341 78.0258C85.4341 78.0258 78.4341 85.0258 78.4341 93.5258C78.4341 102.026 85.4341 109.026 93.9341 109.026C102.434 109.026 109.434 102.026 109.434 93.5258C109.434 85.0258 102.434 78.0258 93.9341 78.0258ZM93.9341 41.0258C85.4341 41.0258 78.4341 48.0258 78.4341 56.5258C78.4341 65.0258 85.4341 72.0258 93.9341 72.0258C102.434 72.0258 109.434 65.0258 109.434 56.5258C109.434 48.0258 102.434 41.0258 93.9341 41.0258ZM351.934 29.5258C352.434 29.5258 352.934 30.0258 353.434 30.5258V46.5258C353.434 47.5258 352.434 48.0258 351.934 48.0258H325.434C324.434 48.0258 323.934 47.0258 323.934 46.5258V31.0258C323.934 30.0258 324.934 29.5258 325.434 29.5258H351.934ZM351.434 52.0258H300.434C299.934 52.0258 298.934 52.5258 298.934 53.5258L292.434 78.5258L291.934 80.0258L283.934 53.5258C283.934 53.0258 282.934 52.0258 282.434 52.0258H262.434C261.934 52.0258 260.934 52.5258 260.934 53.5258L253.434 78.5258L252.934 80.0258L252.434 78.5258L249.434 66.0258L246.434 53.5258C246.434 53.0258 245.434 52.0258 244.934 52.0258H204.934V30.5258C204.934 30.0258 203.934 29.0258 202.934 29.5258L177.934 37.5258C176.934 37.5258 176.434 38.0258 176.434 39.0258V52.5258H169.934C169.434 52.5258 168.434 53.0258 168.434 54.0258V73.0258C168.434 73.5258 168.934 74.5258 169.934 74.5258H176.434V98.0258C176.434 114.526 185.434 122.026 201.934 122.026C208.934 122.026 215.434 120.526 219.934 118.026V98.0258C219.934 97.0258 218.934 96.5258 218.434 97.0258C215.934 98.0258 213.434 98.5258 211.434 98.5258C206.934 98.5258 204.434 96.5258 204.434 91.5258V74.5258H218.934C219.434 74.5258 220.434 74.0258 220.434 73.0258V57.0258L239.434 120.026C239.434 120.526 240.434 121.526 240.934 121.526H261.934C262.434 121.526 263.434 121.026 263.434 120.026L272.434 92.0258L276.934 106.526L280.934 120.026C280.934 120.526 281.934 121.526 282.434 121.526H303.434C303.934 121.526 304.934 121.026 304.934 120.026L323.934 57.0258V120.026C323.934 120.526 324.434 121.526 325.434 121.526H350.934C351.434 121.526 352.434 121.026 352.434 120.026V53.5258C352.434 53.0258 351.934 52.0258 351.434 52.0258ZM384.934 29.5258H359.434C358.934 29.5258 357.934 30.0258 357.934 31.0258V119.526C357.934 120.026 358.434 121.026 359.434 121.026H384.934C385.434 121.026 386.434 120.526 386.434 119.526V30.5258C386.434 30.0258 385.934 29.5258 384.934 29.5258ZM418.934 29.5258H392.434C391.934 29.5258 390.934 30.0258 390.934 31.0258V46.5258C390.934 47.0258 391.434 48.0258 392.434 48.0258H418.934C419.434 48.0258 420.434 47.5258 420.434 46.5258V30.5258C420.434 30.0258 419.934 29.5258 418.934 29.5258ZM418.434 52.0258H392.934C392.434 52.0258 391.434 52.5258 391.434 53.5258V119.026C391.434 119.526 391.934 120.526 392.934 120.526H418.434C418.934 120.526 419.934 120.026 419.934 119.026V53.5258C419.934 53.0258 419.434 52.0258 418.434 52.0258ZM498.934 86.0258C498.934 105.026 482.934 121.526 460.434 121.526C438.434 121.526 422.434 105.026 422.434 86.0258C422.434 67.0258 438.434 50.5258 460.934 50.5258C482.934 50.5258 498.934 67.0258 498.934 86.0258ZM471.934 86.5258C471.934 79.5258 466.934 74.0258 460.934 74.5258C454.434 74.5258 449.934 80.0258 449.934 86.5258C449.934 93.0258 454.934 98.5258 460.934 98.5258C467.434 98.5258 471.934 93.0258 471.934 86.5258Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_177_123">
|
||||||
|
<rect width="499" height="150" fill="#09090b" transform="translate(0.434082 0.0258408)" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
14
apps/client/public/brand-logos/dark/zalando.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg width="781" height="151" viewBox="0 0 781 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M324.116 41.9481C318.271 38.7333 309.581 37.1694 297.548 37.1651C281.083 37.1712 265.8 40.5866 260.015 42.7425C258.709 43.1615 257.312 44.289 257.289 46.2185L257.293 46.644L258.913 54.8322L258.953 54.9846C259.461 56.6518 260.849 57.3995 262.114 57.3995H262.12C262.527 57.3821 262.844 57.3037 263.138 57.2286L265.879 56.5887C273.051 54.793 283.891 52.0811 295.996 52.0811C303.339 52.0811 308.275 52.721 311.584 54.1183C316.953 56.1512 316.987 59.6218 317.041 65.3668V73.9599C316.426 73.949 315.388 73.9381 313.789 73.9381C307.86 73.9381 296.203 74.2134 284.738 76.0515C267.242 78.7385 252.766 82.9262 252.857 107.032C252.864 137.632 281.246 137.69 294.886 137.718H294.888C304.147 137.716 312.69 136.349 318.269 135.195C331.002 132.238 334.253 128.195 334.082 115.543V64.6594C334.101 56.8989 334.124 47.2404 324.116 41.9481ZM317.126 120.552C310.349 122.752 302.934 123.865 295.081 123.865V124.58L295.072 123.865C282.524 123.769 270.27 122.334 270.254 106.329C270.335 93.599 277.127 92.0841 285.724 90.1644L286.321 90.0316C293.969 88.397 313.167 88.0901 317.128 88.0444L317.126 120.552ZM475.964 64.6594V115.543C476.134 128.195 472.883 132.238 460.151 135.195C454.571 136.349 446.028 137.716 436.768 137.718C423.129 137.69 394.745 137.632 394.738 107.032C394.647 82.9262 409.124 78.7385 426.619 76.0515C438.084 74.2134 449.742 73.9381 455.669 73.9381C457.269 73.9381 458.306 73.949 458.922 73.9599V65.3668C458.868 59.6218 458.834 56.1512 453.466 54.1183C450.156 52.721 445.22 52.0811 437.877 52.0811C425.771 52.0811 414.932 54.793 407.76 56.5887L405.02 57.2286C404.725 57.3038 404.408 57.3821 404 57.3995H403.995C402.73 57.3995 401.342 56.6518 400.834 54.9846L400.794 54.8322L399.174 46.644L399.17 46.2185C399.193 44.289 400.59 43.1615 401.896 42.7425C407.679 40.5866 422.964 37.1716 439.429 37.1651C451.462 37.1694 460.152 38.7333 465.997 41.9481C476.004 47.2404 475.982 56.8989 475.964 64.6594ZM459.009 88.0444C455.048 88.0901 435.85 88.397 428.201 90.0316L427.606 90.1644C419.008 92.0841 412.216 93.599 412.136 106.329C412.151 122.334 424.405 123.769 436.953 123.865L436.963 124.58V123.865C444.816 123.865 452.23 122.752 459.007 120.552L459.009 88.0444ZM732.972 37.1651C693.446 37.2183 690.094 63.1891 690.057 87.5318C690.094 111.785 693.446 137.665 732.972 137.718H732.975C772.471 137.665 775.838 111.855 775.893 87.5286C775.852 62.0737 772.501 37.2184 732.972 37.1651ZM732.974 123.51C709 123.405 707.795 111.099 707.632 87.5362C707.795 63.8443 708.998 51.476 732.971 51.3715C756.951 51.476 758.152 63.8443 758.315 87.5253C758.152 111.099 756.947 123.405 732.974 123.51ZM662.037 134.136L661.004 134.398C655.477 135.799 647.904 137.718 634.631 137.718H634.602C598.02 137.665 590.841 119.212 590.801 87.3544C590.845 50.8154 602.428 37.214 633.536 37.1651C644.511 37.1651 652.072 38.5842 657.497 39.8945L657.464 10.4641C657.419 9.17337 658.003 7.48327 661.058 6.90104L670.774 4.42302H671.304C673.536 4.47857 674.326 6.4679 674.331 8.15365V118.735C674.481 125.717 673.659 131.708 662.037 134.136ZM657.473 54.0574C654.229 53.2325 645.173 51.1941 634.976 51.1941C616.247 51.366 608.392 56.4538 608.196 87.5351C608.416 121.664 618.578 123.508 635.144 123.688L635.154 124.403V123.688C645.464 123.688 654.31 121.538 657.465 120.669L657.473 54.0574ZM570.156 135.413H561.466C559.138 135.409 557.383 133.651 557.377 131.324V65.7216C557.22 54.6244 553.904 51.538 541.954 51.3715C528.435 51.3715 514.751 54.7637 510.237 55.9934V131.322C510.233 133.69 508.589 135.41 506.327 135.413H497.461C495.132 135.409 493.374 133.651 493.371 131.324V57.7434C493.242 50.9329 493.882 46.225 503.57 43.0897C512.844 39.8869 530.595 37.1716 542.319 37.1651C564.666 37.1934 574.211 45.2554 574.241 64.1251V131.322C574.238 133.651 572.481 135.41 570.156 135.413ZM373.331 137.186H373.338C378.332 137.088 382.026 136.101 383.472 134.477C383.986 133.901 384.223 133.236 384.159 132.58C384.125 132.04 384.125 132.04 382.755 125.623L382.723 125.469C382.426 123.791 381.228 123.028 380.241 123.028C380.149 123.028 380.059 123.034 380.107 123.04C380.107 123.04 378.853 122.979 377.614 122.979H377.602C374.043 122.866 371.092 122.348 371.031 117.139V8.15478C371.028 6.33731 369.932 4.47308 367.828 4.42302L367.208 4.42825L357.475 6.92259C355.817 7.09236 354.091 8.27097 354.165 10.4617V117.494C354.183 130.169 360.986 137.163 373.323 137.186L373.331 137.186ZM235.87 135.413H166.139C163.811 135.409 162.054 133.651 162.049 131.324V122.812C162.001 120.726 162.67 119.796 163.955 118.204L219.457 53.6776H165.076C162.747 53.6723 160.989 51.9145 160.986 49.5889V43.5598C160.99 41.2331 162.747 39.4755 165.075 39.4711H235.515C237.842 39.4755 239.599 41.2331 239.604 43.5587V52.2476C239.637 53.818 239.093 55.15 237.839 56.5474L182.197 121.204H235.869C238.196 121.211 239.954 122.967 239.958 125.294V131.322C239.954 133.651 238.196 135.41 235.87 135.413Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M22.3275 28.7189C10.2759 28.7189 4.07923 61.6253 4.72458 92.5194C4.7073 92.5151 4.68744 92.5238 4.67017 92.5194C5.31443 119.636 11.3109 145.628 23.1982 145.628C68.8157 145.628 120.79 102.608 120.79 87.1868C120.79 83.3289 116.86 75.9057 111.594 70.0464C111.587 70.0504 111.573 70.0424 111.567 70.0464C111.209 69.6307 110.829 69.216 110.451 68.7949C94.2009 50.6641 60.4881 28.7189 22.3275 28.7189Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M4.71592 92.5183C38.9903 100.567 75.5815 91.5062 111.031 70.3783C111.21 70.2717 111.388 70.165 111.567 70.0573C111.209 69.6416 110.842 69.2226 110.464 68.8014C94.214 50.6706 60.4806 28.7211 22.32 28.7211C10.2683 28.7211 4.07056 61.6242 4.71592 92.5183ZM111.567 70.0573C111.575 70.0538 111.581 70.0495 111.587 70.0454L111.567 70.0573Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
<path
|
||||||
|
d="M4.68018 92.5107C5.32444 119.627 11.323 145.629 23.2103 145.629C68.8279 145.629 120.798 102.621 120.798 87.1999C120.798 83.3419 116.854 75.9046 111.588 70.0453C75.9539 91.4181 39.1461 100.616 4.68018 92.5107Z"
|
||||||
|
fill="#09090b" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
33
apps/client/public/brand-logos/light/amazon.svg
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<svg width="498" height="151" viewBox="0 0 498 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_177_125)">
|
||||||
|
<path
|
||||||
|
d="M309.08 117.21C280.235 138.472 238.425 149.816 202.427 149.816C151.952 149.816 106.512 131.147 72.1348 100.098C69.4339 97.6559 71.8539 94.3284 75.095 96.2298C112.195 117.815 158.067 130.801 205.452 130.801C237.409 130.801 272.564 124.19 304.889 110.469C309.772 108.395 313.856 113.667 309.08 117.21Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M321.072 103.49C317.399 98.7795 296.699 101.264 287.408 102.366C284.578 102.712 284.146 100.249 286.695 98.477C303.182 86.8739 330.234 90.223 333.389 94.1123C336.543 98.0232 332.567 125.14 317.075 138.083C314.698 140.071 312.429 139.012 313.488 136.376C316.967 127.69 324.767 108.222 321.072 103.49Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M288.057 16.5637V5.28474C288.057 3.57776 289.353 2.43258 290.909 2.43258H341.405C343.025 2.43258 344.322 3.59937 344.322 5.28474V14.9432C344.3 16.5637 342.939 18.6813 340.519 22.0304L314.353 59.3894C324.076 59.1517 334.339 60.5994 343.155 65.5691C345.143 66.6926 345.683 68.3348 345.834 69.9554V81.9906C345.834 83.6328 344.019 85.5558 342.118 84.5619C326.582 76.4159 305.947 75.53 288.77 84.6483C287.019 85.599 285.183 83.6976 285.183 82.0554V70.6252C285.183 68.7886 285.204 65.6555 287.041 62.8682L317.356 19.3943H290.974C289.353 19.3943 288.057 18.2491 288.057 16.5637Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M103.854 86.9387H88.4916C87.0223 86.8307 85.8555 85.7287 85.7474 84.3242V5.47922C85.7474 3.90188 87.0655 2.64865 88.7076 2.64865H103.033C104.524 2.71343 105.713 3.85866 105.821 5.28474V15.5914H106.102C109.84 5.63045 116.862 0.984887 126.326 0.984887C135.941 0.984887 141.948 5.63045 146.269 15.5914C149.986 5.63045 158.434 0.984887 167.488 0.984887C173.927 0.984887 180.971 3.64258 185.271 9.60619C190.132 16.2396 189.138 25.8765 189.138 34.3249L189.117 84.0865C189.117 85.6639 187.799 86.9387 186.157 86.9387H170.815C169.281 86.8307 168.05 85.599 168.05 84.0865V42.298C168.05 38.9705 168.352 30.6733 167.617 27.5186C166.472 22.2249 163.037 20.7339 158.586 20.7339C154.869 20.7339 150.98 23.2188 149.403 27.1945C147.825 31.1703 147.976 37.8253 147.976 42.298V84.0865C147.976 85.6639 146.658 86.9387 145.016 86.9387H129.675C128.119 86.8307 126.909 85.599 126.909 84.0865L126.888 42.298C126.888 33.5039 128.335 20.5611 117.424 20.5611C106.382 20.5611 106.815 33.1797 106.815 42.298V84.0865C106.815 85.6639 105.496 86.9387 103.854 86.9387Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M387.796 0.984887C410.591 0.984887 422.929 20.5611 422.929 45.4527C422.929 69.5016 409.295 88.5808 387.796 88.5808C365.411 88.5808 353.224 69.0046 353.224 44.61C353.224 20.0641 365.562 0.984887 387.796 0.984887ZM387.925 17.0823C376.603 17.0823 375.89 32.5099 375.89 42.1252C375.89 51.762 375.739 72.3322 387.796 72.3322C399.701 72.3322 400.263 55.7378 400.263 45.6255C400.263 38.9705 399.982 31.019 397.973 24.7097C396.244 19.2215 392.809 17.0823 387.925 17.0823Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M452.488 86.9387H437.19C435.656 86.8307 434.424 85.599 434.424 84.0865L434.403 5.21991C434.532 3.77223 435.807 2.64865 437.363 2.64865H451.602C452.942 2.71343 454.044 3.62098 454.346 4.8526V16.9095H454.627C458.927 6.12742 464.955 0.984887 475.565 0.984887C482.457 0.984887 489.177 3.46973 493.499 10.276C497.518 16.5853 497.518 27.1945 497.518 34.8219V84.4539C497.345 85.8367 496.07 86.9387 494.557 86.9387H479.151C477.747 86.8307 476.58 85.7935 476.429 84.4539V41.6282C476.429 33.0069 477.423 20.3882 466.814 20.3882C463.076 20.3882 459.64 22.8947 457.933 26.6976C455.772 31.516 455.491 36.3128 455.491 41.6282V84.0865C455.47 85.6639 454.13 86.9387 452.488 86.9387Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M247.802 49.2772V45.9497C236.696 45.9497 224.963 48.3265 224.963 61.4205C224.963 68.0539 228.399 72.5482 234.297 72.5482C238.619 72.5482 242.487 69.8905 244.928 65.5691C247.953 60.2537 247.802 55.2624 247.802 49.2772ZM263.294 86.7226C262.279 87.6301 260.81 87.6949 259.664 87.0899C254.565 82.8549 253.658 80.8887 250.849 76.8481C242.422 85.4478 236.458 88.019 225.525 88.019C212.604 88.019 202.535 80.046 202.535 64.0782C202.535 51.6108 209.298 43.1191 218.913 38.9705C227.254 35.2973 238.9 34.649 247.802 33.6335V31.6456C247.802 27.994 248.083 23.6725 245.944 20.5179C244.064 17.6873 240.477 16.5205 237.323 16.5205C231.467 16.5205 226.238 19.5239 224.963 25.7468C224.704 27.1297 223.688 28.491 222.305 28.5558L207.396 26.9568C206.143 26.676 204.76 25.6604 205.106 23.7374C208.542 5.67368 224.855 0.228627 239.462 0.228627C246.938 0.228627 256.704 2.2165 262.603 7.87761C270.079 14.8568 269.366 24.1695 269.366 34.3033V58.2442C269.366 65.4394 272.348 68.5941 275.157 72.4834C276.151 73.8663 276.367 75.53 275.114 76.5672C271.981 79.1817 266.406 84.0433 263.338 86.7658L263.294 86.7226Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M46.4006 49.2772V45.9497C35.2944 45.9497 23.5617 48.3265 23.5617 61.4205C23.5617 68.0539 26.9972 72.5482 32.896 72.5482C37.2175 72.5482 41.0852 69.8905 43.5268 65.5691C46.5518 60.2537 46.4006 55.2624 46.4006 49.2772ZM61.893 86.7226C60.8775 87.6301 59.4081 87.6949 58.263 87.0899C53.1637 82.8549 52.2561 80.8887 49.4472 76.8481C41.0203 85.4478 35.0567 88.019 24.1234 88.019C11.2023 88.019 1.1333 80.046 1.1333 64.0782C1.1333 51.6108 7.89638 43.1191 17.5116 38.9705C25.852 35.2973 37.4984 34.649 46.4006 33.6335V31.6456C46.4006 27.994 46.6815 23.6725 44.5423 20.5179C42.6625 17.6873 39.0757 16.5205 35.921 16.5205C30.0655 16.5205 24.8365 19.5239 23.5617 25.7468C23.3024 27.1297 22.2868 28.491 20.904 28.5558L5.99494 26.9568C4.74172 26.676 3.35885 25.6604 3.70456 23.7374C7.14012 5.67368 23.4536 0.228627 38.0602 0.228627C45.5363 0.228627 55.3028 2.2165 61.2015 7.87761C68.6777 14.8568 67.9646 24.1695 67.9646 34.3033V58.2442C67.9646 65.4394 70.9464 68.5941 73.7554 72.4834C74.7493 73.8663 74.9654 75.53 73.7122 76.5672C70.5791 79.1817 65.0044 84.0433 61.9362 86.7658L61.893 86.7226Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_177_125">
|
||||||
|
<rect width="496.978" height="150" fill="#fafafa" transform="translate(0.833313 0.0258408)" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.0 KiB |
25
apps/client/public/brand-logos/light/google.svg
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<svg width="452" height="151" viewBox="0 0 452 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_177_106)">
|
||||||
|
<path
|
||||||
|
d="M193.468 78.927C193.468 100.308 176.742 116.063 156.215 116.063C135.689 116.063 118.962 100.308 118.962 78.927C118.962 57.3955 135.689 41.7911 156.215 41.7911C176.742 41.7911 193.468 57.3955 193.468 78.927ZM177.161 78.927C177.161 65.5661 167.467 56.4244 156.215 56.4244C144.964 56.4244 135.27 65.5661 135.27 78.927C135.27 92.1539 144.964 101.429 156.215 101.429C167.467 101.429 177.161 92.1371 177.161 78.927Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M273.835 78.927C273.835 100.308 257.108 116.063 236.582 116.063C216.055 116.063 199.328 100.308 199.328 78.927C199.328 57.4123 216.055 41.7911 236.582 41.7911C257.108 41.7911 273.835 57.3955 273.835 78.927ZM257.527 78.927C257.527 65.5661 247.833 56.4244 236.582 56.4244C225.33 56.4244 215.636 65.5661 215.636 78.927C215.636 92.1539 225.33 101.429 236.582 101.429C247.833 101.429 257.527 92.1371 257.527 78.927Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M350.852 44.0347V110.705C350.852 138.13 334.678 149.331 315.558 149.331C297.559 149.331 286.727 137.293 282.641 127.448L296.839 121.538C299.368 127.582 305.562 134.714 315.541 134.714C327.78 134.714 335.365 127.163 335.365 112.949V107.608H334.796C331.146 112.111 324.114 116.046 315.24 116.046C296.672 116.046 279.661 99.8723 279.661 79.0609C279.661 58.0987 296.672 41.7911 315.24 41.7911C324.097 41.7911 331.129 45.7257 334.796 50.0956H335.365V44.0514H350.852V44.0347ZM336.52 79.0609C336.52 65.9846 327.797 56.4244 316.696 56.4244C305.445 56.4244 296.019 65.9846 296.019 79.0609C296.019 92.0032 305.445 101.429 316.696 101.429C327.797 101.429 336.52 92.0032 336.52 79.0609Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path d="M376.385 4.95665V113.786H360.479V4.95665H376.385Z" fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M438.367 91.1493L451.025 99.5877C446.94 105.632 437.095 116.046 420.084 116.046C398.988 116.046 383.233 99.7384 383.233 78.9102C383.233 56.8263 399.122 41.7744 418.259 41.7744C437.53 41.7744 446.957 57.1109 450.037 65.3986L451.728 69.6179L402.085 90.1782C405.886 97.6288 411.796 101.429 420.084 101.429C428.389 101.429 434.148 97.3442 438.367 91.1493ZM399.407 77.7884L432.591 64.009C430.766 59.3712 425.274 56.1398 418.812 56.1398C410.524 56.1398 398.988 63.4565 399.407 77.7884Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M58.7548 69.2663V53.5112H111.847C112.366 56.257 112.634 59.5051 112.634 63.0211C112.634 74.8417 109.402 89.4583 98.9881 99.8724C88.8586 110.42 75.9163 116.046 58.7715 116.046C26.9935 116.046 0.271729 90.1615 0.271729 58.3834C0.271729 26.6053 26.9935 0.72068 58.7715 0.72068C76.3516 0.72068 88.8753 7.61877 98.2849 16.6097L87.1676 27.7271C80.4202 21.3982 71.2785 16.4758 58.7548 16.4758C35.5491 16.4758 17.3997 35.1776 17.3997 58.3834C17.3997 81.5891 35.5491 100.291 58.7548 100.291C73.8067 100.291 82.3791 94.2467 87.8708 88.755C92.3244 84.3014 95.2544 77.9391 96.4097 69.2495L58.7548 69.2663Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_177_106">
|
||||||
|
<rect width="451.667" height="150" fill="#fafafa" transform="translate(0.166702 0.0258408)" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
15
apps/client/public/brand-logos/light/postman.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
12
apps/client/public/brand-logos/light/twilio.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg width="500" height="151" viewBox="0 0 500 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_177_123)">
|
||||||
|
<path
|
||||||
|
d="M72.4341 56.5258C72.4341 65.0258 65.4341 72.0258 56.9341 72.0258C48.4341 72.0258 41.4341 65.0258 41.4341 56.5258C41.4341 48.0258 48.4341 41.0258 56.9341 41.0258C65.4341 41.0258 72.4341 48.0258 72.4341 56.5258ZM56.9341 78.0258C48.4341 78.0258 41.4341 85.0258 41.4341 93.5258C41.4341 102.026 48.4341 109.026 56.9341 109.026C65.4341 109.026 72.4341 102.026 72.4341 93.5258C72.4341 85.0258 65.4341 78.0258 56.9341 78.0258ZM150.434 75.0258C150.434 116.526 116.934 150.026 75.4341 150.026C33.9341 150.026 0.434082 116.526 0.434082 75.0258C0.434082 33.5258 33.9341 0.0258408 75.4341 0.0258408C116.934 0.0258408 150.434 33.5258 150.434 75.0258ZM130.434 75.0258C130.434 44.5258 105.934 20.0258 75.4341 20.0258C44.9341 20.0258 20.4341 44.5258 20.4341 75.0258C20.4341 105.526 44.9341 130.026 75.4341 130.026C105.934 130.026 130.434 105.526 130.434 75.0258ZM93.9341 78.0258C85.4341 78.0258 78.4341 85.0258 78.4341 93.5258C78.4341 102.026 85.4341 109.026 93.9341 109.026C102.434 109.026 109.434 102.026 109.434 93.5258C109.434 85.0258 102.434 78.0258 93.9341 78.0258ZM93.9341 41.0258C85.4341 41.0258 78.4341 48.0258 78.4341 56.5258C78.4341 65.0258 85.4341 72.0258 93.9341 72.0258C102.434 72.0258 109.434 65.0258 109.434 56.5258C109.434 48.0258 102.434 41.0258 93.9341 41.0258ZM351.934 29.5258C352.434 29.5258 352.934 30.0258 353.434 30.5258V46.5258C353.434 47.5258 352.434 48.0258 351.934 48.0258H325.434C324.434 48.0258 323.934 47.0258 323.934 46.5258V31.0258C323.934 30.0258 324.934 29.5258 325.434 29.5258H351.934ZM351.434 52.0258H300.434C299.934 52.0258 298.934 52.5258 298.934 53.5258L292.434 78.5258L291.934 80.0258L283.934 53.5258C283.934 53.0258 282.934 52.0258 282.434 52.0258H262.434C261.934 52.0258 260.934 52.5258 260.934 53.5258L253.434 78.5258L252.934 80.0258L252.434 78.5258L249.434 66.0258L246.434 53.5258C246.434 53.0258 245.434 52.0258 244.934 52.0258H204.934V30.5258C204.934 30.0258 203.934 29.0258 202.934 29.5258L177.934 37.5258C176.934 37.5258 176.434 38.0258 176.434 39.0258V52.5258H169.934C169.434 52.5258 168.434 53.0258 168.434 54.0258V73.0258C168.434 73.5258 168.934 74.5258 169.934 74.5258H176.434V98.0258C176.434 114.526 185.434 122.026 201.934 122.026C208.934 122.026 215.434 120.526 219.934 118.026V98.0258C219.934 97.0258 218.934 96.5258 218.434 97.0258C215.934 98.0258 213.434 98.5258 211.434 98.5258C206.934 98.5258 204.434 96.5258 204.434 91.5258V74.5258H218.934C219.434 74.5258 220.434 74.0258 220.434 73.0258V57.0258L239.434 120.026C239.434 120.526 240.434 121.526 240.934 121.526H261.934C262.434 121.526 263.434 121.026 263.434 120.026L272.434 92.0258L276.934 106.526L280.934 120.026C280.934 120.526 281.934 121.526 282.434 121.526H303.434C303.934 121.526 304.934 121.026 304.934 120.026L323.934 57.0258V120.026C323.934 120.526 324.434 121.526 325.434 121.526H350.934C351.434 121.526 352.434 121.026 352.434 120.026V53.5258C352.434 53.0258 351.934 52.0258 351.434 52.0258ZM384.934 29.5258H359.434C358.934 29.5258 357.934 30.0258 357.934 31.0258V119.526C357.934 120.026 358.434 121.026 359.434 121.026H384.934C385.434 121.026 386.434 120.526 386.434 119.526V30.5258C386.434 30.0258 385.934 29.5258 384.934 29.5258ZM418.934 29.5258H392.434C391.934 29.5258 390.934 30.0258 390.934 31.0258V46.5258C390.934 47.0258 391.434 48.0258 392.434 48.0258H418.934C419.434 48.0258 420.434 47.5258 420.434 46.5258V30.5258C420.434 30.0258 419.934 29.5258 418.934 29.5258ZM418.434 52.0258H392.934C392.434 52.0258 391.434 52.5258 391.434 53.5258V119.026C391.434 119.526 391.934 120.526 392.934 120.526H418.434C418.934 120.526 419.934 120.026 419.934 119.026V53.5258C419.934 53.0258 419.434 52.0258 418.434 52.0258ZM498.934 86.0258C498.934 105.026 482.934 121.526 460.434 121.526C438.434 121.526 422.434 105.026 422.434 86.0258C422.434 67.0258 438.434 50.5258 460.934 50.5258C482.934 50.5258 498.934 67.0258 498.934 86.0258ZM471.934 86.5258C471.934 79.5258 466.934 74.0258 460.934 74.5258C454.434 74.5258 449.934 80.0258 449.934 86.5258C449.934 93.0258 454.934 98.5258 460.934 98.5258C467.434 98.5258 471.934 93.0258 471.934 86.5258Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_177_123">
|
||||||
|
<rect width="499" height="150" fill="#fafafa" transform="translate(0.434082 0.0258408)" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.3 KiB |
14
apps/client/public/brand-logos/light/zalando.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<svg width="781" height="151" viewBox="0 0 781 151" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M324.116 41.9481C318.271 38.7333 309.581 37.1694 297.548 37.1651C281.083 37.1712 265.8 40.5866 260.015 42.7425C258.709 43.1615 257.312 44.289 257.289 46.2185L257.293 46.644L258.913 54.8322L258.953 54.9846C259.461 56.6518 260.849 57.3995 262.114 57.3995H262.12C262.527 57.3821 262.844 57.3037 263.138 57.2286L265.879 56.5887C273.051 54.793 283.891 52.0811 295.996 52.0811C303.339 52.0811 308.275 52.721 311.584 54.1183C316.953 56.1512 316.987 59.6218 317.041 65.3668V73.9599C316.426 73.949 315.388 73.9381 313.789 73.9381C307.86 73.9381 296.203 74.2134 284.738 76.0515C267.242 78.7385 252.766 82.9262 252.857 107.032C252.864 137.632 281.246 137.69 294.886 137.718H294.888C304.147 137.716 312.69 136.349 318.269 135.195C331.002 132.238 334.253 128.195 334.082 115.543V64.6594C334.101 56.8989 334.124 47.2404 324.116 41.9481ZM317.126 120.552C310.349 122.752 302.934 123.865 295.081 123.865V124.58L295.072 123.865C282.524 123.769 270.27 122.334 270.254 106.329C270.335 93.599 277.127 92.0841 285.724 90.1644L286.321 90.0316C293.969 88.397 313.167 88.0901 317.128 88.0444L317.126 120.552ZM475.964 64.6594V115.543C476.134 128.195 472.883 132.238 460.151 135.195C454.571 136.349 446.028 137.716 436.768 137.718C423.129 137.69 394.745 137.632 394.738 107.032C394.647 82.9262 409.124 78.7385 426.619 76.0515C438.084 74.2134 449.742 73.9381 455.669 73.9381C457.269 73.9381 458.306 73.949 458.922 73.9599V65.3668C458.868 59.6218 458.834 56.1512 453.466 54.1183C450.156 52.721 445.22 52.0811 437.877 52.0811C425.771 52.0811 414.932 54.793 407.76 56.5887L405.02 57.2286C404.725 57.3038 404.408 57.3821 404 57.3995H403.995C402.73 57.3995 401.342 56.6518 400.834 54.9846L400.794 54.8322L399.174 46.644L399.17 46.2185C399.193 44.289 400.59 43.1615 401.896 42.7425C407.679 40.5866 422.964 37.1716 439.429 37.1651C451.462 37.1694 460.152 38.7333 465.997 41.9481C476.004 47.2404 475.982 56.8989 475.964 64.6594ZM459.009 88.0444C455.048 88.0901 435.85 88.397 428.201 90.0316L427.606 90.1644C419.008 92.0841 412.216 93.599 412.136 106.329C412.151 122.334 424.405 123.769 436.953 123.865L436.963 124.58V123.865C444.816 123.865 452.23 122.752 459.007 120.552L459.009 88.0444ZM732.972 37.1651C693.446 37.2183 690.094 63.1891 690.057 87.5318C690.094 111.785 693.446 137.665 732.972 137.718H732.975C772.471 137.665 775.838 111.855 775.893 87.5286C775.852 62.0737 772.501 37.2184 732.972 37.1651ZM732.974 123.51C709 123.405 707.795 111.099 707.632 87.5362C707.795 63.8443 708.998 51.476 732.971 51.3715C756.951 51.476 758.152 63.8443 758.315 87.5253C758.152 111.099 756.947 123.405 732.974 123.51ZM662.037 134.136L661.004 134.398C655.477 135.799 647.904 137.718 634.631 137.718H634.602C598.02 137.665 590.841 119.212 590.801 87.3544C590.845 50.8154 602.428 37.214 633.536 37.1651C644.511 37.1651 652.072 38.5842 657.497 39.8945L657.464 10.4641C657.419 9.17337 658.003 7.48327 661.058 6.90104L670.774 4.42302H671.304C673.536 4.47857 674.326 6.4679 674.331 8.15365V118.735C674.481 125.717 673.659 131.708 662.037 134.136ZM657.473 54.0574C654.229 53.2325 645.173 51.1941 634.976 51.1941C616.247 51.366 608.392 56.4538 608.196 87.5351C608.416 121.664 618.578 123.508 635.144 123.688L635.154 124.403V123.688C645.464 123.688 654.31 121.538 657.465 120.669L657.473 54.0574ZM570.156 135.413H561.466C559.138 135.409 557.383 133.651 557.377 131.324V65.7216C557.22 54.6244 553.904 51.538 541.954 51.3715C528.435 51.3715 514.751 54.7637 510.237 55.9934V131.322C510.233 133.69 508.589 135.41 506.327 135.413H497.461C495.132 135.409 493.374 133.651 493.371 131.324V57.7434C493.242 50.9329 493.882 46.225 503.57 43.0897C512.844 39.8869 530.595 37.1716 542.319 37.1651C564.666 37.1934 574.211 45.2554 574.241 64.1251V131.322C574.238 133.651 572.481 135.41 570.156 135.413ZM373.331 137.186H373.338C378.332 137.088 382.026 136.101 383.472 134.477C383.986 133.901 384.223 133.236 384.159 132.58C384.125 132.04 384.125 132.04 382.755 125.623L382.723 125.469C382.426 123.791 381.228 123.028 380.241 123.028C380.149 123.028 380.059 123.034 380.107 123.04C380.107 123.04 378.853 122.979 377.614 122.979H377.602C374.043 122.866 371.092 122.348 371.031 117.139V8.15478C371.028 6.33731 369.932 4.47308 367.828 4.42302L367.208 4.42825L357.475 6.92259C355.817 7.09236 354.091 8.27097 354.165 10.4617V117.494C354.183 130.169 360.986 137.163 373.323 137.186L373.331 137.186ZM235.87 135.413H166.139C163.811 135.409 162.054 133.651 162.049 131.324V122.812C162.001 120.726 162.67 119.796 163.955 118.204L219.457 53.6776H165.076C162.747 53.6723 160.989 51.9145 160.986 49.5889V43.5598C160.99 41.2331 162.747 39.4755 165.075 39.4711H235.515C237.842 39.4755 239.599 41.2331 239.604 43.5587V52.2476C239.637 53.818 239.093 55.15 237.839 56.5474L182.197 121.204H235.869C238.196 121.211 239.954 122.967 239.958 125.294V131.322C239.954 133.651 238.196 135.41 235.87 135.413Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M22.3275 28.7189C10.2759 28.7189 4.07923 61.6253 4.72458 92.5194C4.7073 92.5151 4.68744 92.5238 4.67017 92.5194C5.31443 119.636 11.3109 145.628 23.1982 145.628C68.8157 145.628 120.79 102.608 120.79 87.1868C120.79 83.3289 116.86 75.9057 111.594 70.0464C111.587 70.0504 111.573 70.0424 111.567 70.0464C111.209 69.6307 110.829 69.216 110.451 68.7949C94.2009 50.6641 60.4881 28.7189 22.3275 28.7189Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M4.71592 92.5183C38.9903 100.567 75.5815 91.5062 111.031 70.3783C111.21 70.2717 111.388 70.165 111.567 70.0573C111.209 69.6416 110.842 69.2226 110.464 68.8014C94.214 50.6706 60.4806 28.7211 22.32 28.7211C10.2683 28.7211 4.07056 61.6242 4.71592 92.5183ZM111.567 70.0573C111.575 70.0538 111.581 70.0495 111.587 70.0454L111.567 70.0573Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
<path
|
||||||
|
d="M4.68018 92.5107C5.32444 119.627 11.323 145.629 23.2103 145.629C68.8279 145.629 120.798 102.621 120.798 87.1999C120.798 83.3419 116.854 75.9046 111.588 70.0453C75.9539 91.4181 39.1461 100.616 4.68018 92.5107Z"
|
||||||
|
fill="#fafafa" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/client/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 68 KiB |
8
apps/client/public/icon/dark.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z"
|
||||||
|
fill="#09090B" />
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2518 137.634 61.6569 137.614C75.0665 136.968 85.1471 135.549 96.3849 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7678 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z"
|
||||||
|
fill="#09090B" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
8
apps/client/public/icon/light.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M173.611 166.311L132.877 219.804H173.524L193.973 191.813L213.183 219.804H256L215.673 165.707L215.15 165.046L207.461 155.332L195.329 140.004L195.258 139.915L193.813 138.089L193.923 138.001L176.286 112.861H134.061L173.611 166.311ZM199.89 133.554L214.959 112.861H254.619L219.874 158.8L199.89 133.554Z"
|
||||||
|
fill="#FAFAFA" />
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
d="M0 36.1959V174.314H39.0678V137.614H60.3938L60.4323 137.671C60.8436 137.653 61.2517 137.634 61.6567 137.614C75.0665 136.968 85.1471 135.549 96.385 131.385C96.7596 131.246 97.1355 131.104 97.5128 130.959L97.4591 130.881C105.816 126.86 112.331 121.344 117.006 114.331C122.005 106.702 124.504 97.6915 124.504 87.2997C124.504 76.7764 122.005 67.7 117.006 60.0706C112.007 52.3097 104.904 46.3903 95.6964 42.3125C86.62 38.2347 75.7679 36.1959 63.1399 36.1959H0ZM102.156 137.725L64.8705 144.175L85.4361 174.314H127.266L102.156 137.725ZM39.0678 107.426H60.7721C68.9277 107.426 74.9786 105.65 78.9248 102.098C83.0026 98.5465 85.0415 93.6137 85.0415 87.2997C85.0415 80.8542 83.0026 75.8556 78.9248 72.304C74.9786 68.7523 68.9277 66.9765 60.7721 66.9765H39.0678V107.426Z"
|
||||||
|
fill="#FAFAFA" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
18
apps/client/public/logo/dark.svg
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
18
apps/client/public/logo/light.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/client/public/screenshots/builder.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
14
apps/client/public/scripts/initialize-theme.js
Normal file
@ -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
|
||||||
|
}
|
||||||
|
})();
|
||||||
0
apps/client/src/assets/.gitkeep
Normal file
121
apps/client/src/components/ai-actions.tsx
Normal file
@ -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<Action | false>(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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative mt-4 rounded bg-secondary-accent/50 p-3 outline outline-secondary-accent",
|
||||||
|
"flex flex-wrap items-center justify-center gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="absolute -left-5 z-10">
|
||||||
|
<Badge
|
||||||
|
outline
|
||||||
|
variant="primary"
|
||||||
|
className="-rotate-90 bg-background px-2 text-[10px] leading-[10px]"
|
||||||
|
>
|
||||||
|
<MagicWand size={10} className="mr-1" />
|
||||||
|
AI
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" disabled={!!loading} onClick={() => onClick("improve")}>
|
||||||
|
{loading === "improve" ? <CircleNotch className="animate-spin" /> : <PenNib />}
|
||||||
|
<span className="ml-2 text-xs">Improve Writing</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button size="sm" variant="outline" disabled={!!loading} onClick={() => onClick("fix")}>
|
||||||
|
{loading === "fix" ? <CircleNotch className="animate-spin" /> : <Exam />}
|
||||||
|
<span className="ml-2 text-xs">Fix Spelling & Grammar</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size="sm" variant="outline" disabled={!!loading}>
|
||||||
|
{loading === "tone" ? <CircleNotch className="animate-spin" /> : <ChatTeardropText />}
|
||||||
|
<span className="mx-2 text-xs">Change Tone</span>
|
||||||
|
<CaretDown />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => onClick("tone", "casual")}>
|
||||||
|
<span role="img" aria-label="Casual">
|
||||||
|
🙂
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">Casual</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onClick("tone", "professional")}>
|
||||||
|
<span role="img" aria-label="Professional">
|
||||||
|
💼
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">Professional</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onClick("tone", "confident")}>
|
||||||
|
<span role="img" aria-label="Confident">
|
||||||
|
😎
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">Confident</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onClick("tone", "friendly")}>
|
||||||
|
<span role="img" aria-label="Friendly">
|
||||||
|
😊
|
||||||
|
</span>
|
||||||
|
<span className="ml-2">Friendly</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
apps/client/src/components/copyright.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from "@reactive-resume/utils";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Copyright = ({ className }: Props) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"prose prose-sm prose-zinc flex max-w-none flex-col gap-y-1 text-xs opacity-40 dark:prose-invert",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Licensed under{" "}
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
|
||||||
|
>
|
||||||
|
MIT
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
<span>By the community, for the community.</span>
|
||||||
|
<span>
|
||||||
|
A passion project by{" "}
|
||||||
|
<a target="_blank" rel="noopener noreferrer nofollow" href="https://www.amruthpillai.com/">
|
||||||
|
Amruth Pillai
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="mt-2 font-bold">Reactive Resume v{appVersion}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
32
apps/client/src/components/icon.tsx
Normal file
@ -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 (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt="Reactive Resume"
|
||||||
|
className={cn("rounded-sm", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
32
apps/client/src/components/logo.tsx
Normal file
@ -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 (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt="Reactive Resume"
|
||||||
|
className={cn("rounded-sm", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
apps/client/src/components/theme-switch.tsx
Normal file
@ -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 (
|
||||||
|
<Button size="icon" variant="ghost" onClick={toggleTheme}>
|
||||||
|
<div className="cursor-pointer overflow-hidden" style={{ width: size, height: size }}>
|
||||||
|
<motion.div animate={theme} variants={variants} className="flex">
|
||||||
|
<Sun size={size} className="shrink-0" />
|
||||||
|
<CloudSun size={size} className="shrink-0" />
|
||||||
|
<Moon size={size} className="shrink-0" />
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
apps/client/src/components/user-avatar.tsx
Normal file
@ -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 = (
|
||||||
|
<div
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
className="flex items-center justify-center rounded-full bg-secondary text-center text-[10px] font-semibold text-secondary-foreground"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
picture = (
|
||||||
|
<img
|
||||||
|
alt={user.name}
|
||||||
|
src={user.picture}
|
||||||
|
className="rounded-full"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={className}>{picture}</div>;
|
||||||
|
};
|
||||||
38
apps/client/src/components/user-options.tsx
Normal file
@ -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 (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent side="top" align="start" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={() => navigate("/dashboard/settings")}>
|
||||||
|
Settings
|
||||||
|
<KeyboardShortcut>⇧S</KeyboardShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout()}>
|
||||||
|
Logout
|
||||||
|
<KeyboardShortcut>⇧Q</KeyboardShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
20
apps/client/src/constants/colors.ts
Normal file
@ -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
|
||||||
|
];
|
||||||
11
apps/client/src/constants/parallax-tilt.ts
Normal file
@ -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",
|
||||||
|
};
|
||||||
7
apps/client/src/constants/query-keys.ts
Normal file
@ -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"];
|
||||||
177
apps/client/src/hooks/use-toast.ts
Normal file
@ -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<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["DISMISS_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType["REMOVE_TOAST"];
|
||||||
|
toastId?: ToasterToast["id"];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
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<ToasterToast, "id">;
|
||||||
|
|
||||||
|
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<State>(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 };
|
||||||
53
apps/client/src/libs/axios.ts
Normal file
@ -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);
|
||||||
6
apps/client/src/libs/dayjs.ts
Normal file
@ -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);
|
||||||
16
apps/client/src/libs/query-client.ts
Normal file
@ -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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
13
apps/client/src/main.tsx
Normal file
@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
20
apps/client/src/pages/auth/_components/social-auth.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { GithubLogo, GoogleLogo } from "@phosphor-icons/react";
|
||||||
|
import { Button } from "@reactive-resume/ui";
|
||||||
|
|
||||||
|
export const SocialAuth = () => (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Button asChild size="lg" className="w-full !bg-[#222] !text-white hover:!bg-[#222]/80">
|
||||||
|
<a href="/api/auth/github">
|
||||||
|
<GoogleLogo className="mr-3 h-4 w-4" />
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild size="lg" className="w-full !bg-[#4285F4] !text-white hover:!bg-[#4285F4]/80">
|
||||||
|
<a href="/api/auth/google">
|
||||||
|
<GithubLogo className="mr-3 h-4 w-4" />
|
||||||
|
Google
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
102
apps/client/src/pages/auth/backup-otp/page.tsx
Normal file
@ -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<typeof twoFactorBackupSchema>;
|
||||||
|
|
||||||
|
export const BackupOtpPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { backupOtp, loading } = useBackupOtp();
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
usePasswordToggle(formRef);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
title: "An error occurred while trying to sign in",
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Use your backup code</h2>
|
||||||
|
<h6 className="leading-relaxed opacity-60">
|
||||||
|
Enter one of the 10 backup codes you saved when you enabled two-factor authentication.
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="code"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Backup Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
pattern="[a-z0-9]{10}"
|
||||||
|
placeholder="a1b2c3d4e5"
|
||||||
|
title="may contain lowercase letters or numbers, and must be exactly 10 characters."
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
105
apps/client/src/pages/auth/forgot-password/page.tsx
Normal file
@ -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<typeof forgotPasswordSchema>;
|
||||||
|
|
||||||
|
export const ForgotPasswordPage = () => {
|
||||||
|
const [submitted, setSubmitted] = useState<boolean>(false);
|
||||||
|
const { forgotPassword, loading } = useForgotPassword();
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
title: "An error occurred while trying to send your password recovery email",
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">You've got mail!</h2>
|
||||||
|
<Alert variant="success">
|
||||||
|
<AlertDescription className="pt-0">
|
||||||
|
A password reset link should have been sent to your inbox, if an account existed with
|
||||||
|
the email you provided.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Forgot your password?</h2>
|
||||||
|
<h6 className="leading-relaxed opacity-75">
|
||||||
|
Enter your email address and we will send you a link to reset your password if the account
|
||||||
|
exists.
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form className="flex flex-col gap-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
name="email"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john.doe@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||||
|
Send Email
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
apps/client/src/pages/auth/layout.tsx
Normal file
@ -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 (
|
||||||
|
<div className="flex h-screen w-screen">
|
||||||
|
<div className="flex w-full flex-col justify-center gap-y-8 px-12 sm:mx-auto sm:basis-[420px] sm:px-0 lg:basis-[480px] lg:px-12">
|
||||||
|
<Link to="/" className="h-24 w-24">
|
||||||
|
<Logo className="-ml-3" size={96} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
|
||||||
|
{isAuthRoute && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<hr className="flex-1" />
|
||||||
|
<span className="text-xs font-medium">or continue with</span>
|
||||||
|
<hr className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SocialAuth />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative hidden lg:block lg:flex-1">
|
||||||
|
<img
|
||||||
|
width={1920}
|
||||||
|
height={1080}
|
||||||
|
alt="Open books on a table"
|
||||||
|
className="h-screen w-full object-cover object-center"
|
||||||
|
src="/backgrounds/patrick-tomasso-Oaqk7qqNh_c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="absolute bottom-5 right-5 z-10 bg-primary/30 px-4 py-2 text-xs font-medium text-primary-foreground backdrop-blur-sm">
|
||||||
|
<a
|
||||||
|
href="https://unsplash.com/photos/Oaqk7qqNh_c"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
>
|
||||||
|
Photograph by Patrick Tomasso
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
apps/client/src/pages/auth/login/page.tsx
Normal file
@ -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<typeof loginSchema>;
|
||||||
|
|
||||||
|
export const LoginPage = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { login, loading } = useLogin();
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
usePasswordToggle(formRef);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
title: "An error occurred while trying to sign in",
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Sign in to your account</h2>
|
||||||
|
<h6>
|
||||||
|
<span className="opacity-75">Don't have an account?</span>
|
||||||
|
<Button asChild variant="link" className="px-1.5">
|
||||||
|
<Link to="/auth/register">
|
||||||
|
Create one now <ArrowRight className="ml-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="identifier"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john.doe@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>You can also enter your username.</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
|
||||||
|
temporarily.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-x-4">
|
||||||
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild variant="link" className="px-4">
|
||||||
|
<Link to="/auth/forgot-password">Forgot Password?</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
155
apps/client/src/pages/auth/register/page.tsx
Normal file
@ -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<typeof registerSchema>;
|
||||||
|
|
||||||
|
export const RegisterPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { register, loading } = useRegister();
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
usePasswordToggle(formRef);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
title: "An error occurred while trying to sign up",
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Create a new account</h2>
|
||||||
|
<h6>
|
||||||
|
<span className="opacity-75">Already have an account?</span>
|
||||||
|
<Button asChild variant="link" className="px-1.5">
|
||||||
|
<Link to="/auth/login">
|
||||||
|
Sign in now <ArrowRight className="ml-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="John Doe" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="username"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john.doe" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="email"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="john.doe@example.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
|
||||||
|
temporarily.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button disabled={loading} className="mt-4 w-full">
|
||||||
|
Sign up
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
110
apps/client/src/pages/auth/reset-password/page.tsx
Normal file
@ -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<typeof resetPasswordSchema>;
|
||||||
|
|
||||||
|
export const ResetPasswordPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get("token") || "";
|
||||||
|
|
||||||
|
const { resetPassword, loading } = useResetPassword();
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
usePasswordToggle(formRef);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Reset your password</h2>
|
||||||
|
<h6 className="leading-relaxed opacity-75">
|
||||||
|
Enter a new password below, and make sure it's secure.
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Hold <code className="text-xs font-bold">Ctrl</code> to display your password
|
||||||
|
temporarily.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||||
|
Update Password
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
apps/client/src/pages/auth/verify-email/page.tsx
Normal file
@ -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: <SealCheck size={16} weight="bold" />,
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
title: "An error occurred while trying to verify your email address",
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
handleVerifyEmail(token);
|
||||||
|
}, [token, navigate, verifyEmail]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Verify your email address</h2>
|
||||||
|
<p className="leading-relaxed opacity-75">
|
||||||
|
You should have received an email from <strong>Reactive Resume</strong> with a link to
|
||||||
|
verify your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert variant="info">
|
||||||
|
<Info size={18} />
|
||||||
|
|
||||||
|
<AlertTitle>Please note that this step is completely optional.</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
We verify your email address only to ensure that we can send you a password reset link in
|
||||||
|
case you forget your password.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Button asChild disabled={loading}>
|
||||||
|
<Link to="/dashboard">
|
||||||
|
Continue to Dashboard
|
||||||
|
<ArrowRight className="ml-2" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
104
apps/client/src/pages/auth/verify-otp/page.tsx
Normal file
@ -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<typeof twoFactorSchema>;
|
||||||
|
|
||||||
|
export const VerifyOtpPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { verifyOtp, loading } = useVerifyOtp();
|
||||||
|
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
usePasswordToggle(formRef);
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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: <Warning size={16} weight="bold" />,
|
||||||
|
title: "An error occurred while trying to sign in",
|
||||||
|
description: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">Two Step Verification</h2>
|
||||||
|
<h6>
|
||||||
|
<span className="opacity-75">
|
||||||
|
Enter the one-time password provided by your authenticator app below.
|
||||||
|
</span>
|
||||||
|
<Button asChild variant="link" className="px-1.5">
|
||||||
|
<Link to="/auth/backup-otp">
|
||||||
|
Lost your device? <ArrowRight className="ml-1" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="code"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>One-Time Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="123456" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="mt-4 w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
apps/client/src/pages/builder/_components/header.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
style={{ left: `${leftPanelSize}%`, right: `${rightPanelSize}%` }}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 top-0 z-[100] h-16 bg-secondary-accent/50 backdrop-blur-lg lg:z-20",
|
||||||
|
!isDragging && "transition-[left,right]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-full items-center justify-between px-4">
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => onToggle("left")}>
|
||||||
|
<SidebarSimple />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-x-1">
|
||||||
|
<Button asChild size="icon" variant="ghost">
|
||||||
|
<Link to="/dashboard/resumes">
|
||||||
|
<HouseSimple />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<span className="mr-2 text-xs opacity-40">{"/"}</span>
|
||||||
|
|
||||||
|
<h1 className="font-medium">{title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => onToggle("right")}>
|
||||||
|
<SidebarSimple className="-scale-x-100" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
167
apps/client/src/pages/builder/_components/toolbar.tsx
Normal file
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, bottom: -64 }}
|
||||||
|
whileHover={{ opacity: 1, bottom: 0 }}
|
||||||
|
animate={{ opacity: 0.3, bottom: -28 }}
|
||||||
|
className="fixed inset-x-0 mx-auto pb-4 pt-6 text-center"
|
||||||
|
>
|
||||||
|
<div className="inline-flex items-center justify-center rounded-full bg-background px-4 shadow-xl">
|
||||||
|
{/* Undo */}
|
||||||
|
<Tooltip content="Undo">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => undo()}>
|
||||||
|
<ArrowCounterClockwise />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Redo */}
|
||||||
|
<Tooltip content="Redo">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-none" onClick={() => redo()}>
|
||||||
|
<ArrowClockwise />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-9" />
|
||||||
|
|
||||||
|
{/* Zoom In */}
|
||||||
|
<Tooltip content="Zoom In">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-none"
|
||||||
|
onClick={() => transformRef?.zoomIn(0.2)}
|
||||||
|
>
|
||||||
|
<MagnifyingGlassPlus />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Zoom Out */}
|
||||||
|
<Tooltip content="Zoom Out">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-none"
|
||||||
|
onClick={() => transformRef?.zoomOut(0.2)}
|
||||||
|
>
|
||||||
|
<MagnifyingGlassMinus />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="Reset Zoom">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-none"
|
||||||
|
onClick={() => transformRef?.resetTransform()}
|
||||||
|
>
|
||||||
|
<ClockClockwise />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Center Artboard */}
|
||||||
|
<Tooltip content="Center Artboard">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-none"
|
||||||
|
onClick={() => transformRef?.centerView()}
|
||||||
|
>
|
||||||
|
<CubeFocus />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-9" />
|
||||||
|
|
||||||
|
{/* Toggle Page Break Line */}
|
||||||
|
<Tooltip content="Toggle Page Break Line">
|
||||||
|
<Toggle
|
||||||
|
className="rounded-none"
|
||||||
|
pressed={pageOptions.breakLine}
|
||||||
|
onPressedChange={(pressed) => {
|
||||||
|
setValue("metadata.page.options.breakLine", pressed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LineSegment />
|
||||||
|
</Toggle>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Toggle Page Numbers */}
|
||||||
|
<Tooltip content="Toggle Page Numbers">
|
||||||
|
<Toggle
|
||||||
|
className="rounded-none"
|
||||||
|
pressed={pageOptions.pageNumbers}
|
||||||
|
onPressedChange={(pressed) => {
|
||||||
|
setValue("metadata.page.options.pageNumbers", pressed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Hash />
|
||||||
|
</Toggle>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-9" />
|
||||||
|
|
||||||
|
{/* Copy Link to Resume */}
|
||||||
|
<Tooltip content="Copy Link to Resume">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-none" disabled={!isPublic}>
|
||||||
|
<LinkSimple />
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Download PDF */}
|
||||||
|
<Tooltip content="Download PDF">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-none"
|
||||||
|
onClick={onPrint}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <CircleNotch className="animate-spin" /> : <DownloadSimple />}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
apps/client/src/pages/builder/layout.tsx
Normal file
@ -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 = () => (
|
||||||
|
<>
|
||||||
|
<BuilderHeader />
|
||||||
|
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BuilderToolbar />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
|
<PanelGroup direction="horizontal">
|
||||||
|
<Panel
|
||||||
|
collapsible
|
||||||
|
minSize={20}
|
||||||
|
maxSize={35}
|
||||||
|
defaultSize={28}
|
||||||
|
ref={panel.left.setRef}
|
||||||
|
className={cn("z-10 bg-background", !panel.left.isDragging && "transition-[flex]")}
|
||||||
|
>
|
||||||
|
<LeftSidebar />
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle
|
||||||
|
isDragging={panel.left.isDragging}
|
||||||
|
onDragging={panel.left.setDragging}
|
||||||
|
/>
|
||||||
|
<Panel>
|
||||||
|
<OutletSlot />
|
||||||
|
</Panel>
|
||||||
|
<PanelResizeHandle
|
||||||
|
isDragging={panel.right.isDragging}
|
||||||
|
onDragging={panel.right.setDragging}
|
||||||
|
/>
|
||||||
|
<Panel
|
||||||
|
collapsible
|
||||||
|
minSize={20}
|
||||||
|
maxSize={35}
|
||||||
|
defaultSize={28}
|
||||||
|
ref={panel.right.setRef}
|
||||||
|
className={cn("z-10 bg-background", !panel.right.isDragging && "transition-[flex]")}
|
||||||
|
>
|
||||||
|
<RightSidebar />
|
||||||
|
</Panel>
|
||||||
|
</PanelGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Sheet open={sheet.left.open} onOpenChange={sheet.left.setOpen}>
|
||||||
|
<SheetContent
|
||||||
|
side="left"
|
||||||
|
className="p-0 sm:max-w-xl"
|
||||||
|
showClose={false}
|
||||||
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
|
>
|
||||||
|
<LeftSidebar />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<OutletSlot />
|
||||||
|
|
||||||
|
<Sheet open={sheet.right.open} onOpenChange={sheet.right.setOpen}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="p-0 sm:max-w-xl"
|
||||||
|
showClose={false}
|
||||||
|
onOpenAutoFocus={onOpenAutoFocus}
|
||||||
|
>
|
||||||
|
<RightSidebar />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
98
apps/client/src/pages/builder/page.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>{title} - Reactive Resume</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<TransformWrapper
|
||||||
|
centerOnInit
|
||||||
|
minScale={0.4}
|
||||||
|
initialScale={1}
|
||||||
|
limitToBounds={false}
|
||||||
|
velocityAnimation={{ disabled: true }}
|
||||||
|
ref={(ref: ReactZoomPanPinchRef) => setTransformRef(ref)}
|
||||||
|
>
|
||||||
|
<TransformComponent wrapperClass="!w-screen !h-screen">
|
||||||
|
<PageGrid $offset={32}>
|
||||||
|
<AnimatePresence presenceAffectsLayout>
|
||||||
|
{resume.metadata.layout.map((columns, pageIndex) => (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
key={pageIndex}
|
||||||
|
initial={{ opacity: 0, x: -100 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -100 }}
|
||||||
|
>
|
||||||
|
<Artboard resume={resume}>
|
||||||
|
<PageWrapper>
|
||||||
|
{showPageNumbers && <PageNumber>Page {pageIndex + 1}</PageNumber>}
|
||||||
|
|
||||||
|
<Rhyhorn isFirstPage={pageIndex === 0} columns={columns as SectionKey[][]} />
|
||||||
|
|
||||||
|
{showBreakLine && <PageBreakLine $pageHeight={pageHeight} />}
|
||||||
|
</PageWrapper>
|
||||||
|
</Artboard>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</PageGrid>
|
||||||
|
</TransformComponent>
|
||||||
|
</TransformWrapper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const builderLoader: LoaderFunction<ResumeDto> = 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");
|
||||||
|
}
|
||||||
|
};
|
||||||
112
apps/client/src/pages/builder/sidebars/left/dialogs/awards.tsx
Normal file
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const AwardsDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultAward,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="awards" form={form} defaultValues={defaultAward}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="title"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Title</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="3rd Runner Up" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="awarder"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Awarder</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="TechCrunch Disrupt SF" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Aug 2019" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://techcrunch.com/events/disrupt-sf-2019" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const CertificationsDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultCertification,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="certifications" form={form} defaultValues={defaultCertification}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Web Developer Bootcamp" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="issuer"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Issuer</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Udemy" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Aug 2019" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://udemy.com/certificate/UC-..." />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const CustomSectionDialog = () => {
|
||||||
|
const { payload } = useDialog<CustomSection>("custom");
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultCustomSection,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues>
|
||||||
|
form={form}
|
||||||
|
id={payload.id as DialogName}
|
||||||
|
defaultValues={defaultCustomSection}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="description"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="level"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Level</FormLabel>
|
||||||
|
<FormControl className="py-2">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Slider
|
||||||
|
{...field}
|
||||||
|
min={0}
|
||||||
|
max={5}
|
||||||
|
value={[field.value]}
|
||||||
|
orientation="horizontal"
|
||||||
|
onValueChange={(value) => field.onChange(value[0])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="text-base font-bold">{field.value}</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="keywords"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keywords</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<BadgeInput {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
You can add multiple keywords by separating them with a comma.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{field.value.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
key={item}
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(field.value.filter((v) => item !== v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{item}</span>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const EducationDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultEducation,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="education" form={form} defaultValues={defaultEducation}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="institution"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Institution</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Carnegie Mellon University" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="studyType"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Type of Study</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Bachelor's Degree" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="area"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Area of Study</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Computer Science" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="score"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Score</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="9.2 GPA" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Aug 2006 - Oct 2012" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://www.cmu.edu/" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const ExperienceDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultExperience,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="experience" form={form} defaultValues={defaultExperience}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="company"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Company</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Alphabet Inc." />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="position"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Position</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Chief Executive Officer" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Dec 2019 - Present" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="location"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Location</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="New York, NY" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://www.abc.xyz/" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const InterestsDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultInterest,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="interests" form={form} defaultValues={defaultInterest}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Video Games" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="keywords"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keywords</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
You can add multiple keywords by separating them with a comma.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{field.value.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
key={item}
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(field.value.filter((v) => item !== v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{item}</span>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const LanguagesDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultLanguage,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="languages" form={form} defaultValues={defaultLanguage}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="German" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="fluency"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Fluency</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Native Speaker" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="fluencyLevel"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Fluency (CEFR)</FormLabel>
|
||||||
|
<FormControl className="py-2">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Slider
|
||||||
|
{...field}
|
||||||
|
min={1}
|
||||||
|
max={6}
|
||||||
|
value={[field.value]}
|
||||||
|
onValueChange={(value) => field.onChange(value[0])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="text-base font-bold">{getCEFRLevel(field.value)}</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
112
apps/client/src/pages/builder/sidebars/left/dialogs/profiles.tsx
Normal file
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const ProfilesDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultProfile,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="profiles" form={form} defaultValues={defaultProfile}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="network"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Network</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="LinkedIn" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="username"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Username</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="johndoe" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://linkedin.com/in/johndoe" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="icon"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel htmlFor="iconSlug">Icon</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Avatar className="h-8 w-8 bg-white">
|
||||||
|
{field.value && (
|
||||||
|
<AvatarImage
|
||||||
|
className="p-1.5"
|
||||||
|
src={`https://cdn.simpleicons.org/${field.value}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Avatar>
|
||||||
|
<Input {...field} id="iconSlug" placeholder="linkedin" />
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription className="ml-10">
|
||||||
|
Powered by{" "}
|
||||||
|
<a
|
||||||
|
href="https://simpleicons.org/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
className="font-medium"
|
||||||
|
>
|
||||||
|
Simple Icons
|
||||||
|
</a>
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
160
apps/client/src/pages/builder/sidebars/left/dialogs/projects.tsx
Normal file
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const ProjectsDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultProject,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="projects" form={form} defaultValues={defaultProject}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Reactive Resume" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="description"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Open Source Resume Builder" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Sep 2018 - Present" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://rxresu.me" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="keywords"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keywords</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<BadgeInput {...field} placeholder="FIFA 23, Call of Duty, etc." />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
You can add multiple keywords by separating them with a comma.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{field.value.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
key={item}
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(field.value.filter((v) => item !== v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{item}</span>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const PublicationsDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultPublication,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="publications" form={form} defaultValues={defaultPublication}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="The Great Gatsby" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="publisher"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Publisher</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Charles Scribner's Sons" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Release Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="April 10, 1925" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://books.google.com/..." />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const ReferencesDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultReference,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="references" form={form} defaultValues={defaultReference}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Cosmo Kramer" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="description"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Neighbour" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://linkedin.com/in/cosmo.kramer" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
133
apps/client/src/pages/builder/sidebars/left/dialogs/skills.tsx
Normal file
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const SkillsDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultSkill,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="skills" form={form} defaultValues={defaultSkill}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Content Management" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="description"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Advanced" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="level"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Level</FormLabel>
|
||||||
|
<FormControl className="py-2">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Slider
|
||||||
|
{...field}
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={[field.value]}
|
||||||
|
orientation="horizontal"
|
||||||
|
onValueChange={(value) => field.onChange(value[0])}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className="text-base font-bold">{field.value}</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="keywords"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="col-span-2 space-y-3">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keywords</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<BadgeInput {...field} placeholder="WordPress, Joomla, Webflow etc." />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
You can add multiple keywords by separating them with a comma.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{field.value.map((item, index) => (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
key={item}
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0, transition: { delay: index * 0.1 } }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(field.value.filter((v) => item !== v));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mr-1">{item}</span>
|
||||||
|
<X size={12} weight="bold" />
|
||||||
|
</Badge>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export const VolunteerDialog = () => {
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
defaultValues: defaultVolunteer,
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionDialog<FormValues> id="volunteer" form={form} defaultValues={defaultVolunteer}>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
name="organization"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Organization</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Amnesty International" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="position"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Position</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Recruiter" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="date"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Date</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Dec 2016 - Aug 2017" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="location"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1">
|
||||||
|
<FormLabel>Location</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="New York, NY" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="url"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Website</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<URLInput {...field} placeholder="https://www.amnesty.org/" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="summary"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-1 sm:col-span-2">
|
||||||
|
<FormLabel>Summary</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<RichInput
|
||||||
|
{...field}
|
||||||
|
content={field.value}
|
||||||
|
onChange={(value) => field.onChange(value)}
|
||||||
|
footer={(editor) => (
|
||||||
|
<AiActions value={editor.getText()} onChange={editor.commands.setContent} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SectionDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
193
apps/client/src/pages/builder/sidebars/left/index.tsx
Normal file
@ -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<HTMLDivElement | null>(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 (
|
||||||
|
<div className="flex bg-secondary-accent/30 pt-16 lg:pt-0">
|
||||||
|
<div className="hidden basis-12 flex-col items-center justify-between bg-secondary-accent/30 py-4 sm:flex">
|
||||||
|
<Button asChild size="icon" variant="ghost" className="h-8 w-8 rounded-full">
|
||||||
|
<Link to="/dashboard">
|
||||||
|
<Icon size={14} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-2">
|
||||||
|
<SectionIcon id="basics" name="Basics" onClick={() => scrollIntoView("#basics")} />
|
||||||
|
<SectionIcon id="summary" onClick={() => scrollIntoView("#summary")} />
|
||||||
|
<SectionIcon id="profiles" onClick={() => scrollIntoView("#profiles")} />
|
||||||
|
<SectionIcon id="experience" onClick={() => scrollIntoView("#experience")} />
|
||||||
|
<SectionIcon id="education" onClick={() => scrollIntoView("#education")} />
|
||||||
|
<SectionIcon id="awards" onClick={() => scrollIntoView("#awards")} />
|
||||||
|
<SectionIcon id="certifications" onClick={() => scrollIntoView("#certifications")} />
|
||||||
|
<SectionIcon id="interests" onClick={() => scrollIntoView("#interests")} />
|
||||||
|
<SectionIcon id="languages" onClick={() => scrollIntoView("#languages")} />
|
||||||
|
<SectionIcon id="volunteer" onClick={() => scrollIntoView("#volunteer")} />
|
||||||
|
<SectionIcon id="projects" onClick={() => scrollIntoView("#projects")} />
|
||||||
|
<SectionIcon id="publications" onClick={() => scrollIntoView("#publications")} />
|
||||||
|
<SectionIcon id="skills" onClick={() => scrollIntoView("#skills")} />
|
||||||
|
<SectionIcon id="references" onClick={() => scrollIntoView("#references")} />
|
||||||
|
|
||||||
|
<SectionIcon
|
||||||
|
id="custom"
|
||||||
|
variant="outline"
|
||||||
|
name="Add a new section"
|
||||||
|
icon={<Plus size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
addSection();
|
||||||
|
scrollIntoView("& > section:last-of-type");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserOptions>
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-full">
|
||||||
|
<UserAvatar size={28} />
|
||||||
|
</Button>
|
||||||
|
</UserOptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea orientation="vertical" className="h-screen flex-1">
|
||||||
|
<div ref={containterRef} className="grid gap-y-6 p-6 @container/left">
|
||||||
|
<BasicsSection />
|
||||||
|
<Separator />
|
||||||
|
<SummarySection />
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Profile>
|
||||||
|
id="profiles"
|
||||||
|
title={(item) => item.network}
|
||||||
|
description={(item) => item.username}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Experience>
|
||||||
|
id="experience"
|
||||||
|
title={(item) => item.company}
|
||||||
|
description={(item) => item.position}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Education>
|
||||||
|
id="education"
|
||||||
|
title={(item) => item.institution}
|
||||||
|
description={(item) => item.area}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Award>
|
||||||
|
id="awards"
|
||||||
|
title={(item) => item.title}
|
||||||
|
description={(item) => item.awarder}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Certification>
|
||||||
|
id="certifications"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => item.issuer}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Interest>
|
||||||
|
id="interests"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => {
|
||||||
|
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Language>
|
||||||
|
id="languages"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => item.fluency || getCEFRLevel(item.fluencyLevel)}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Volunteer>
|
||||||
|
id="volunteer"
|
||||||
|
title={(item) => item.organization}
|
||||||
|
description={(item) => item.position}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Project>
|
||||||
|
id="projects"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => item.description}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Publication>
|
||||||
|
id="publications"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => item.publisher}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Skill>
|
||||||
|
id="skills"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => {
|
||||||
|
if (item.description) return item.description;
|
||||||
|
if (item.keywords.length > 0) return `${item.keywords.length} keywords`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<SectionBase<Reference>
|
||||||
|
id="references"
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => item.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Custom Sections */}
|
||||||
|
{Object.values(customSections).map((section) => (
|
||||||
|
<Fragment key={section.id}>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<SectionBase<CustomSectionItem>
|
||||||
|
id={`custom.${section.id}`}
|
||||||
|
title={(item) => item.name}
|
||||||
|
description={(item) => item.description}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Button size="lg" variant="outline" onClick={addSection}>
|
||||||
|
<PlusCircle />
|
||||||
|
<span className="ml-2">Add a new section</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<section id="basics" className="grid gap-y-6">
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
{getSectionIcon("basics")}
|
||||||
|
<h2 className="line-clamp-1 text-3xl font-bold">Basics</h2>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<PictureSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="basics.name">Full Name</Label>
|
||||||
|
<Input
|
||||||
|
id="basics.name"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={basics.name}
|
||||||
|
hasError={!basicsSchema.pick({ name: true }).safeParse({ name: basics.name }).success}
|
||||||
|
onChange={(event) => setValue("basics.name", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 sm:col-span-2">
|
||||||
|
<Label htmlFor="basics.headline">Headline</Label>
|
||||||
|
<Input
|
||||||
|
id="basics.headline"
|
||||||
|
placeholder="Highly Creative Frontend Web Developer"
|
||||||
|
value={basics.headline}
|
||||||
|
onChange={(event) => setValue("basics.headline", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="basics.email">Email Address</Label>
|
||||||
|
<Input
|
||||||
|
id="basics.email"
|
||||||
|
placeholder="john.doe@example.com"
|
||||||
|
value={basics.email}
|
||||||
|
hasError={
|
||||||
|
!basicsSchema.pick({ email: true }).safeParse({ email: basics.email }).success
|
||||||
|
}
|
||||||
|
onChange={(event) => setValue("basics.email", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="basics.url">Website</Label>
|
||||||
|
<URLInput
|
||||||
|
id="basics.url"
|
||||||
|
value={basics.url}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
onChange={(value) => setValue("basics.url", value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="basics.phone">Phone Number</Label>
|
||||||
|
<Input
|
||||||
|
id="basics.phone"
|
||||||
|
placeholder="+1 (123) 4567 7890"
|
||||||
|
value={basics.phone}
|
||||||
|
onChange={(event) => setValue("basics.phone", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="basics.location">Location</Label>
|
||||||
|
<Input
|
||||||
|
id="basics.location"
|
||||||
|
placeholder="105 Cedarhurst Ave, Cedarhurst, NY 11516"
|
||||||
|
value={basics.location}
|
||||||
|
onChange={(event) => setValue("basics.location", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CustomFieldsSection className="col-span-2" />
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<Reorder.Item
|
||||||
|
value={field}
|
||||||
|
dragListener={false}
|
||||||
|
dragControls={controls}
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -50 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-end justify-between space-x-4">
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="shrink-0"
|
||||||
|
onPointerDown={(event) => controls.start(event)}
|
||||||
|
>
|
||||||
|
<DotsSixVertical />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Name"
|
||||||
|
className="!ml-2"
|
||||||
|
value={field.name}
|
||||||
|
onChange={(event) => handleChange("name", event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder="Value"
|
||||||
|
value={field.value}
|
||||||
|
onChange={(event) => handleChange("value", event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="!ml-2 shrink-0"
|
||||||
|
onClick={() => onRemove(field.id)}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={cn("space-y-4", className)}>
|
||||||
|
<AnimatePresence>
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
className="space-y-4"
|
||||||
|
values={customFields}
|
||||||
|
onReorder={onReorderCustomFields}
|
||||||
|
>
|
||||||
|
{customFields.map((field) => (
|
||||||
|
<CustomField
|
||||||
|
field={field}
|
||||||
|
key={field.id}
|
||||||
|
onChange={onChangeCustomField}
|
||||||
|
onRemove={onRemoveCustomField}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<Button variant="link" onClick={onAddCustomField}>
|
||||||
|
<Plus className="mr-2" />
|
||||||
|
<span>Add a custom field</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<div className="flex flex-col gap-y-5">
|
||||||
|
<div className="grid grid-cols-3 items-center gap-x-6">
|
||||||
|
<Label htmlFor="picture.size" className="col-span-1">
|
||||||
|
Size (in px)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="picture.size"
|
||||||
|
placeholder="128"
|
||||||
|
value={picture.size}
|
||||||
|
className="col-span-2"
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue("basics.picture.size", event.target.valueAsNumber);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 items-center gap-x-6">
|
||||||
|
<Label htmlFor="picture.aspectRatio" className="col-span-1">
|
||||||
|
Aspect Ratio
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-2 flex items-center justify-between">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={aspectRatio}
|
||||||
|
onValueChange={onAspectRatioChange}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Tooltip content="Square">
|
||||||
|
<ToggleGroupItem value="square">
|
||||||
|
<div className="h-3 w-3 border border-foreground" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="Horizontal">
|
||||||
|
<ToggleGroupItem value="horizontal">
|
||||||
|
<div className="h-2 w-3 border border-foreground" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="Portrait">
|
||||||
|
<ToggleGroupItem value="portrait">
|
||||||
|
<div className="h-3 w-2 border border-foreground" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
min={0.1}
|
||||||
|
max={2}
|
||||||
|
step={0.05}
|
||||||
|
type="number"
|
||||||
|
className="w-[60px]"
|
||||||
|
id="picture.aspectRatio"
|
||||||
|
value={picture.aspectRatio}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue("basics.picture.aspectRatio", event.target.valueAsNumber ?? 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 items-center gap-x-6">
|
||||||
|
<Label htmlFor="picture.borderRadius" className="col-span-1">
|
||||||
|
Border Radius
|
||||||
|
</Label>
|
||||||
|
<div className="col-span-2 flex items-center justify-between">
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
value={borderRadius}
|
||||||
|
onValueChange={onBorderRadiusChange}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Tooltip content="Square">
|
||||||
|
<ToggleGroupItem value="square">
|
||||||
|
<div className="h-3 w-3 border border-foreground" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="Rounded">
|
||||||
|
<ToggleGroupItem value="rounded">
|
||||||
|
<div className="h-3 w-3 rounded-sm border border-foreground" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="Circle">
|
||||||
|
<ToggleGroupItem value="circle">
|
||||||
|
<div className="h-3 w-3 rounded-full border border-foreground" />
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</Tooltip>
|
||||||
|
</ToggleGroup>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
min={0}
|
||||||
|
step={2}
|
||||||
|
max={9999}
|
||||||
|
type="number"
|
||||||
|
className="w-[60px]"
|
||||||
|
id="picture.borderRadius"
|
||||||
|
value={picture.borderRadius}
|
||||||
|
onChange={(event) => {
|
||||||
|
setValue("basics.picture.borderRadius", event.target.valueAsNumber ?? 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-3 items-start gap-x-6">
|
||||||
|
<div className="col-span-1">
|
||||||
|
<Label>Effects</Label>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 space-y-4">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="picture.effects.hidden"
|
||||||
|
checked={picture.effects.hidden}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setValue("basics.picture.effects.hidden", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="picture.effects.hidden">Hidden</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="picture.effects.border"
|
||||||
|
checked={picture.effects.border}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setValue("basics.picture.effects.border", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="picture.effects.border">Border</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="picture.effects.grayscale"
|
||||||
|
checked={picture.effects.grayscale}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setValue("basics.picture.effects.grayscale", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="picture.effects.grayscale">Grayscale</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Avatar className="h-14 w-14">
|
||||||
|
{isValidUrl && <AvatarImage src={picture.url} />}
|
||||||
|
<AvatarFallback className="text-lg font-bold">{getInitials(name)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-col gap-y-1.5">
|
||||||
|
<Label htmlFor="basics.picture.url">Picture</Label>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Input
|
||||||
|
id="basics.picture.url"
|
||||||
|
placeholder="https://..."
|
||||||
|
value={picture.url}
|
||||||
|
onChange={(event) => setValue("basics.picture.url", event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{/* Show options button if picture exists */}
|
||||||
|
{isValidUrl && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||||
|
>
|
||||||
|
<Aperture />
|
||||||
|
</motion.button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[360px]">
|
||||||
|
<PictureOptions />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show upload button if picture doesn't exist, else show remove button to delete picture */}
|
||||||
|
{!isValidUrl && (
|
||||||
|
<>
|
||||||
|
<input hidden type="file" ref={inputRef} onChange={onSelectImage} />
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
disabled={loading}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className={cn(buttonVariants({ size: "icon", variant: "ghost" }))}
|
||||||
|
>
|
||||||
|
<UploadSimple />
|
||||||
|
</motion.button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<T extends SectionItem> = {
|
||||||
|
id: SectionKey;
|
||||||
|
title: (item: T) => string;
|
||||||
|
description?: (item: T) => string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionBase = <T extends SectionItem>({ id, title, description }: Props<T>) => {
|
||||||
|
const { open } = useDialog(id);
|
||||||
|
|
||||||
|
const setValue = useResumeStore((state) => state.setValue);
|
||||||
|
const section = useResumeStore((state) =>
|
||||||
|
get(state.resume.data.sections, id),
|
||||||
|
) as SectionWithItem<T>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.section
|
||||||
|
id={id}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="grid gap-y-6"
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
{getSectionIcon(id)}
|
||||||
|
|
||||||
|
<h2 className="line-clamp-1 text-3xl font-bold">{section.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<SectionOptions id={id} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className={cn("grid transition-opacity", !section.visible && "opacity-50")}>
|
||||||
|
{section.items.length === 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCreate}
|
||||||
|
className="gap-x-2 border-dashed py-6 leading-relaxed hover:bg-secondary-accent"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span className="font-medium">Add New {section.name}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToParentElement]}
|
||||||
|
>
|
||||||
|
<SortableContext items={section.items} strategy={verticalListSortingStrategy}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{section.items.map((item, index) => (
|
||||||
|
<SectionListItem
|
||||||
|
id={item.id}
|
||||||
|
key={item.id}
|
||||||
|
visible={item.visible}
|
||||||
|
title={title(item as T)}
|
||||||
|
description={description?.(item as T)}
|
||||||
|
onUpdate={() => onUpdate(item as T)}
|
||||||
|
onDelete={() => onDelete(item as T)}
|
||||||
|
onDuplicate={() => onDuplicate(item as T)}
|
||||||
|
onToggleVisibility={() => onToggleVisibility(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{section.items.length > 0 && (
|
||||||
|
<footer className="flex items-center justify-end">
|
||||||
|
<Button variant="outline" className="ml-auto gap-x-2" onClick={onCreate}>
|
||||||
|
<Plus />
|
||||||
|
<span>Add New {section.name}</span>
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<T extends SectionItem> = {
|
||||||
|
id: DialogName;
|
||||||
|
form: UseFormReturn<T>;
|
||||||
|
defaultValues: T;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionDialog = <T extends SectionItem>({
|
||||||
|
id,
|
||||||
|
form,
|
||||||
|
defaultValues,
|
||||||
|
children,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const { isOpen, mode, close, payload } = useDialog<T>(id);
|
||||||
|
const setValue = useResumeStore((state) => state.setValue);
|
||||||
|
const section = useResumeStore((state) => {
|
||||||
|
if (!id) return null;
|
||||||
|
return get(state.resume.data.sections, id);
|
||||||
|
}) as SectionWithItem<T> | 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 (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={close}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure you want to delete this {name}?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This action can be reverted by clicking on the undo button in the floating
|
||||||
|
toolbar.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
|
||||||
|
<AlertDialogAction variant="error" onClick={form.handleSubmit(onSubmit)}>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={close}>
|
||||||
|
<DialogContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<div className="flex items-center space-x-2.5">
|
||||||
|
{isCreate && <Plus />}
|
||||||
|
{isUpdate && <PencilSimple />}
|
||||||
|
{isDuplicate && <CopySimple />}
|
||||||
|
<h2>
|
||||||
|
{isCreate && `Create a new ${name}`}
|
||||||
|
{isUpdate && `Update an existing ${name}`}
|
||||||
|
{isDuplicate && `Duplicate an existing ${name}`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit">
|
||||||
|
{isCreate && "Create"}
|
||||||
|
{isUpdate && "Save Changes"}
|
||||||
|
{isDuplicate && "Duplicate"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 <User size={18} {...props} />;
|
||||||
|
case "summary":
|
||||||
|
return <Article size={18} {...props} />;
|
||||||
|
case "awards":
|
||||||
|
return <Medal size={18} {...props} />;
|
||||||
|
case "profiles":
|
||||||
|
return <ShareNetwork size={18} {...props} />;
|
||||||
|
case "experience":
|
||||||
|
return <Briefcase size={18} {...props} />;
|
||||||
|
case "education":
|
||||||
|
return <GraduationCap size={18} {...props} />;
|
||||||
|
case "certifications":
|
||||||
|
return <Certificate size={18} {...props} />;
|
||||||
|
case "interests":
|
||||||
|
return <GameController size={18} {...props} />;
|
||||||
|
case "languages":
|
||||||
|
return <Translate size={18} {...props} />;
|
||||||
|
case "volunteer":
|
||||||
|
return <HandHeart size={18} {...props} />;
|
||||||
|
case "projects":
|
||||||
|
return <PuzzlePiece size={18} {...props} />;
|
||||||
|
case "publications":
|
||||||
|
return <Books size={18} {...props} />;
|
||||||
|
case "skills":
|
||||||
|
return <CompassTool size={18} {...props} />;
|
||||||
|
case "references":
|
||||||
|
return <Users size={18} {...props} />;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tooltip side="right" content={name ?? section.name}>
|
||||||
|
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" {...props}>
|
||||||
|
{icon ?? getSectionIcon(id, { size: 14 })}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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 (
|
||||||
|
<motion.section
|
||||||
|
ref={setNodeRef}
|
||||||
|
initial={{ opacity: 0, y: -50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -50 }}
|
||||||
|
className="border-x border-t bg-secondary/10 first-of-type:rounded-t last-of-type:rounded-b last-of-type:border-b"
|
||||||
|
>
|
||||||
|
<div style={style} className="flex transition-opacity">
|
||||||
|
{/* Drag Handle */}
|
||||||
|
<div
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className={cn(
|
||||||
|
"flex w-5 cursor-move items-center justify-center",
|
||||||
|
!isDragging && "hover:bg-secondary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DotsSixVertical size={12} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Item */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex-1 cursor-context-menu p-4 hover:bg-secondary-accent",
|
||||||
|
!visible && "opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<h4 className="font-medium leading-relaxed">{title}</h4>
|
||||||
|
{description && <p className="text-xs leading-relaxed opacity-50">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="center" side="left" sideOffset={-16}>
|
||||||
|
<DropdownMenuCheckboxItem checked={visible} onCheckedChange={onToggleVisibility}>
|
||||||
|
<span className="-ml-0.5">Visible</span>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
<DropdownMenuItem onClick={onUpdate}>
|
||||||
|
<PencilSimple size={14} />
|
||||||
|
<span className="ml-2">Edit</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onDuplicate}>
|
||||||
|
<CopySimple size={14} />
|
||||||
|
<span className="ml-2">Copy</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="text-error" onClick={onDelete}>
|
||||||
|
<TrashSimple size={14} />
|
||||||
|
<span className="ml-2">Remove</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</motion.section>
|
||||||
|
);
|
||||||
|
};
|
||||||