Compare commits

...

76 Commits

Author SHA1 Message Date
a102f62e28 replace JSON.parse(JSON.stringify({})) with structuredClone({}) 2024-05-10 11:00:15 +02:00
c1a58118c2 - add AuthRefreshProvider to refresh auth tokens every 5 minutes
- pull the latest crowdin language artifacts
2024-05-10 10:52:35 +02:00
b0d26e3230 Merge pull request #1889 from AmruthPillai/l10n
New Translations from Crowdin
2024-05-09 08:06:29 +02:00
7e354b74bd New Crowdin translations by GitHub Action 2024-05-09 00:08:50 +00:00
e20bcb8c14 Merge pull request #1886 from AmruthPillai/l10n
New Translations from Crowdin
2024-05-08 09:21:26 +02:00
506058aacb New Crowdin translations by GitHub Action 2024-05-08 00:07:50 +00:00
7b394f1437 pin versions of pnpm and node 2024-05-07 12:33:16 +02:00
e2e2551db4 fix lint-test-build workflow 2024-05-07 12:31:01 +02:00
52e062c0b5 fix eslint issues 2024-05-07 12:02:39 +02:00
e3785030e1 add code chunking to vite.config.ts 2024-05-07 11:45:33 +02:00
e21430a421 Merge pull request #1883 from AmruthPillai/l10n
New Translations from Crowdin
2024-05-07 09:25:38 +02:00
f66704af88 New Crowdin translations by GitHub Action 2024-05-07 00:08:07 +00:00
7a65363296 add test to github action 2024-05-05 17:06:08 +02:00
8180e8c7b8 add SENTRY_AUTH_TOKEN 2024-05-05 16:50:01 +02:00
13b2a5be94 add max-parallel:1 to github action 2024-05-05 16:09:29 +02:00
4dd5367572 attempt to fix lint issues 2024-05-05 15:02:16 +02:00
e87b05a93a release: v4.1.0 2024-05-05 14:55:06 +02:00
68252c35fc update postgres version in templates 2024-05-03 23:03:23 +02:00
ac9b280bd5 revert runs-on 2024-05-03 21:48:28 +02:00
32b8407b1a experimenting with self-hosted action runners 2024-05-03 21:18:25 +02:00
989e8dee5b update dependencies 2024-05-03 20:55:13 +02:00
5ed561812f add privacy policy, bump up version to 4.0.13 2024-05-03 20:53:58 +02:00
cba2eda5d0 fix artifact name 2024-05-03 16:43:20 +02:00
1c7b44b604 migrate actions/upload-artifact to v4 2024-05-03 15:29:59 +02:00
5d61e865a8 changes to Dockerfile 2024-05-03 15:12:38 +02:00
f32a85cec9 add emptyOutDir: true to vite.config.ts 2024-05-03 15:07:07 +02:00
c99ce90cf8 add ARG NX_CLOUD_ACCESS_TOKEN to Dockerfile 2024-05-03 14:52:59 +02:00
6e2b960bdb fix dependencies installation location 2024-05-03 14:31:13 +02:00
862c812ee1 add files to .dockerignore 2024-05-03 14:22:28 +02:00
6424b15b76 update versions of workflows 2024-05-03 14:08:49 +02:00
5e32673358 modify action workflows 2024-05-03 14:04:20 +02:00
470f187c0b fix Dockerfile 2024-05-03 14:02:08 +02:00
8b966946ea remove unnecessary pnpm-lock.yaml files 2024-05-03 13:53:39 +02:00
8579a4c98d Merge pull request #1871 from AmruthPillai/l10n
New Translations from Crowdin
2024-05-03 13:31:56 +02:00
8deff757a9 fix tiptap issues, update dependencies, fix typescript issues with minio client 2024-05-03 12:05:54 +02:00
d227cf64aa New Crowdin translations by GitHub Action 2024-05-03 00:08:45 +00:00
458af1d840 Merge pull request #1870 from AmruthPillai/l10n
New Translations from Crowdin
2024-04-22 19:42:30 +02:00
0024aec60a New Crowdin translations by GitHub Action 2024-04-22 00:08:42 +00:00
ec86536ace Merge pull request #1864 from AmruthPillai/l10n
New Translations from Crowdin
2024-04-18 06:20:17 +02:00
f7e2bfb078 New Crowdin translations by GitHub Action 2024-04-18 00:08:40 +00:00
168be7dfb8 Merge pull request #1861 from AmruthPillai/l10n
New Translations from Crowdin
2024-04-12 09:12:00 +02:00
832d0002e9 New Crowdin translations by GitHub Action 2024-04-12 00:08:14 +00:00
1f9e3aa9d1 update dependencies, update translations 2024-04-10 15:43:56 +02:00
dd97c6d71f Merge pull request #1856 from WangZhiYao/main
feat(server): Support for SMTP-over-TLS
2024-04-09 10:30:17 +02:00
339
1f5dce2233 feat(server): Support for SMTP-over-TLS 2024-04-09 14:42:47 +08:00
fe77b14807 Merge pull request #1855 from AmruthPillai/l10n
New Translations from Crowdin
2024-04-08 11:10:22 +02:00
a36c49fa77 New Crowdin translations by GitHub Action 2024-04-08 00:08:19 +00:00
709bf0a526 Merge pull request #1851 from AmruthPillai/l10n
New Translations from Crowdin
2024-04-05 08:49:19 +02:00
7189c7f203 New Crowdin translations by GitHub Action 2024-04-05 00:08:37 +00:00
cdef456aac update dependencies 2024-04-03 09:58:08 +02:00
a578bd1054 Merge pull request #1836 from AmruthPillai/l10n
New Translations from Crowdin
2024-03-31 10:22:04 +02:00
116c038861 New Crowdin translations by GitHub Action 2024-03-31 00:09:18 +00:00
6e6914fe6b Merge pull request #1830 from AmruthPillai/l10n
New Translations from Crowdin
2024-03-27 08:15:01 +01:00
2bf04f2616 New Crowdin translations by GitHub Action 2024-03-27 00:08:21 +00:00
f6c2ae7504 remove banner on mobile/tablet devices 2024-03-25 15:08:39 +01:00
890875ad9d - upgrade to browserless v2
- add missing languages
- add donation banner
- update dependencies
- bump version to 4.0.9
2024-03-22 12:11:15 +01:00
11953af700 Merge pull request #1816 from AmruthPillai/dependabot/npm_and_yarn/follow-redirects-1.15.6
Bump follow-redirects from 1.15.5 to 1.15.6
2024-03-19 09:17:57 +01:00
3c774102cf Merge pull request #1809 from Rash-Hit/debouncing-in-profile-icon
debounce in profile icon
2024-03-19 09:17:28 +01:00
fbf92160a3 Merge pull request #1815 from AmruthPillai/l10n
New Translations from Crowdin
2024-03-19 09:16:33 +01:00
a798845865 New Crowdin translations by GitHub Action 2024-03-19 00:08:05 +00:00
7db57e04c0 Bump follow-redirects from 1.15.5 to 1.15.6
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-17 01:21:40 +00:00
b23efa773f Merge pull request #1813 from AmruthPillai/l10n
New Translations from Crowdin
2024-03-14 14:04:42 +01:00
3b41b32f09 New Crowdin translations by GitHub Action 2024-03-14 00:07:49 +00:00
5513b909e7 Merge pull request #1810 from AmruthPillai/l10n
New Translations from Crowdin
2024-03-12 06:46:38 +01:00
c1a50d4125 New Crowdin translations by GitHub Action 2024-03-12 00:07:52 +00:00
7babde2d62 Merge branch 'AmruthPillai:main' into debouncing-in-profile-icon 2024-03-11 16:58:04 +05:30
08a6415ba8 -- code refactored -- 2024-03-11 11:25:45 +00:00
1ee9478200 debounce in profile icon 2024-03-11 10:28:50 +00:00
bbe8fb6655 Merge pull request #1808 from AmruthPillai/1805-bugfont-subset-and-font-variants-not-working
Add CommandList to Combobox component
2024-03-11 09:57:07 +01:00
6405102cab add CommandList to Combobox component 2024-03-11 09:54:10 +01:00
32445a5cd7 Merge pull request #1804 from AmruthPillai/l10n
New Translations from Crowdin
2024-03-11 09:44:55 +01:00
5494d93e1d New Crowdin translations by GitHub Action 2024-03-11 00:08:17 +00:00
ad9647a3f4 revert @nestjs-modules/mailer to 1.10.3 2024-03-10 11:19:51 +01:00
9ee7e3195b bump up version to 4.0.7 2024-03-10 11:14:23 +01:00
53dfd4cb09 fix language selector 2024-03-10 11:12:15 +01:00
7ceb0f6e39 add symbolic links to prettierignore 2024-03-10 10:50:45 +01:00
315 changed files with 34925 additions and 27456 deletions

View File

@ -1,22 +1,62 @@
!README.md
.dockerignore
# Compiled Output
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
Thumbs.db
.editorconfig
.eslint*
.git
.github
.gitignore
.husky
# Generated Files
.nx
.prettier*
.vscode
*.env*
*.md
compose*.yml
dist
Dockerfile
node_modules
Thumbs.db
tmp
.swc
fly.toml
stats.html
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
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
# 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
@ -53,19 +45,22 @@ STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_USE_SSL=false
# Redis (for cache & server session management)
REDIS_URL=redis://default:password@localhost:6379
# 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_PROJECT_ID=
CROWDIN_PERSONAL_TOKEN=
# CROWDIN_PROJECT_ID=
# CROWDIN_PERSONAL_TOKEN=
# Email (Optional)
# Flags (Optional)
# DISABLE_EMAIL_AUTH=true
# VITE_DISABLE_SIGNUPS=false
# SKIP_STORAGE_BUCKET_CHECK=false
# GitHub (OAuth, Optional)
# GITHUB_CLIENT_ID=

View File

@ -8,6 +8,9 @@
"extends": ["plugin:prettier/recommended"],
"plugins": ["simple-import-sort", "unused-imports"],
"rules": {
// eslint
"no-return-await": "off",
// simple-import-sort
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
@ -43,10 +46,37 @@
},
{
"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": {
// 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"
}
},
{

View File

@ -21,7 +21,7 @@ body:
label: Product Variant
description: What variant of Reactive Resume are you using?
options:
- Cloud (http://rxresu.me)
- Cloud (https://rxresu.me)
- Self-Hosted
validations:
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:
group: ${{ github.workflow }}-${{ github.ref }}
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: true
env:
@ -21,7 +21,7 @@ jobs:
version: ${{ steps.version.outputs.version }}
strategy:
fail-fast: false
max-parallel: 1
matrix:
platform:
- linux/amd64
@ -29,7 +29,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.4
- name: Extract version from package.json
id: version
@ -38,42 +38,51 @@ jobs:
- 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
uses: docker/login-action@v3.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registery
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.3.0
with:
driver: cloud
version: "lab:latest"
endpoint: "amruthpillai/default"
- name: Extract Docker Metadata
id: meta
uses: docker/metadata-action@v5.0.0
uses: docker/metadata-action@v5.5.1
with:
tags: type=semver,pattern={{version}},prefix=v,value=${{ steps.version.outputs.version }}
images: |
${{ 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
uses: docker/build-push-action@v5.0.0
uses: docker/build-push-action@v5.3.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: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
NX_CLOUD_ACCESS_TOKEN=${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Export Digest
@ -83,9 +92,9 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload Digest
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4.3.3
with:
name: digests
name: digests-${{ steps.artifact_name.outputs.name }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
@ -98,33 +107,38 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4.1.1
uses: actions/checkout@v4.1.4
- name: Download Digest
uses: actions/download-artifact@v3.0.0
uses: actions/download-artifact@v4.1.7
with:
name: digests
path: /tmp/digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
pattern: digests-*
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Login to GitHub Container Registery
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.3.0
with:
driver: cloud
version: "lab:latest"
endpoint: "amruthpillai/default"
- name: Extract Docker Metadata
id: meta
uses: docker/metadata-action@v5.0.0
uses: docker/metadata-action@v5.5.1
with:
tags: type=semver,pattern={{version}},prefix=v,value=${{ needs.build.outputs.version }}
images: |
@ -142,7 +156,7 @@ jobs:
docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
- name: Update Repository Description
uses: peter-evans/dockerhub-description@v3
uses: peter-evans/dockerhub-description@v4.0.0
with:
repository: ${{ github.repository }}
username: ${{ secrets.DOCKER_USERNAME }}

9
.gitignore vendored
View File

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

7
.ncurc.json Normal file
View File

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

View File

@ -2,4 +2,6 @@
/coverage
/.nx/cache
stats.html
pnpm-lock.yaml
pnpm-lock.yaml
compose-dev.yml
compose.yml

View File

@ -10,5 +10,6 @@
"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 **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).

View File

@ -2,7 +2,7 @@
## 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:
@ -57,15 +57,13 @@ You can also visit `http://localhost:3000/api/health`, the health check endpoint
"info": {
"database": { "status": "up" },
"storage": { "status": "up" },
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" },
"redis": { "status": "up" }
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" }
},
"error": {},
"details": {
"database": { "status": "up" },
"storage": { "status": "up" },
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" },
"redis": { "status": "up" }
"browser": { "status": "up", "version": "Chrome/119.0.6045.9" }
}
}
```

View File

@ -1,39 +1,49 @@
ARG SENTRY_AUTH_TOKEN
ARG NX_CLOUD_ACCESS_TOKEN
# --- Base Image ---
FROM node:lts-bullseye-slim AS base
ARG SENTRY_AUTH_TOKEN
ARG NX_CLOUD_ACCESS_TOKEN
ENV PNPM_HOME="/pnpm"
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
# --- Build Image ---
FROM base AS build
ENV NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_ACCESS_TOKEN
ARG SENTRY_AUTH_TOKEN
ARG 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 ./tools/prisma /app/tools/prisma
RUN pnpm install --frozen-lockfile
COPY . .
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV NX_CLOUD_ACCESS_TOKEN=$NX_CLOUD_ACCESS_TOKEN
RUN pnpm run build
# --- Release Image ---
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 ./
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/tools/prisma ./tools/prisma
RUN pnpm run prisma:generate
ENV TZ=UTC
ENV PORT=3000
ENV NODE_ENV=production
EXPOSE 3000

View File

@ -40,11 +40,11 @@ Start creating your standout resume with Reactive Resume today!
- **Free, forever** and open-source
- 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/))
- 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 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
- 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
@ -69,7 +69,6 @@ Start creating your standout resume with Reactive Resume today!
- NestJS, for the backend
- Postgres (primary database)
- 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)
- SMTP Server (to send password recovery emails)

View File

@ -11,4 +11,4 @@
## 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": {
// react
"react/no-unescaped-entities": "off",
"react/jsx-sort-props": [
"error",
{
"reservedFirst": true,
"callbacksLast": true,
"shorthandFirst": true,
"noSortAlphabetically": true
}
],
// react-hooks
"react-hooks/exhaustive-deps": "off",

View File

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

View File

@ -4,7 +4,8 @@ import { RouterProvider } from "react-router-dom";
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(
<StrictMode>

View File

@ -39,20 +39,20 @@ export const ArtboardPage = () => {
`${metadata.typography.lineHeight}`,
);
document.documentElement.style.setProperty("--color-text", `${metadata.theme.text}`);
document.documentElement.style.setProperty("--color-primary", `${metadata.theme.primary}`);
document.documentElement.style.setProperty(
"--color-background",
`${metadata.theme.background}`,
);
document.documentElement.style.setProperty("--color-text", metadata.theme.text);
document.documentElement.style.setProperty("--color-primary", metadata.theme.primary);
document.documentElement.style.setProperty("--color-background", metadata.theme.background);
}, [metadata]);
// Typography Options
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("underline-links", metadata.typography.underlineLinks);
});
}
}, [metadata]);
return <Outlet />;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,8 +4,3 @@ export type TemplateProps = {
columns: SectionKey[][];
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 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({
base: "/artboard/",
@ -11,15 +12,22 @@ export default defineConfig({
build: {
sourcemap: true,
emptyOutDir: true,
},
server: {
host: true,
port: +(process.env.__DEV__ARTBOARD_PORT ?? 6173),
port: 6173,
fs: { allow: [searchForWorkspaceRoot(process.cwd())] },
},
plugins: [react(), nxViteTsPaths(), splitVendorChunkPlugin()],
plugins: [
react(),
nxViteTsPaths(),
chunkSplitPlugin({
strategy: "unbundle",
}),
],
resolve: {
alias: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
@ -37,39 +38,43 @@ export const LocaleCombobox = ({ value, onValueChange }: Props) => {
<Command shouldFilter={false}>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder={t`Search for a language`}
onValueChange={setSearch}
/>
<CommandEmpty>{t`No results found`}</CommandEmpty>
<CommandGroup>
<ScrollArea orientation="vertical">
<div className="max-h-60">
{options.map(({ original }) => (
<CommandItem
key={original.locale}
value={original.locale.trim().toLowerCase()}
onSelect={async (selectedValue) => {
const result = options.find(
({ original }) => original.locale.trim().toLowerCase() === selectedValue,
);
<CommandList>
<CommandEmpty>{t`No results found`}</CommandEmpty>
<CommandGroup>
<ScrollArea orientation="vertical">
<div className="max-h-60">
{options.map(({ original }) => (
<CommandItem
key={original.locale}
disabled={false}
value={original.locale.trim()}
onSelect={(selectedValue) => {
const result = options.find(
({ original }) => original.locale.trim() === selectedValue,
);
if (!result) return null;
if (!result) return null;
onValueChange(result.original.locale);
}}
>
<Check
className={cn(
"mr-2 size-4 opacity-0",
value === original.locale && "opacity-100",
)}
/>
{original.name} <span className="ml-1 text-xs opacity-50">({original.locale})</span>
</CommandItem>
))}
</div>
</ScrollArea>
</CommandGroup>
onValueChange(result.original.locale);
}}
>
<Check
className={cn(
"mr-2 size-4 opacity-0",
value === original.locale && "opacity-100",
)}
/>
{original.name}{" "}
<span className="ml-1 text-xs opacity-50">({original.locale})</span>
</CommandItem>
))}
</div>
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
);
};

View File

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

View File

@ -12,9 +12,18 @@ export const UserAvatar = ({ size = 36, className }: Props) => {
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);
picture = (
@ -25,15 +34,6 @@ export const UserAvatar = ({ size = 36, className }: Props) => {
{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>;

View File

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

View File

@ -40,9 +40,9 @@ type Action =
toastId?: ToasterToast["id"];
};
interface State {
type State = {
toasts: ToasterToast[];
}
};
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
@ -64,17 +64,19 @@ const addToRemoveQueue = (toastId: string) => {
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
case "ADD_TOAST": {
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
}
case "UPDATE_TOAST":
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;
@ -82,9 +84,9 @@ export const reducer = (state: State, action: Action): State => {
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
});
}
}
return {
@ -99,7 +101,7 @@ export const reducer = (state: State, action: Action): State => {
),
};
}
case "REMOVE_TOAST":
case "REMOVE_TOAST": {
if (action.toastId === undefined) {
return {
...state,
@ -110,18 +112,19 @@ export const reducer = (state: State, action: Action): State => {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
};
const listeners: Array<(state: State) => void> = [];
const listeners: ((state: State) => void)[] = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
for (const listener of listeners) {
listener(memoryState);
});
}
}
type Toast = Omit<ToasterToast, "id">;
@ -129,12 +132,15 @@ type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = createId();
const update = (props: ToasterToast) =>
const update = (props: ToasterToast) => {
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
};
const dismiss = () => {
dispatch({ type: "DISMISS_TOAST", toastId: id });
};
dispatch({
type: "ADD_TOAST",
@ -170,7 +176,9 @@ function useToast() {
return {
...state,
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 { redirect } from "react-router-dom";
import { refreshToken } from "@/client/services/auth";
import { USER_KEY } from "../constants/query-keys";
import { toast } from "../hooks/use-toast";
import { refresh } from "../services/auth/refresh";
import { translateError } from "../services/errors/translate-error";
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
@ -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 });
// Interceptor to handle expired access token errors
const handleAuthError = async () => {
try {
await refresh(axiosForRefresh);
return Promise.resolve();
} catch (error) {
return Promise.reject(error);
}
};
const handleAuthError = () => refreshToken(axiosForRefresh);
// 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);
}
await queryClient.invalidateQueries({ queryKey: USER_KEY });
redirect("/auth/login");
};
// 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 });
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (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

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