Merge branch 'main' into fix/nosepass-template-europass-logo

This commit is contained in:
Abishek Ilango
2024-05-20 13:03:31 +05:30
committed by GitHub
301 changed files with 32052 additions and 26220 deletions

View File

@ -1,22 +1,62 @@
!README.md # Compiled Output
.dockerignore dist
tmp
/out-tsc
# Project Dependencies
.git
.gitignore
node_modules
# Docker
compose*.yml
Dockerfile
# IDEs and Editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vs/*
.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 .DS_Store
Thumbs.db
.editorconfig .editorconfig
.eslint* .eslint*
.git
.github # Generated Files
.gitignore
.husky
.nx .nx
.prettier* .swc
.vscode fly.toml
*.env* stats.html
*.md
compose*.yml
dist
Dockerfile
node_modules
Thumbs.db
tmp
tools/compose/* tools/compose/*
tools/scripts/* tools/scripts/*
# Environment Variables
*.env*
!.env.example
# Lingui Compiled Messages
apps/client/src/locales/_build/
apps/client/src/locales/*/messages.mjs

View File

@ -4,14 +4,6 @@ NODE_ENV=development
# Ports # Ports
PORT=3000 PORT=3000
# Client Port & URL (for development)
__DEV__CLIENT_PORT=5173 # Only used in development
__DEV__CLIENT_URL=http://localhost:5173 # Only used in development
# Artboard Port & URL (for development)
__DEV__ARTBOARD_PORT=6173 # Only used in development
__DEV__ARTBOARD_URL=http://localhost:6173 # Only used in development
# URLs # URLs
# These URLs must reference a publicly accessible domain or IP address, not a docker container ID (depending on your compose setup) # These URLs must reference a publicly accessible domain or IP address, not a docker container ID (depending on your compose setup)
PUBLIC_URL=http://localhost:3000 PUBLIC_URL=http://localhost:3000
@ -38,6 +30,8 @@ REFRESH_TOKEN_SECRET=refresh_token_secret
CHROME_PORT=8080 CHROME_PORT=8080
CHROME_TOKEN=chrome_token CHROME_TOKEN=chrome_token
CHROME_URL=ws://localhost:8080 CHROME_URL=ws://localhost:8080
# Launch puppeteer with flag to ignore https errors
# CHROME_IGNORE_HTTPS_ERRORS=true
# Mail Server (for e-mails) # Mail Server (for e-mails)
# For testing, you can use https://ethereal.email/create # For testing, you can use https://ethereal.email/create
@ -53,19 +47,22 @@ STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin STORAGE_SECRET_KEY=minioadmin
STORAGE_USE_SSL=false STORAGE_USE_SSL=false
# Redis (for cache & server session management)
REDIS_URL=redis://default:password@localhost:6379
# Sentry (for error reporting, Optional) # Sentry (for error reporting, Optional)
# VITE_SENTRY_DSN= # SENTRY_AUTH_TOKEN=
# SERVER_SENTRY_DSN=
# VITE_CLIENT_SENTRY_DSN=
# Nx Cloud (Optional)
# NX_CLOUD_ACCESS_TOKEN=
# Crowdin (Optional) # Crowdin (Optional)
CROWDIN_PROJECT_ID= # CROWDIN_PROJECT_ID=
CROWDIN_PERSONAL_TOKEN= # CROWDIN_PERSONAL_TOKEN=
# Email (Optional) # Flags (Optional)
# DISABLE_EMAIL_AUTH=true # DISABLE_EMAIL_AUTH=true
# VITE_DISABLE_SIGNUPS=false # VITE_DISABLE_SIGNUPS=false
# SKIP_STORAGE_BUCKET_CHECK=false
# GitHub (OAuth, Optional) # GitHub (OAuth, Optional)
# GITHUB_CLIENT_ID= # GITHUB_CLIENT_ID=

View File

@ -8,6 +8,9 @@
"extends": ["plugin:prettier/recommended"], "extends": ["plugin:prettier/recommended"],
"plugins": ["simple-import-sort", "unused-imports"], "plugins": ["simple-import-sort", "unused-imports"],
"rules": { "rules": {
// eslint
"no-return-await": "off",
// simple-import-sort // simple-import-sort
"simple-import-sort/imports": "error", "simple-import-sort/imports": "error",
"simple-import-sort/exports": "error", "simple-import-sort/exports": "error",
@ -43,10 +46,38 @@
}, },
{ {
"files": ["*.ts", "*.tsx"], "files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"], "parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"ecmaVersion": "latest",
"project": ["tsconfig.*?.json"]
},
"extends": [
"plugin:@nx/typescript",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:unicorn/recommended"
],
"plugins": ["@typescript-eslint", "unicorn"],
"rules": { "rules": {
// typescript-eslint // typescript-eslint
"@typescript-eslint/no-unused-vars": "off" "@typescript-eslint/no-unsafe-call": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/return-await": "off",
"@typescript-eslint/no-unsafe-return": "off",
"@typescript-eslint/no-unsafe-argument": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/no-unsafe-assignment": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-redundant-type-constituents": "off",
"@typescript-eslint/consistent-type-definitions": ["error", "type"],
// unicorn
"unicorn/no-null": "off",
"unicorn/prevent-abbreviations": "off",
"unicorn/prefer-string-replace-all": "off",
"unicorn/prefer-structured-clone": "off"
} }
}, },
{ {

View File

@ -21,7 +21,7 @@ body:
label: Product Variant label: Product Variant
description: What variant of Reactive Resume are you using? description: What variant of Reactive Resume are you using?
options: options:
- Cloud (http://rxresu.me) - Cloud (https://rxresu.me)
- Self-Hosted - Self-Hosted
validations: validations:
required: true required: true

View File

@ -1,44 +0,0 @@
name: Lint and Build
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
permissions:
actions: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
NX_BRANCH: ${{ github.event.number || github.ref_name }}
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
jobs:
main:
name: Nx Cloud - Main Job
uses: nrwl/ci/.github/workflows/nx-cloud-main.yml@v0.14.0
with:
main-branch-name: main
number-of-agents: 3
init-commands: |
pnpm exec prisma generate
pnpm exec nx-cloud start-ci-run --stop-agents-after="build" --agent-count=3
parallel-commands: |
pnpm exec nx-cloud record -- pnpm exec nx format:check
parallel-commands-on-agents: |
pnpm exec nx affected --target=lint --parallel=3
pnpm exec nx affected --target=build --parallel=3
agents:
name: Nx Cloud - Agents
uses: nrwl/ci/.github/workflows/nx-cloud-agents.yml@v0.14.0
with:
number-of-agents: 3

54
.github/workflows/lint-test-build.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Lint, Test & Build
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4.1.1
with:
fetch-depth: 2
- name: Setup pnpm
uses: pnpm/action-setup@v3.0.0
with:
version: 9.1.0
- name: Setup Node.js
uses: actions/setup-node@v4.0.2
with:
cache: "pnpm"
node-version: 20.12.2
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Format
run: pnpm format:check
- name: Test
run: pnpm test
- name: Build
run: pnpm build
env:
NODE_ENV: production
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}

View File

@ -7,7 +7,7 @@ on:
- "*" - "*"
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true cancel-in-progress: true
env: env:
@ -21,7 +21,6 @@ jobs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
strategy: strategy:
fail-fast: false
matrix: matrix:
platform: platform:
- linux/amd64 - linux/amd64
@ -29,7 +28,7 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.4
- name: Extract version from package.json - name: Extract version from package.json
id: version id: version
@ -38,42 +37,47 @@ jobs:
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0 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 - name: Login to Docker Hub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.1.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registery - name: Login to GitHub Container Registery
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ github.token }} password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.3.0
- name: Extract Docker Metadata - name: Extract Docker Metadata
id: meta id: meta
uses: docker/metadata-action@v5.0.0 uses: docker/metadata-action@v5.5.1
with: with:
tags: type=semver,pattern={{version}},prefix=v,value=${{ steps.version.outputs.version }} tags: type=semver,pattern={{version}},prefix=v,value=${{ steps.version.outputs.version }}
images: | images: |
${{ env.IMAGE }} ${{ env.IMAGE }}
ghcr.io/${{ env.IMAGE }} ghcr.io/${{ env.IMAGE }}
- name: Prepare a unique name for Artifacts
id: artifact_name
run: |
name=$(echo -n "${{ matrix.platform }}" | sed -e 's/[ \t:\/\\"<>|*?]/-/g' -e 's/--*/-/g')
echo "name=$name" >> "$GITHUB_OUTPUT"
- name: Build and Push by Digest - name: Build and Push by Digest
uses: docker/build-push-action@v5.0.0 uses: docker/build-push-action@v5.3.0
id: build id: build
with: with:
context: . context: .
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
build-args: | build-args: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }} NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Export Digest - name: Export Digest
@ -83,9 +87,9 @@ jobs:
touch "/tmp/digests/${digest#sha256:}" touch "/tmp/digests/${digest#sha256:}"
- name: Upload Digest - name: Upload Digest
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4.3.3
with: with:
name: digests name: digests-${{ steps.artifact_name.outputs.name }}
path: /tmp/digests/* path: /tmp/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
@ -98,33 +102,34 @@ jobs:
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.4
- name: Download Digest - name: Download Digest
uses: actions/download-artifact@v3.0.0 uses: actions/download-artifact@v4.1.7
with: with:
name: digests
path: /tmp/digests path: /tmp/digests
pattern: digests-*
- name: Set up Docker Buildx merge-multiple: true
uses: docker/setup-buildx-action@v3.0.0
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.1.0
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registery - name: Login to GitHub Container Registery
uses: docker/login-action@v3.0.0 uses: docker/login-action@v3.1.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ github.token }} password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.3.0
- name: Extract Docker Metadata - name: Extract Docker Metadata
id: meta id: meta
uses: docker/metadata-action@v5.0.0 uses: docker/metadata-action@v5.5.1
with: with:
tags: type=semver,pattern={{version}},prefix=v,value=${{ needs.build.outputs.version }} tags: type=semver,pattern={{version}},prefix=v,value=${{ needs.build.outputs.version }}
images: | images: |
@ -142,8 +147,18 @@ jobs:
docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }} docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
- name: Update Repository Description - name: Update Repository Description
uses: peter-evans/dockerhub-description@v3 uses: peter-evans/dockerhub-description@v4.0.0
with: with:
repository: ${{ github.repository }} repository: ${{ github.repository }}
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }} password: ${{ secrets.DOCKER_TOKEN }}
- uses: sarisia/actions-status-discord@v1.14.3
if: always()
with:
username: ReleaseBot
webhook: ${{ secrets.DISCORD_WEBHOOK }}
status: ${{ job.status }}
title: "Release `${{ steps.meta.outputs.version }}`"
description: "A new version of Reactive Resume just dropped! 🚀"
url: "https://github.com/AmruthPillai/Reactive-Resume"

9
.gitignore vendored
View File

@ -16,14 +16,8 @@ node_modules
*.sublime-workspace *.sublime-workspace
# IDE - VSCode # IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# IDE - Visual Studio
.vs/* .vs/*
.vscode/*
!.vscode/settings.json !.vscode/settings.json
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
@ -48,7 +42,6 @@ Thumbs.db
.swc .swc
fly.toml fly.toml
stats.html stats.html
libs/prisma
# Environment Variables # Environment Variables
*.env* *.env*

View File

@ -2,5 +2,6 @@
"$schema": "https://raw.githubusercontent.com/raineorshine/npm-check-updates/main/src/types/RunOptions.json", "$schema": "https://raw.githubusercontent.com/raineorshine/npm-check-updates/main/src/types/RunOptions.json",
"upgrade": true, "upgrade": true,
"install": "always", "install": "always",
"reject": ["@nestjs-modules/mailer"] "packageManager": "pnpm",
"reject": ["eslint", "vite-plugin-chunk-split", "@reactive-resume/*"]
} }

View File

@ -10,5 +10,6 @@
"tools/compose/*" "tools/compose/*"
] ]
}, },
"i18n-ally.localesPaths": ["apps/client/src/locales"] "i18n-ally.localesPaths": ["apps/client/src/locales"],
"vitest.disableWorkspaceWarning": true
} }

View File

@ -14,6 +14,6 @@ When it comes to **security**, you now have the option to protect your account w
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 **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. 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, 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). 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).

View File

@ -2,7 +2,7 @@
## Getting the project set up locally ## Getting the project set up locally
There are a number of Docker Compose examples that are suitable for a wide variety of deployment strategies depending on your use-case. All of the examples can be found in the `tools/compose` folder. There are a number of Docker Compose examples that are suitable for a wide variety of deployment strategies depending on your use-case. All the examples can be found in the `tools/compose` folder.
To run the development environment of the application locally on your computer, please follow these steps: To run the development environment of the application locally on your computer, please follow these steps:
@ -57,15 +57,13 @@ You can also visit `http://localhost:3000/api/health`, the health check endpoint
"info": { "info": {
"database": { "status": "up" }, "database": { "status": "up" },
"storage": { "status": "up" }, "storage": { "status": "up" },
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" }, "browser": { "status": "up", "version": "Chrome/119.0.6045.9" }
"redis": { "status": "up" }
}, },
"error": {}, "error": {},
"details": { "details": {
"database": { "status": "up" }, "database": { "status": "up" },
"storage": { "status": "up" }, "storage": { "status": "up" },
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" }, "browser": { "status": "up", "version": "Chrome/119.0.6045.9" }
"redis": { "status": "up" }
} }
} }
``` ```

View File

@ -1,39 +1,49 @@
ARG SENTRY_AUTH_TOKEN
ARG NX_CLOUD_ACCESS_TOKEN
# --- Base Image --- # --- Base Image ---
FROM node:lts-bullseye-slim AS base FROM node:lts-bullseye-slim AS base
ARG SENTRY_AUTH_TOKEN
ARG NX_CLOUD_ACCESS_TOKEN
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
ARG NX_CLOUD_ACCESS_TOKEN
RUN corepack enable RUN corepack enable pnpm && corepack prepare pnpm@9.0.6 --activate
WORKDIR /app WORKDIR /app
# --- Build Image --- # --- Build Image ---
FROM base AS build FROM base AS build
ARG SENTRY_AUTH_TOKEN
ENV NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_ACCESS_TOKEN ARG NX_CLOUD_ACCESS_TOKEN
COPY .npmrc package.json pnpm-lock.yaml ./ COPY .npmrc package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile COPY ./tools/prisma /app/tools/prisma
RUN pnpm install --frozen-lockfile
COPY . . COPY . .
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_ACCESS_TOKEN
RUN pnpm run build RUN pnpm run build
# --- Release Image --- # --- Release Image ---
FROM base AS release FROM base AS release
ARG NX_CLOUD_ACCESS_TOKEN
RUN apt update && apt install -y dumb-init --no-install-recommends RUN apt update && apt install -y dumb-init --no-install-recommends && rm -rf /var/lib/apt/lists/*
COPY --chown=node:node --from=build /app/.npmrc /app/package.json /app/pnpm-lock.yaml ./ COPY --chown=node:node --from=build /app/.npmrc /app/package.json /app/pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile RUN pnpm install --prod --frozen-lockfile
COPY --chown=node:node --from=build /app/dist ./dist COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node --from=build /app/tools/prisma ./tools/prisma COPY --chown=node:node --from=build /app/tools/prisma ./tools/prisma
RUN pnpm run prisma:generate RUN pnpm run prisma:generate
ENV TZ=UTC ENV TZ=UTC
ENV PORT=3000
ENV NODE_ENV=production ENV NODE_ENV=production
EXPOSE 3000 EXPOSE 3000

View File

@ -40,11 +40,11 @@ Start creating your standout resume with Reactive Resume today!
- **Free, forever** and open-source - **Free, forever** and open-source
- No telemetry, user tracking or advertising - No telemetry, user tracking or advertising
- You can self-host the application in less then 30 seconds - You can self-host the application in less than 30 seconds
- **Available in multiple languages** ([help add/improve your language here](https://translate.rxresu.me/)) - **Available in multiple languages** ([help add/improve your language here](https://translate.rxresu.me/))
- Use your email address (or a throw-away address, no problem) to create an account - 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 - 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 its description for a higher ATS score - Create as many resumes as you like under a single account, optimising each resume for every job application based on its 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 - **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
- Translate your resume into any language using ChatGPT and import it back for easier editing - Translate your resume into any language using ChatGPT and import it back for easier editing
- Create single page resumes or a resume that spans multiple pages easily - Create single page resumes or a resume that spans multiple pages easily
@ -69,7 +69,6 @@ Start creating your standout resume with Reactive Resume today!
- NestJS, for the backend - NestJS, for the backend
- Postgres (primary database) - Postgres (primary database)
- Prisma ORM, which frees you to switch to any other relational database with a few minor changes in the code - 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) - Minio (for object storage: to store avatars, resume PDFs and previews)
- Browserless (for headless chrome, to print PDFs and generate previews) - Browserless (for headless chrome, to print PDFs and generate previews)
- SMTP Server (to send password recovery emails) - SMTP Server (to send password recovery emails)

View File

@ -11,4 +11,4 @@
## Reporting a Vulnerability ## 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. Please raise an issue on GitHub to report any security vulnerabilities in the app. If the vulnerability is potentially lethal, email me about it on hello@amruthpillai.com.

View File

@ -12,6 +12,18 @@
} }
}, },
"rules": { "rules": {
// react
"react/no-unescaped-entities": "off",
"react/jsx-sort-props": [
"error",
{
"reservedFirst": true,
"callbacksLast": true,
"shorthandFirst": true,
"noSortAlphabetically": true
}
],
// react-hooks // react-hooks
"react-hooks/exhaustive-deps": "off", "react-hooks/exhaustive-deps": "off",

View File

@ -1,9 +1,9 @@
const { join } = require("path"); const path = require("node:path");
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: { tailwindcss: {
config: join(__dirname, "tailwind.config.js"), config: path.join(__dirname, "tailwind.config.js"),
}, },
autoprefixer: {}, autoprefixer: {},
}, },

View File

@ -4,7 +4,8 @@ import { RouterProvider } from "react-router-dom";
import { router } from "./router"; import { router } from "./router";
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = ReactDOM.createRoot(document.querySelector("#root")!);
root.render( root.render(
<StrictMode> <StrictMode>

View File

@ -39,20 +39,20 @@ export const ArtboardPage = () => {
`${metadata.typography.lineHeight}`, `${metadata.typography.lineHeight}`,
); );
document.documentElement.style.setProperty("--color-text", `${metadata.theme.text}`); document.documentElement.style.setProperty("--color-text", metadata.theme.text);
document.documentElement.style.setProperty("--color-primary", `${metadata.theme.primary}`); document.documentElement.style.setProperty("--color-primary", metadata.theme.primary);
document.documentElement.style.setProperty( document.documentElement.style.setProperty("--color-background", metadata.theme.background);
"--color-background",
`${metadata.theme.background}`,
);
}, [metadata]); }, [metadata]);
// Typography Options // Typography Options
useEffect(() => { useEffect(() => {
document.querySelectorAll(`[data-page]`).forEach((el) => { // eslint-disable-next-line unicorn/prefer-spread
const elements = Array.from(document.querySelectorAll(`[data-page]`));
for (const el of elements) {
el.classList.toggle("hide-icons", metadata.typography.hideIcons); el.classList.toggle("hide-icons", metadata.typography.hideIcons);
el.classList.toggle("underline-links", metadata.typography.underlineLinks); el.classList.toggle("underline-links", metadata.typography.underlineLinks);
}); }
}, [metadata]); }, [metadata]);
return <Outlet />; return <Outlet />;

View File

@ -38,11 +38,11 @@ export const BuilderLayout = () => {
return ( return (
<TransformWrapper <TransformWrapper
ref={transformRef}
centerOnInit centerOnInit
maxScale={2} maxScale={2}
minScale={0.4} minScale={0.4}
initialScale={0.8} initialScale={0.8}
ref={transformRef}
limitToBounds={false} limitToBounds={false}
> >
<TransformComponent <TransformComponent
@ -56,8 +56,8 @@ export const BuilderLayout = () => {
<AnimatePresence> <AnimatePresence>
{layout.map((columns, pageIndex) => ( {layout.map((columns, pageIndex) => (
<motion.div <motion.div
layout
key={pageIndex} key={pageIndex}
layout
initial={{ opacity: 0, x: -200, y: 0 }} initial={{ opacity: 0, x: -200, y: 0 }}
animate={{ opacity: 1, x: 0, transition: { delay: pageIndex * 0.3 } }} animate={{ opacity: 1, x: 0, transition: { delay: pageIndex * 0.3 } }}
exit={{ opacity: 0, x: -200 }} exit={{ opacity: 0, x: -200 }}

View File

@ -20,7 +20,10 @@ export const Providers = () => {
}; };
const resumeData = window.localStorage.getItem("resume"); const resumeData = window.localStorage.getItem("resume");
if (resumeData) return setResume(JSON.parse(resumeData)); if (resumeData) {
setResume(JSON.parse(resumeData));
return;
}
window.addEventListener("message", handleMessage); window.addEventListener("message", handleMessage);
@ -34,6 +37,7 @@ export const Providers = () => {
// setResume(sampleResume); // setResume(sampleResume);
// }, [setResume]); // }, [setResume]);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!resume) return null; if (!resume) return null;
return <Outlet />; return <Outlet />;

View File

@ -8,5 +8,7 @@ export type ArtboardStore = {
export const useArtboardStore = create<ArtboardStore>()((set) => ({ export const useArtboardStore = create<ArtboardStore>()((set) => ({
resume: null as unknown as ResumeData, resume: null as unknown as ResumeData,
setResume: (resume) => set({ resume }), setResume: (resume) => {
set({ resume });
},
})); }));

View File

@ -93,9 +93,9 @@ const Summary = () => {
<div className="absolute left-[-4.5px] top-[8px] hidden size-[8px] rounded-full bg-primary group-[.main]:block" /> <div className="absolute left-[-4.5px] top-[8px] hidden size-[8px] rounded-full bg-primary group-[.main]:block" />
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</main> </main>
</section> </section>
@ -133,7 +133,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -158,7 +158,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -196,7 +196,7 @@ const Section = <T,>({
<div>{children?.(item as T)}</div> <div>{children?.(item as T)}</div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -449,36 +449,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -84,9 +84,9 @@ const Summary = () => {
</div> </div>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg col-span-4" className="wysiwyg col-span-4"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -124,7 +124,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -149,7 +149,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid grid-cols-5 border-t pt-2.5"> <section id={section.id} className="grid grid-cols-5 border-t pt-2.5">
@ -177,7 +177,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -460,36 +460,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -84,9 +84,9 @@ const Summary = () => {
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4> <h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -127,7 +127,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -152,7 +152,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -178,7 +178,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -461,36 +461,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -104,9 +104,9 @@ const Summary = () => {
<h4 className="mb-2 text-base font-bold">{section.name}</h4> <h4 className="mb-2 text-base font-bold">{section.name}</h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -144,7 +144,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -169,7 +169,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -202,7 +202,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -487,36 +487,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -82,9 +82,9 @@ const Summary = () => {
return ( return (
<section id={section.id}> <section id={section.id}>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -122,7 +122,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -147,7 +147,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -173,7 +173,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -456,34 +456,47 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -84,9 +84,9 @@ const Summary = () => {
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4> <h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -130,7 +130,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -155,7 +155,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -183,7 +183,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -466,36 +466,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -15,31 +15,44 @@ import { Rhyhorn } from "./rhyhorn";
export const getTemplate = (template: Template) => { export const getTemplate = (template: Template) => {
switch (template) { switch (template) {
case "azurill": case "azurill": {
return Azurill; return Azurill;
case "bronzor": }
case "bronzor": {
return Bronzor; return Bronzor;
case "chikorita": }
case "chikorita": {
return Chikorita; return Chikorita;
case "ditto": }
case "ditto": {
return Ditto; return Ditto;
case "gengar": }
case "gengar": {
return Gengar; return Gengar;
case "glalie": }
case "glalie": {
return Glalie; return Glalie;
case "kakuna": }
case "kakuna": {
return Kakuna; return Kakuna;
case "leafish": }
case "leafish": {
return Leafish; return Leafish;
case "nosepass": }
case "nosepass": {
return Nosepass; return Nosepass;
case "onyx": }
case "onyx": {
return Onyx; return Onyx;
case "pikachu": }
case "pikachu": {
return Pikachu; return Pikachu;
case "rhyhorn": }
case "rhyhorn": {
return Rhyhorn; return Rhyhorn;
default: }
default: {
return Onyx; return Onyx;
}
} }
}; };

View File

@ -110,9 +110,9 @@ const Summary = () => {
</h4> </h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -150,7 +150,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -175,7 +175,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -200,7 +200,7 @@ const Section = <T,>({
<div>{children?.(item as T)}</div> <div>{children?.(item as T)}</div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -417,34 +417,47 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "summary": case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -44,9 +44,9 @@ const Header = () => {
</div> </div>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</div> </div>
@ -147,7 +147,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -172,7 +172,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -197,7 +197,7 @@ const Section = <T,>({
<div>{children?.(item as T)}</div> <div>{children?.(item as T)}</div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -414,32 +414,44 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "experience": case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -98,9 +98,9 @@ const Summary = () => {
</div> </div>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</div> </div>
</section> </section>
@ -126,7 +126,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -150,7 +150,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className={cn("grid", dateKey !== undefined && "gap-y-4")}> <section id={section.id} className={cn("grid", dateKey !== undefined && "gap-y-4")}>
@ -187,7 +187,7 @@ const Section = <T,>({
{url !== undefined && <Link url={url} />} {url !== undefined && <Link url={url} />}
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{keywords !== undefined && keywords.length > 0 && ( {keywords !== undefined && keywords.length > 0 && (
@ -222,7 +222,7 @@ const Section = <T,>({
{url !== undefined && <Link url={url} />} {url !== undefined && <Link url={url} />}
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{keywords !== undefined && keywords.length > 0 && ( {keywords !== undefined && keywords.length > 0 && (
@ -464,36 +464,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -113,9 +113,9 @@ const Summary = () => {
<h4 className="font-bold text-primary">{section.name}</h4> <h4 className="font-bold text-primary">{section.name}</h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -153,7 +153,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -178,7 +178,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -204,7 +204,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -455,34 +455,47 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "summary": case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -105,9 +105,9 @@ const Summary = () => {
<h4 className="mb-2 border-b border-primary text-base font-bold">{section.name}</h4> <h4 className="mb-2 border-b border-primary text-base font-bold">{section.name}</h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -153,7 +153,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -178,7 +178,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -204,7 +204,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -487,36 +487,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -85,9 +85,9 @@ const Summary = () => {
<h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4> <h4 className="mb-2 border-b pb-0.5 text-sm font-bold">{section.name}</h4>
<div <div
dangerouslySetInnerHTML={{ __html: section.content }}
className="wysiwyg" className="wysiwyg"
style={{ columns: section.columns }} style={{ columns: section.columns }}
dangerouslySetInnerHTML={{ __html: section.content }}
/> />
</section> </section>
); );
@ -125,7 +125,7 @@ const Link = ({ url, icon, label, className }: LinkProps) => {
rel="noreferrer noopener nofollow" rel="noreferrer noopener nofollow"
className={cn("inline-block", className)} className={cn("inline-block", className)}
> >
{label || url.label || url.href} {label ?? (url.label || url.href)}
</a> </a>
</div> </div>
); );
@ -150,7 +150,7 @@ const Section = <T,>({
summaryKey, summaryKey,
keywordsKey, keywordsKey,
}: SectionProps<T>) => { }: SectionProps<T>) => {
if (!section.visible || !section.items.length) return null; if (!section.visible || section.items.length === 0) return null;
return ( return (
<section id={section.id} className="grid"> <section id={section.id} className="grid">
@ -176,7 +176,7 @@ const Section = <T,>({
</div> </div>
{summary !== undefined && !isEmptyString(summary) && ( {summary !== undefined && !isEmptyString(summary) && (
<div className="wysiwyg" dangerouslySetInnerHTML={{ __html: summary }} /> <div dangerouslySetInnerHTML={{ __html: summary }} className="wysiwyg" />
)} )}
{level !== undefined && level > 0 && <Rating level={level} />} {level !== undefined && level > 0 && <Rating level={level} />}
@ -459,36 +459,50 @@ const Custom = ({ id }: { id: string }) => {
const mapSectionToComponent = (section: SectionKey) => { const mapSectionToComponent = (section: SectionKey) => {
switch (section) { switch (section) {
case "profiles": case "profiles": {
return <Profiles />; return <Profiles />;
case "summary": }
case "summary": {
return <Summary />; return <Summary />;
case "experience": }
case "experience": {
return <Experience />; return <Experience />;
case "education": }
case "education": {
return <Education />; return <Education />;
case "awards": }
case "awards": {
return <Awards />; return <Awards />;
case "certifications": }
case "certifications": {
return <Certifications />; return <Certifications />;
case "skills": }
case "skills": {
return <Skills />; return <Skills />;
case "interests": }
case "interests": {
return <Interests />; return <Interests />;
case "publications": }
case "publications": {
return <Publications />; return <Publications />;
case "volunteer": }
case "volunteer": {
return <Volunteer />; return <Volunteer />;
case "languages": }
case "languages": {
return <Languages />; return <Languages />;
case "projects": }
case "projects": {
return <Projects />; return <Projects />;
case "references": }
case "references": {
return <References />; return <References />;
default: }
default: {
if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />; if (section.startsWith("custom.")) return <Custom id={section.split(".")[1]} />;
return null; return null;
}
} }
}; };

View File

@ -4,8 +4,3 @@ export type TemplateProps = {
columns: SectionKey[][]; columns: SectionKey[][];
isFirstPage?: boolean; isFirstPage?: boolean;
}; };
export type BaseProps = {
children?: React.ReactNode;
className?: string;
};

View File

@ -2,7 +2,8 @@
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
import react from "@vitejs/plugin-react-swc"; import react from "@vitejs/plugin-react-swc";
import { defineConfig, searchForWorkspaceRoot, splitVendorChunkPlugin } from "vite"; import { defineConfig, searchForWorkspaceRoot } from "vite";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
export default defineConfig({ export default defineConfig({
base: "/artboard/", base: "/artboard/",
@ -11,15 +12,22 @@ export default defineConfig({
build: { build: {
sourcemap: true, sourcemap: true,
emptyOutDir: true,
}, },
server: { server: {
host: true, host: true,
port: +(process.env.__DEV__ARTBOARD_PORT ?? 6173), port: 6173,
fs: { allow: [searchForWorkspaceRoot(process.cwd())] }, fs: { allow: [searchForWorkspaceRoot(process.cwd())] },
}, },
plugins: [react(), nxViteTsPaths(), splitVendorChunkPlugin()], plugins: [
react(),
nxViteTsPaths(),
chunkSplitPlugin({
strategy: "unbundle",
}),
],
resolve: { resolve: {
alias: { alias: {

View File

@ -16,6 +16,18 @@
}, },
"plugins": ["lingui"], "plugins": ["lingui"],
"rules": { "rules": {
// react
"react/no-unescaped-entities": "off",
"react/jsx-sort-props": [
"error",
{
"reservedFirst": true,
"callbacksLast": true,
"shorthandFirst": true,
"noSortAlphabetically": true
}
],
// react-hooks // react-hooks
"react-hooks/exhaustive-deps": "off", "react-hooks/exhaustive-deps": "off",

View File

@ -1,10 +1,10 @@
const { join } = require("path"); const path = require("node:path");
module.exports = { module.exports = {
plugins: { plugins: {
"postcss-import": {}, "postcss-import": {},
"tailwindcss/nesting": {}, "tailwindcss/nesting": {},
tailwindcss: { config: join(__dirname, "tailwind.config.js") }, tailwindcss: { config: path.join(__dirname, "tailwind.config.js") },
autoprefixer: {}, autoprefixer: {},
}, },
}; };

View File

@ -54,7 +54,7 @@ export const AiActions = ({ value, onChange, className }: Props) => {
toast({ toast({
variant: "error", variant: "error",
title: t`Oops, the server returned an error.`, title: t`Oops, the server returned an error.`,
description: (error as Error)?.message, description: (error as Error).message,
}); });
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -27,10 +27,7 @@ export const Copyright = ({ className }: Props) => (
<span>{t`By the community, for the community.`}</span> <span>{t`By the community, for the community.`}</span>
<span> <span>
<Trans> <Trans>
A passion project by{" "} A passion project by <a href="https://www.amruthpillai.com/">Amruth Pillai</a>
<a target="_blank" rel="noopener noreferrer nofollow" href="https://www.amruthpillai.com/">
Amruth Pillai
</a>
</Trans> </Trans>
</span> </span>

View File

@ -12,12 +12,14 @@ export const Icon = ({ size = 32, className }: Props) => {
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
switch (isDarkMode) { switch (isDarkMode) {
case false: case false: {
src = "/icon/dark.svg"; src = "/icon/dark.svg";
break; break;
case true: }
case true: {
src = "/icon/light.svg"; src = "/icon/light.svg";
break; break;
}
} }
return ( return (

View File

@ -38,8 +38,8 @@ export const LocaleCombobox = ({ value, onValueChange }: Props) => {
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
value={search} value={search}
onValueChange={setSearch}
placeholder={t`Search for a language`} placeholder={t`Search for a language`}
onValueChange={setSearch}
/> />
<CommandList> <CommandList>
<CommandEmpty>{t`No results found`}</CommandEmpty> <CommandEmpty>{t`No results found`}</CommandEmpty>
@ -48,10 +48,10 @@ export const LocaleCombobox = ({ value, onValueChange }: Props) => {
<div className="max-h-60"> <div className="max-h-60">
{options.map(({ original }) => ( {options.map(({ original }) => (
<CommandItem <CommandItem
disabled={false}
key={original.locale} key={original.locale}
disabled={false}
value={original.locale.trim()} value={original.locale.trim()}
onSelect={async (selectedValue) => { onSelect={(selectedValue) => {
const result = options.find( const result = options.find(
({ original }) => original.locale.trim() === selectedValue, ({ original }) => original.locale.trim() === selectedValue,
); );

View File

@ -12,12 +12,14 @@ export const Logo = ({ size = 32, className }: Props) => {
let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; let src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
switch (isDarkMode) { switch (isDarkMode) {
case false: case false: {
src = "/logo/light.svg"; src = "/logo/light.svg";
break; break;
case true: }
case true: {
src = "/logo/dark.svg"; src = "/logo/dark.svg";
break; break;
}
} }
return ( return (

View File

@ -12,9 +12,18 @@ export const UserAvatar = ({ size = 36, className }: Props) => {
if (!user) return null; if (!user) return null;
let picture: React.ReactNode = null; let picture: React.ReactNode;
if (!user.picture) { if (user.picture) {
picture = (
<img
alt={user.name}
src={user.picture}
className="rounded-full"
style={{ width: size, height: size }}
/>
);
} else {
const initials = getInitials(user.name); const initials = getInitials(user.name);
picture = ( picture = (
@ -25,15 +34,6 @@ export const UserAvatar = ({ size = 36, className }: Props) => {
{initials} {initials}
</div> </div>
); );
} else {
picture = (
<img
alt={user.name}
src={user.picture}
className="rounded-full"
style={{ width: size, height: size }}
/>
);
} }
return <div className={className}>{picture}</div>; return <div className={className}>{picture}</div>;

View File

@ -24,7 +24,11 @@ export const UserOptions = ({ children }: Props) => {
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" className="w-48"> <DropdownMenuContent side="top" align="start" className="w-48">
<DropdownMenuItem onClick={() => navigate("/dashboard/settings")}> <DropdownMenuItem
onClick={() => {
navigate("/dashboard/settings");
}}
>
{t`Settings`} {t`Settings`}
{/* eslint-disable-next-line lingui/no-unlocalized-strings */} {/* eslint-disable-next-line lingui/no-unlocalized-strings */}
<KeyboardShortcut>S</KeyboardShortcut> <KeyboardShortcut>S</KeyboardShortcut>

View File

@ -40,9 +40,9 @@ type Action =
toastId?: ToasterToast["id"]; toastId?: ToasterToast["id"];
}; };
interface State { type State = {
toasts: ToasterToast[]; toasts: ToasterToast[];
} };
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>(); const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@ -64,17 +64,19 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => { export const reducer = (state: State, action: Action): State => {
switch (action.type) { switch (action.type) {
case "ADD_TOAST": case "ADD_TOAST": {
return { return {
...state, ...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}; };
}
case "UPDATE_TOAST": case "UPDATE_TOAST": {
return { return {
...state, ...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)), toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
}; };
}
case "DISMISS_TOAST": { case "DISMISS_TOAST": {
const { toastId } = action; const { toastId } = action;
@ -82,9 +84,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) { if (toastId) {
addToRemoveQueue(toastId); addToRemoveQueue(toastId);
} else { } else {
state.toasts.forEach((toast) => { for (const toast of state.toasts) {
addToRemoveQueue(toast.id); addToRemoveQueue(toast.id);
}); }
} }
return { return {
@ -99,7 +101,7 @@ export const reducer = (state: State, action: Action): State => {
), ),
}; };
} }
case "REMOVE_TOAST": case "REMOVE_TOAST": {
if (action.toastId === undefined) { if (action.toastId === undefined) {
return { return {
...state, ...state,
@ -110,18 +112,19 @@ export const reducer = (state: State, action: Action): State => {
...state, ...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId), toasts: state.toasts.filter((t) => t.id !== action.toastId),
}; };
}
} }
}; };
const listeners: Array<(state: State) => void> = []; const listeners: ((state: State) => void)[] = [];
let memoryState: State = { toasts: [] }; let memoryState: State = { toasts: [] };
function dispatch(action: Action) { function dispatch(action: Action) {
memoryState = reducer(memoryState, action); memoryState = reducer(memoryState, action);
listeners.forEach((listener) => { for (const listener of listeners) {
listener(memoryState); listener(memoryState);
}); }
} }
type Toast = Omit<ToasterToast, "id">; type Toast = Omit<ToasterToast, "id">;
@ -129,12 +132,15 @@ type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ ...props }: Toast) {
const id = createId(); const id = createId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) => {
dispatch({ dispatch({
type: "UPDATE_TOAST", type: "UPDATE_TOAST",
toast: { ...props, id }, toast: { ...props, id },
}); });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); };
const dismiss = () => {
dispatch({ type: "DISMISS_TOAST", toastId: id });
};
dispatch({ dispatch({
type: "ADD_TOAST", type: "ADD_TOAST",
@ -170,7 +176,9 @@ function useToast() {
return { return {
...state, ...state,
toast, toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), dismiss: (toastId?: string) => {
dispatch({ type: "DISMISS_TOAST", toastId });
},
}; };
} }

View File

@ -4,18 +4,13 @@ import _axios from "axios";
import createAuthRefreshInterceptor from "axios-auth-refresh"; import createAuthRefreshInterceptor from "axios-auth-refresh";
import { redirect } from "react-router-dom"; import { redirect } from "react-router-dom";
import { refreshToken } from "@/client/services/auth";
import { USER_KEY } from "../constants/query-keys"; import { USER_KEY } from "../constants/query-keys";
import { toast } from "../hooks/use-toast"; import { toast } from "../hooks/use-toast";
import { refresh } from "../services/auth/refresh";
import { translateError } from "../services/errors/translate-error"; import { translateError } from "../services/errors/translate-error";
import { queryClient } from "./query-client"; import { queryClient } from "./query-client";
export type ServerError = {
statusCode: number;
message: string;
error: string;
};
export const axios = _axios.create({ baseURL: "/api", withCredentials: true }); export const axios = _axios.create({ baseURL: "/api", withCredentials: true });
// Intercept responses to transform ISO dates to JS date objects // Intercept responses to transform ISO dates to JS date objects
@ -36,7 +31,7 @@ axios.interceptors.response.use(
}); });
} }
return Promise.reject(error); return Promise.reject(new Error(message));
}, },
); );
@ -45,26 +40,12 @@ axios.interceptors.response.use(
const axiosForRefresh = _axios.create({ baseURL: "/api", withCredentials: true }); const axiosForRefresh = _axios.create({ baseURL: "/api", withCredentials: true });
// Interceptor to handle expired access token errors // Interceptor to handle expired access token errors
const handleAuthError = async () => { const handleAuthError = () => refreshToken(axiosForRefresh);
try {
await refresh(axiosForRefresh);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};
// Interceptor to handle expired refresh token errors // Interceptor to handle expired refresh token errors
const handleRefreshError = async () => { const handleRefreshError = async () => {
try { await queryClient.invalidateQueries({ queryKey: USER_KEY });
queryClient.invalidateQueries({ queryKey: USER_KEY }); redirect("/auth/login");
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 // Intercept responses to check for 401 and 403 errors, refresh token and retry the request

View File

@ -13,6 +13,7 @@ export async function dynamicActivate(locale: string) {
i18n.loadAndActivate({ locale, messages }); i18n.loadAndActivate({ locale, messages });
} }
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (dayjsLocales[locale]) { if (dayjsLocales[locale]) {
dayjs.locale(await dayjsLocales[locale]()); dayjs.locale(await dayjsLocales[locale]());
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,37 @@
import { StrictMode } from "react"; import * as Sentry from "@sentry/react";
import { StrictMode, useEffect } from "react";
import * as ReactDOM from "react-dom/client"; import * as ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom"; import {
createRoutesFromChildren,
matchRoutes,
RouterProvider,
useLocation,
useNavigationType,
} from "react-router-dom";
import { router } from "./router"; import { router } from "./router";
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement); if (import.meta.env.VITE_CLIENT_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_CLIENT_SENTRY_DSN,
integrations: [
Sentry.reactRouterV6BrowserTracingIntegration({
useEffect,
matchRoutes,
useLocation,
useNavigationType,
createRoutesFromChildren,
}),
Sentry.replayIntegration(),
],
tracesSampleRate: 1,
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: 1,
});
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const root = ReactDOM.createRoot(document.querySelector("#root")!);
root.render( root.render(
<StrictMode> <StrictMode>

Some files were not shown because too many files have changed in this diff Show More