Compare commits

...

76 Commits

Author SHA1 Message Date
4b1ce539d5 remove sentry integration 2022-11-24 16:58:36 +01:00
a6fbb8191d Update docker-build-push.yml 2022-11-24 16:32:22 +01:00
552ff281b8 I have no idea what I'm doing here. 2022-11-24 16:29:36 +01:00
54fad2f6d8 update docker-build-push.yml 2022-11-24 16:20:40 +01:00
78edcd7d0e fix typo in github workflow 2022-11-24 16:02:39 +01:00
a8034b21d5 attempting to fix github actions 2022-11-24 15:58:27 +01:00
f0e95905d2 trying out env instead of secrets 2022-11-24 15:44:28 +01:00
69a5276614 attempt to fix locale issue 2022-11-24 15:40:49 +01:00
2e62eea351 fix sentry issue: 28c5a41aea3c4435902046e56c435e56 2022-11-24 15:38:21 +01:00
13d972b8f3 update docker-build-push.yaml 2022-11-24 15:15:35 +01:00
03cb198e95 move from env to secrets 2022-11-24 15:11:55 +01:00
67ee55b502 fix env for sentry auth token 2022-11-24 15:01:33 +01:00
b5998d7f3a pass sentry token to docker build push step 2022-11-24 14:58:26 +01:00
f71cf99b77 remove .git from .dockerignore 2022-11-24 13:27:43 +01:00
a2092a6a39 revert version back to 3.6.12 2022-11-24 12:58:15 +01:00
43c09666a0 add sentry CLI to github actions 2022-11-24 11:42:03 +01:00
0da23f95fd Merge pull request #1101 from stonespheres/patch-1
Fix link typo
2022-11-24 11:25:17 +01:00
e8f44e2142 update pnpm-lock.yaml 2022-11-24 11:23:37 +01:00
fbb237e982 Fix link typo
Bad practice on my part for last commit - did not check before push.
Link under table of contents fixed and now directs to the documentation at https://docs.rxresu.me
2022-11-24 18:22:57 +08:00
7f7c1d7b87 update version to 3.6.13 2022-11-24 11:21:45 +01:00
be0b7f20f9 integrate sentry for error logging 2022-11-24 11:21:30 +01:00
0672988fff Merge pull request #1100 from stonespheres/patch-1
Fixed formatting and typos on README.md
2022-11-24 11:01:38 +01:00
75dad60cb5 Fixed formatting and typos on README.md
- Under Table of Contents: Fixed the formatting error for the link to the docs.
- Under Languages: fixed typo Ukranian -> Ukrainian
- Under Building from Source: ...head over to the doc's -> head over to the docs
2022-11-24 17:47:29 +08:00
0140e3fce0 update version to 3.6.12 2022-11-23 15:20:34 +01:00
42d0e14b98 fix styling issues and theme cascades across all templates 2022-11-23 15:20:04 +01:00
9a42d684fb add branching deploy condition 2022-11-23 14:15:36 +01:00
ab6ad65445 update github actions to a more streamlined workflow using gh matrix 2022-11-23 14:10:14 +01:00
b613764ccc fix matrix variable name 2022-11-23 13:59:25 +01:00
ac44d0489f change name of test action so as to not trigger further actions 2022-11-23 13:56:59 +01:00
c57e6fbbb8 fix versioning of github action package 2022-11-23 13:56:22 +01:00
6c6da215c8 add on: [workflow_dispatch] to test github action 2022-11-23 13:55:15 +01:00
be700c7629 Testing a new streamlined GitHub Actions workflow 2022-11-23 13:53:41 +01:00
b697f73492 fix #1096 2022-11-23 13:11:29 +01:00
3106f94989 - update version to v.3.6.11
- update dependencies to latest versions
2022-11-23 13:04:59 +01:00
50f41f73d5 Add detailed description to page title, to increase SEO 2022-11-23 12:57:07 +01:00
83e3f59e68 fix #1082 2022-11-23 12:47:29 +01:00
056c61e985 resolves #1061, resolves #1027, resolves #1007, resolves #1001, resolves #987, resolves #890, resolves #882, resolves #837 2022-11-23 12:24:17 +01:00
d1a1b68302 fix #1095: make PDF_DELETION_TIME optional, add default value 2022-11-23 11:51:28 +01:00
6bd7b9a50f Merge pull request #1092 from GETandSELECT/main
Update common.json - tiny translation error to German
2022-11-23 11:41:54 +01:00
e6967aab88 Update common.json 2022-11-22 10:03:14 +00:00
47e96803e3 fix password recovery link 2022-11-19 09:37:23 +01:00
f9ef4d0a64 fix max width of description 2022-11-18 09:42:59 +01:00
c4b4e6013f Merge pull request #1073 from AmruthPillai/dependabot/gradle/app/org.jetbrains.kotlin.android-1.7.21
Bump org.jetbrains.kotlin.android from 1.7.20 to 1.7.21 in /app
2022-11-15 11:36:04 +01:00
24bbc46c32 Merge pull request #1075 from RobbeVanslambrouck/main
fix typos in English and Dutch translation
2022-11-15 11:35:54 +01:00
85bc9ef124 fix typos in English and Dutch translation 2022-11-14 17:29:09 +01:00
33755a8573 remove console.log 2022-11-14 10:06:19 +01:00
ab45321889 fixes #1074 2022-11-14 10:05:51 +01:00
940b310f64 Bump org.jetbrains.kotlin.android from 1.7.20 to 1.7.21 in /app
Bumps [org.jetbrains.kotlin.android](https://github.com/JetBrains/kotlin) from 1.7.20 to 1.7.21.
- [Release notes](https://github.com/JetBrains/kotlin/releases)
- [Changelog](https://github.com/JetBrains/kotlin/blob/master/ChangeLog.md)
- [Commits](https://github.com/JetBrains/kotlin/compare/v1.7.20...v1.7.21)

---
updated-dependencies:
- dependency-name: org.jetbrains.kotlin.android
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-14 02:06:07 +00:00
8026241b6c release: v3.6.9 2022-11-13 14:28:47 +01:00
89b35392bd Merge pull request #1060 from sashokbg/feat/multiple_work_sections
feat: additional work sections
2022-11-13 10:28:28 +01:00
62eb239ec4 Merge pull request #1023 from klejejs/main
Add PDF file caching
2022-11-13 10:28:19 +01:00
7fdf8c1f0c Merge pull request #1069 from arvaid/main
fixed grammatical and stylistic errors in Hungarian translation
2022-11-07 09:28:00 +01:00
538697238a fixed grammatical and stylistic errors in Hungarian translation 2022-11-06 14:46:55 +01:00
7bc4a998fe feat: additional work sections 2022-11-03 17:59:15 +01:00
e33df485ab Merge pull request #951 from Leopere/patch-3
Cleanup superfluous docker-compose.yml declarations
2022-11-02 23:36:21 +01:00
36ae54fe17 Merge branch 'main' into patch-3 2022-11-02 23:36:13 +01:00
50958fd6df Merge pull request #1058 from klejejs/fix/zip_file_upload_crash
Fix server crash when non-zip file is uploaded
2022-10-26 07:57:49 +02:00
e9e595f0d0 Fix server crash when non-zip file is uploaded 2022-10-25 22:03:18 +03:00
43ddfba777 Add scheduled deletion for cached PDF files 2022-10-25 21:10:40 +03:00
78a32961d7 Add PDF file caching 2022-10-25 20:16:39 +03:00
9b1f3eda05 Merge pull request #1053 from AmruthPillai/i18n_main
New Crowdin updates
2022-10-24 07:58:36 +02:00
1154621e5c Merge pull request #1057 from AmruthPillai/dependabot/github_actions/docker/setup-buildx-action-2.2.1
Bump docker/setup-buildx-action from 2.1.0 to 2.2.1
2022-10-24 07:58:25 +02:00
e7aeee77a7 Merge pull request #1056 from AmruthPillai/dependabot/github_actions/digitalocean/action-doctl-2.2.0
Bump digitalocean/action-doctl from 2.1.1 to 2.2.0
2022-10-24 07:58:16 +02:00
fab3988a36 Bump docker/setup-buildx-action from 2.1.0 to 2.2.1
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.1.0 to 2.2.1.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.1.0...v2.2.1)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-24 02:53:50 +00:00
354cad88d3 Bump digitalocean/action-doctl from 2.1.1 to 2.2.0
Bumps [digitalocean/action-doctl](https://github.com/digitalocean/action-doctl) from 2.1.1 to 2.2.0.
- [Release notes](https://github.com/digitalocean/action-doctl/releases)
- [Commits](https://github.com/digitalocean/action-doctl/compare/v2.1.1...v2.2.0)

---
updated-dependencies:
- dependency-name: digitalocean/action-doctl
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-24 02:53:45 +00:00
876f930f30 New translations builder.json (Indonesian) 2022-10-21 05:55:29 +02:00
5b3ea46f0f Merge pull request #1045 from AmruthPillai/dependabot/github_actions/docker/setup-buildx-action-2.1.0
Bump docker/setup-buildx-action from 2.0.0 to 2.1.0
2022-10-17 10:39:55 +02:00
37a2563c11 Merge pull request #1047 from AmruthPillai/dependabot/github_actions/docker/build-push-action-3.2.0
Bump docker/build-push-action from 3.1.1 to 3.2.0
2022-10-17 10:39:42 +02:00
cb977a146b Merge pull request #1046 from AmruthPillai/dependabot/github_actions/docker/login-action-2.1.0
Bump docker/login-action from 2.0.0 to 2.1.0
2022-10-17 10:39:31 +02:00
72b2551b6d Merge pull request #1044 from AmruthPillai/dependabot/github_actions/docker/setup-qemu-action-2.1.0
Bump docker/setup-qemu-action from 2.0.0 to 2.1.0
2022-10-17 10:39:22 +02:00
c94633e616 Bump docker/build-push-action from 3.1.1 to 3.2.0
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3.1.1 to 3.2.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3.1.1...v3.2.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-17 02:21:39 +00:00
7fee2d670f Bump docker/login-action from 2.0.0 to 2.1.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-17 02:21:36 +00:00
837b06eb38 Bump docker/setup-buildx-action from 2.0.0 to 2.1.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-17 02:21:33 +00:00
2b8860b21c Bump docker/setup-qemu-action from 2.0.0 to 2.1.0
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2.0.0 to 2.1.0.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2.0.0...v2.1.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-10-17 02:21:31 +00:00
3a7b98d30e Fix issue with variable accessor 2022-10-15 23:47:16 +02:00
fc0b69796f Cleanup superfluous docker-compose.yml declarations 2022-07-25 10:15:03 -04:00
101 changed files with 2676 additions and 1906 deletions

View File

@ -2,9 +2,9 @@
/app
# Build Artifacts
dist
.next
.turbo
**/.turbo
/server/dist
/client/.next
# IDEs
.vscode
@ -19,7 +19,7 @@ CHANGELOG.md
CODE_OF_CONDUCT.md
# Project Dependencies
node_modules
**/node_modules
# Docker
Dockerfile

View File

@ -34,6 +34,7 @@ STORAGE_ENDPOINT=
STORAGE_URL_PREFIX=
STORAGE_ACCESS_KEY=
STORAGE_SECRET_KEY=
PDF_DELETION_TIME=345600000
# Flags (Client)
PUBLIC_FLAG_DISABLE_SIGNUPS=false

View File

@ -1,6 +1,7 @@
name: Deploy Latest Version on DigitalOcean
on:
workflow_dispatch:
workflow_run:
workflows:
- Build and Push Docker Image
@ -8,14 +9,21 @@ on:
- completed
jobs:
deploy:
on-success:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Install DigitalOcean CLI
uses: digitalocean/action-doctl@v2.1.1
uses: digitalocean/action-doctl@v2.2.0
with:
token: ${{ secrets.DIGITALOCEAN_TOKEN }}
- name: Create Deployment with Latest Version
run: doctl apps create-deployment ${{ secrets.DIGITALOCEAN_APP_ID }} --wait --force-rebuild
on-failure:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
steps:
- name: Abruptly end the worklfow
run: exit 1

View File

@ -1,112 +1,63 @@
name: Build and Push Docker Image
on:
workflow_dispatch:
release:
types: [published]
jobs:
client:
name: Client
build_matrix:
name: Build and Push Docker Image
runs-on: ubuntu-latest
env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
strategy:
matrix:
arch: [amd64, arm64]
image: [client, server]
steps:
- name: Checkout the repository
uses: actions/checkout@v3.1.0
with:
fetch-depth: 2
- id: version
name: Get Version
run: echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
name: App Version
uses: martinbeentjes/npm-get-version-action@v1.2.3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
uses: docker/setup-qemu-action@v2.1.0
with:
platforms: ${{ matrix.arch }}
- id: buildx
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.0.0
with:
install: true
uses: docker/setup-buildx-action@v2.2.1
- name: Login to Docker Hub
uses: docker/login-action@v2.0.0
uses: docker/login-action@v2.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.0.0
uses: docker/login-action@v2.1.0
with:
registry: ghcr.io
username: $GITHUB_REPOSITORY_OWNER
password: ${{ secrets.GH_TOKEN }}
- name: Build and Push Client Image
uses: docker/build-push-action@v3.1.1
- name: Build and Push Docker Image
uses: docker/build-push-action@v3.2.0
with:
context: .
push: true
file: client/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.arch }}
file: ${{ matrix.image }}/Dockerfile
tags: |
amruthpillai/reactive-resume:client-latest
amruthpillai/reactive-resume:client-${{ env.version }}
ghcr.io/amruthpillai/reactive-resume:client-latest
ghcr.io/amruthpillai/reactive-resume:client-${{ env.version }}
server:
name: Server
runs-on: ubuntu-latest
env:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v3.1.0
with:
fetch-depth: 2
- id: version
name: Get Version
run: echo "version=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- id: buildx
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2.0.0
with:
install: true
- name: Login to Docker Hub
uses: docker/login-action@v2.0.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2.0.0
with:
registry: ghcr.io
username: $GITHUB_REPOSITORY_OWNER
password: ${{ secrets.GH_TOKEN }}
- name: Build and Push Server Image
uses: docker/build-push-action@v3.1.1
with:
context: .
push: true
file: server/Dockerfile
platforms: linux/amd64,linux/arm64
tags: |
amruthpillai/reactive-resume:server-latest
amruthpillai/reactive-resume:server-${{ env.version }}
ghcr.io/amruthpillai/reactive-resume:server-latest
ghcr.io/amruthpillai/reactive-resume:server-${{ env.version }}
amruthpillai/reactive-resume:${{ matrix.image }}-latest
amruthpillai/reactive-resume:${{ matrix.image }}-${{ steps.version.outputs.current-version }}
ghcr.io/amruthpillai/reactive-resume:${{ matrix.image }}-latest
ghcr.io/amruthpillai/reactive-resume:${{ matrix.image }}-${{ steps.version.outputs.current-version }}

5
.gitignore vendored
View File

@ -10,4 +10,7 @@ node_modules
.DS_Store
# Turbo
.turbo
.turbo
# Intellij
.idea

View File

@ -18,7 +18,7 @@ You have complete control over what goes into your resume, how it looks, what co
## Table of Contents
- [Reactive Resume](#reactive-resume)
- [Go to App | [Docs](https://docs.rxresu.me)](#go-to-app--docs)
- [Go to App Docs](https://docs.rxresu.me)
- [Table of Contents](#table-of-contents)
- [Features](#features)
- [Languages](#languages)
@ -93,7 +93,7 @@ You have complete control over what goes into your resume, how it looks, what co
- Swedish (Svenska)
- Tamil (தமிழ்)
- Turkish (Türkçe)
- Ukranian (Українська мова)
- Ukrainian (Українська мова)
- Vietnamese (Tiếng Việt)
Help by [translating Reactive Resume](https://translate.rxresu.me) to your language!
@ -104,7 +104,7 @@ The docs include an extensive [Tutorial](https://docs.rxresu.me/tutorial) sectio
## Build from Source
For extensive information on how to build the app on your local machine, head over to the docs's [Source Code](https://docs.rxresu.me/source-code) section.
For extensive information on how to build the app on your local machine, head over to the docs [Source Code](https://docs.rxresu.me/source-code) section.
## Contributing

View File

@ -1,7 +1,7 @@
plugins {
id 'com.android.application' version '7.1.2' apply false
id 'com.android.library' version '7.1.2' apply false
id 'org.jetbrains.kotlin.android' version '1.7.20' apply false
id 'org.jetbrains.kotlin.android' version '1.7.21' apply false
}
task clean(type: Delete) {

2
client/.gitignore vendored
View File

@ -39,4 +39,4 @@ yarn-error.log*
__ENV.js
# next-sitemap
sitemap*.xml
sitemap*.xml

View File

@ -46,6 +46,6 @@ EXPOSE 3000
ENV PORT 3000
HEALTHCHECK --interval=30s --timeout=20s --retries=3 --start-period=15s \
CMD curl -fSs 127.0.0.1:3000 || exit 1
CMD curl -fSs localhost:3000 || exit 1
CMD [ "pnpm", "run", "start", "--filter", "client" ]

View File

@ -13,6 +13,7 @@ import {
} from '@mui/icons-material';
import { ButtonBase, Divider, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import clsx from 'clsx';
import dayjs from 'dayjs';
import { get } from 'lodash';
import { useTranslation } from 'next-i18next';
import toast from 'react-hot-toast';
@ -67,8 +68,9 @@ const ArtboardController: React.FC<ReactZoomPanPinchRef> = ({ zoomIn, zoomOut, c
const slug = get(resume, 'slug');
const username = get(resume, 'user.username');
const updatedAt = get(resume, 'updatedAt');
const url = await mutateAsync({ username, slug });
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
download(url);
};

View File

@ -5,7 +5,7 @@
}
.wrapper {
@apply h-full w-full #{!important};
@apply h-full w-full overflow-visible #{!important};
}
.artboard {

View File

@ -36,13 +36,6 @@
top: calc(279mm - 19px);
}
}
.markdown {
ul {
padding-left: 1.5em;
text-indent: -1.5em;
}
}
}
.pageNumber {

View File

@ -26,24 +26,24 @@ const Page: React.FC<Props> = ({ page, showPageNumbers = false }) => {
const theme: ThemeConfig = get(resume, 'metadata.theme');
const customCSS: CustomCSS = get(resume, 'metadata.css');
const template: string = get(resume, 'metadata.template');
const pageConfig: PageConfig = get(resume, 'metadata.page');
const typography: Typography = get(resume, 'metadata.typography');
const pageConfig: PageConfig = get(resume, 'metadata.page', {} as PageConfig);
const themeCSS = useMemo(() => !isEmpty(theme) && generateThemeStyles(theme), [theme]);
const typographyCSS = useMemo(() => !isEmpty(typography) && generateTypographyStyles(typography), [typography]);
const TemplatePage: React.FC<PageProps> | null = useMemo(() => templateMap[template].component, [template]);
return (
<div data-page={page + 1} data-format={pageConfig.format || 'A4'} className={styles.container}>
<div className={styles.container} data-page={page + 1} data-format={pageConfig?.format || 'A4'}>
<div
className={clsx({
reset: true,
[styles.page]: true,
[styles.break]: breakLine,
[styles['format-letter']]: pageConfig?.format === 'Letter',
[css(themeCSS)]: true,
[css(typographyCSS)]: true,
[css(customCSS.value)]: customCSS.visible,
[styles['format-letter']]: pageConfig?.format === 'Letter',
})}
>
{TemplatePage && <TemplatePage page={page} />}

View File

@ -1,14 +1,15 @@
import { Add, Star } from '@mui/icons-material';
import { Button, Divider, IconButton, SwipeableDrawer, Tooltip, useMediaQuery, useTheme } from '@mui/material';
import { Section as SectionRecord } from '@reactive-resume/schema';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import { useMemo } from 'react';
import React, { ReactComponentElement, useMemo } from 'react';
import { validate } from 'uuid';
import Logo from '@/components/shared/Logo';
import { getCustomSections, left } from '@/config/sections';
import { getCustomSections, getSectionsByType, left } from '@/config/sections';
import { setSidebarState } from '@/store/build/buildSlice';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { addSection } from '@/store/resume/resumeSlice';
@ -52,7 +53,49 @@ const LeftSidebar = () => {
items: [],
};
dispatch(addSection({ value: newSection }));
dispatch(addSection({ value: newSection, type: 'custom' }));
};
const sectionsList = () => {
const sectionsComponents: Array<ReactComponentElement<any>> = [];
for (const item of left) {
const id = (item as any).id;
const component = (item as any).component;
const type = component.props.type;
const addMore = !!component.props.addMore;
sectionsComponents.push(
<section key={id} id={id}>
{component}
</section>
);
if (addMore) {
const additionalSections = getSectionsByType(sections, type);
const elements = [];
for (const element of additionalSections) {
const newId = element.id;
const props = cloneDeep(component.props);
props.path = 'sections.' + newId;
props.name = element.name;
props.isDeletable = true;
props.addMore = false;
props.isDuplicated = true;
const newComponent = React.cloneElement(component, props);
elements.push(
<section key={newId} id={`section-${newId}`}>
{newComponent}
</section>
);
}
sectionsComponents.push(...elements);
}
}
return sectionsComponents;
};
return (
@ -68,9 +111,7 @@ const LeftSidebar = () => {
<nav className="overflow-y-scroll">
<div>
<Link href="/dashboard">
<a className="inline-flex">
<Logo size={40} />
</a>
<Logo size={40} />
</Link>
<Divider />
</div>
@ -89,7 +130,7 @@ const LeftSidebar = () => {
{customSections.map(({ id }) => (
<Tooltip key={id} title={get(sections, `${id}.name`, '') as string} placement="right" arrow>
<IconButton onClick={() => handleClick(id)}>
<IconButton onClick={() => id && handleClick(id)}>
<Star />
</IconButton>
</Tooltip>
@ -100,15 +141,11 @@ const LeftSidebar = () => {
</nav>
<main>
{left.map(({ id, component }) => (
<section key={id} id={id}>
{component}
</section>
))}
{sectionsList()}
{customSections.map(({ id }) => (
<section key={id} id={`section-${id}`}>
<Section path={`sections.${id}`} isEditable isHideable isDeletable />
<Section path={`sections.${id}`} type="custom" isEditable isHideable isDeletable />
</section>
))}

View File

@ -1,6 +1,6 @@
import { Add } from '@mui/icons-material';
import { Button } from '@mui/material';
import { ListItem } from '@reactive-resume/schema';
import { ListItem, Section as SectionRecord, SectionType } from '@reactive-resume/schema';
import clsx from 'clsx';
import get from 'lodash/get';
import { useTranslation } from 'next-i18next';
@ -10,28 +10,34 @@ import Heading from '@/components/shared/Heading';
import List from '@/components/shared/List';
import { useAppDispatch, useAppSelector } from '@/store/hooks';
import { ModalName, setModalState } from '@/store/modal/modalSlice';
import { duplicateItem } from '@/store/resume/resumeSlice';
import { duplicateItem, duplicateSection } from '@/store/resume/resumeSlice';
import SectionSettings from './SectionSettings';
type Props = {
path: `sections.${string}`;
type?: SectionType;
name?: string;
titleKey?: string;
subtitleKey?: string;
isEditable?: boolean;
isHideable?: boolean;
isDeletable?: boolean;
addMore?: boolean;
isDuplicated?: boolean;
};
const Section: React.FC<Props> = ({
path,
name = 'Section Name',
type = 'basic',
titleKey = 'title',
subtitleKey = 'subtitle',
isEditable = false,
isHideable = false,
isDeletable = false,
addMore = false,
isDuplicated = false,
}) => {
const { t } = useTranslation();
@ -41,22 +47,40 @@ const Section: React.FC<Props> = ({
const visibility = useAppSelector<boolean>((state) => get(state.resume.present, `${path}.visible`, true));
const handleAdd = () => {
const id = path.split('.')[1];
const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
const modal: ModalName = `builder.sections.${type}`;
dispatch(setModalState({ modal, state: { open: true, payload: { path } } }));
};
const handleEdit = (item: ListItem) => {
const id = path.split('.')[1];
const modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
let modal: ModalName = validate(id) ? 'builder.sections.custom' : `builder.${path}`;
const payload = validate(id) ? { path, item } : { item };
if (isDuplicated) {
modal = `builder.sections.${type}`;
payload.path = path;
}
dispatch(setModalState({ modal, state: { open: true, payload } }));
};
const handleDuplicate = (item: ListItem) => dispatch(duplicateItem({ path: `${path}.items`, value: item }));
const handleDuplicateSection = () => {
const newSection: SectionRecord = {
name: `${heading}`,
type: type,
visible: true,
columns: 2,
items: [],
isDuplicated: true,
};
dispatch(duplicateSection({ value: newSection, type }));
};
return (
<>
<Heading path={path} name={name} isEditable={isEditable} isHideable={isHideable} isDeletable={isDeletable} />
@ -77,6 +101,16 @@ const Section: React.FC<Props> = ({
{t<string>('builder.common.actions.add', { token: heading })}
</Button>
</footer>
{addMore ? (
<div className="py-6 text-right">
<Button fullWidth variant="outlined" startIcon={<Add />} onClick={handleDuplicateSection}>
{t<string>('builder.common.actions.duplicate')}
</Button>
</div>
) : (
<></>
)}
</>
);
};

View File

@ -17,7 +17,9 @@ const CustomCSS = () => {
const dispatch = useAppDispatch();
const customCSS: CustomCSSType = useAppSelector((state) => get(state.resume.present, 'metadata.css', {}));
const customCSS: CustomCSSType = useAppSelector((state) =>
get(state.resume.present, 'metadata.css', {} as CustomCSSType)
);
const handleChange = (value: string | undefined) => {
dispatch(setResumeState({ path: 'metadata.css.value', value }));

View File

@ -1,5 +1,6 @@
import { PictureAsPdf, Schema } from '@mui/icons-material';
import { List, ListItem, ListItemButton, ListItemText } from '@mui/material';
import dayjs from 'dayjs';
import get from 'lodash/get';
import pick from 'lodash/pick';
import { useTranslation } from 'next-i18next';
@ -45,8 +46,9 @@ const Export = () => {
const slug = get(resume, 'slug');
const username = get(resume, 'user.username');
const updatedAt = get(resume, 'updatedAt');
const url = await mutateAsync({ username, slug });
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
download(url);
};

View File

@ -47,8 +47,8 @@ const Settings = () => {
const id: number = useMemo(() => get(resume, 'id'), [resume]);
const slug: string = useMemo(() => get(resume, 'slug'), [resume]);
const username: string = useMemo(() => get(resume, 'user.username'), [resume]);
const pageConfig: PageConfig = useMemo(() => get(resume, 'metadata.page'), [resume]);
const dateConfig: DateConfig = useMemo(() => get(resume, 'metadata.date'), [resume]);
const pageConfig: PageConfig | undefined = useMemo(() => get(resume, 'metadata.page'), [resume]);
const isDarkMode = useMemo(() => theme === 'dark', [theme]);
const exampleDateString = useMemo(() => `Eg. ${dayjs().utc().format(dateConfig.format)}`, [dateConfig.format]);
@ -98,7 +98,7 @@ const Settings = () => {
<>
<Heading path="metadata.settings" name={t<string>('builder.rightSidebar.sections.settings.heading')} />
<List sx={{ padding: 0 }}>
<List disablePadding>
{/* Global Settings */}
<>
<ListSubheader disableSticky className="rounded">
@ -212,7 +212,7 @@ const Settings = () => {
{t<string>('builder.rightSidebar.sections.settings.resume.heading')}
</ListSubheader>
<ListItem>
<ListItem disableGutters>
<ListItemButton onClick={handleLoadSampleData}>
<ListItemIcon>
<Anchor />
@ -224,7 +224,7 @@ const Settings = () => {
</ListItemButton>
</ListItem>
<ListItem>
<ListItem disableGutters>
<ListItemButton onClick={handleResetResume}>
<ListItemIcon>
<DeleteForever />

View File

@ -31,7 +31,14 @@ const Templates = () => {
<div key={template.id} className={styles.template}>
<div className={clsx(styles.preview, { [styles.selected]: template.id === currentTemplate })}>
<ButtonBase onClick={() => handleChange(template)}>
<Image src={template.preview} alt={template.name} className="rounded-sm" layout="fill" priority />
<Image
fill
priority
alt={template.name}
src={template.preview}
className="rounded-sm"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</ButtonBase>
</div>

View File

@ -16,9 +16,7 @@ type Props = {
const ResumeCard: React.FC<Props> = ({ modal, icon: Icon, title, subtitle }) => {
const dispatch = useAppDispatch();
const handleClick = () => {
dispatch(setModalState({ modal, state: { open: true } }));
};
const handleClick = () => dispatch(setModalState({ modal, state: { open: true } }));
return (
<section className={styles.resume}>

View File

@ -115,9 +115,7 @@ const ResumePreview: React.FC<Props> = ({ resume }) => {
}}
>
<ButtonBase className={styles.preview}>
{resume.image ? (
<Image src={resume.image} alt={resume.name} objectFit="cover" layout="fill" priority />
) : null}
{resume.image ? <Image src={resume.image} alt={resume.name} priority width={400} height={0} /> : null}
</ButtonBase>
</Link>

View File

@ -47,9 +47,9 @@ const Avatar: React.FC<Props> = ({ size = 64 }) => {
<Image
width={size}
height={size}
alt={user?.name}
className={styles.avatar}
src={getGravatarUrl(email, size)}
alt={user?.name ?? 'User Avatar'}
/>
</IconButton>

View File

@ -4,8 +4,8 @@ type Props = {
size?: 256 | 64 | 48 | 40 | 32;
};
const Logo: React.FC<Props> = ({ size = 64 }) => {
return <Image alt="Reactive Resume" src="/images/logos/logo.svg" className="rounded" width={size} height={size} />;
};
const Logo: React.FC<Props> = ({ size = 64 }) => (
<Image alt="Reactive Resume" src="/images/logos/logo.svg" className="rounded" width={size} height={size} priority />
);
export default Logo;

View File

@ -1,7 +1,6 @@
import clsx from 'clsx';
import { isEmpty } from 'lodash';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
type Props = {
children?: string;
@ -12,7 +11,7 @@ const Markdown: React.FC<Props> = ({ className, children }) => {
if (!children || isEmpty(children)) return null;
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} className={clsx('markdown', className)}>
<ReactMarkdown remarkPlugins={[]} className={clsx('markdown', className)}>
{children}
</ReactMarkdown>
);

View File

@ -22,6 +22,7 @@ const ResumeInput: React.FC<Props> = ({ type = 'text', label, path, className, m
const dispatch = useAppDispatch();
const stateValue = useAppSelector((state) => get(state.resume.present, path, ''));
const dateFormat = useAppSelector((state) => state.resume.present.metadata.date.format);
useEffect(() => {
setValue(stateValue);
@ -56,14 +57,16 @@ const ResumeInput: React.FC<Props> = ({ type = 'text', label, path, className, m
if (type === 'date') {
return (
<DatePicker
showToolbar
openTo="year"
label={label}
value={value}
toolbarFormat={dateFormat}
views={['year', 'month', 'day']}
renderInput={(params) => <TextField {...params} error={false} className={className} />}
onChange={(date: Date | null, keyboardInputValue: string | undefined) => {
isEmpty(keyboardInputValue) && onChangeValue('');
date && dayjs(date).utc().isValid() && onChangeValue(dayjs(date).utc().toISOString());
date && dayjs(date).isValid() && onChangeValue(dayjs(date).format('YYYY-MM-DD'));
}}
/>
);

View File

@ -23,7 +23,7 @@ import {
VolunteerActivism,
Work,
} from '@mui/icons-material';
import { Section as SectionRecord } from '@reactive-resume/schema';
import { Section as SectionRecord, SectionType } from '@reactive-resume/schema';
import isEmpty from 'lodash/isEmpty';
import Basics from '@/components/build/LeftSidebar/sections/Basics';
@ -60,59 +60,136 @@ export const left: SidebarSection[] = [
{
id: 'work',
icon: <Work />,
component: <Section path="sections.work" titleKey="name" subtitleKey="position" isEditable isHideable />,
component: (
<Section
type={'work'}
addMore={true}
path="sections.work"
titleKey="name"
subtitleKey="position"
isEditable
isHideable
/>
),
},
{
id: 'education',
icon: <School />,
component: <Section path="sections.education" titleKey="institution" subtitleKey="area" isEditable isHideable />,
component: (
<Section
type={'education'}
path="sections.education"
titleKey="institution"
subtitleKey="area"
isEditable
isHideable
/>
),
},
{
id: 'awards',
icon: <EmojiEvents />,
component: <Section path="sections.awards" titleKey="title" subtitleKey="awarder" isEditable isHideable />,
component: (
<Section type={'awards'} path="sections.awards" titleKey="title" subtitleKey="awarder" isEditable isHideable />
),
},
{
id: 'certifications',
icon: <CardGiftcard />,
component: <Section path="sections.certifications" titleKey="name" subtitleKey="issuer" isEditable isHideable />,
component: (
<Section
type={'certifications'}
path="sections.certifications"
titleKey="name"
subtitleKey="issuer"
isEditable
isHideable
/>
),
},
{
id: 'publications',
icon: <MenuBook />,
component: <Section path="sections.publications" titleKey="name" subtitleKey="publisher" isEditable isHideable />,
component: (
<Section
type={'publications'}
path="sections.publications"
titleKey="name"
subtitleKey="publisher"
isEditable
isHideable
/>
),
},
{
id: 'skills',
icon: <Architecture />,
component: <Section path="sections.skills" titleKey="name" subtitleKey="level" isEditable isHideable />,
component: (
<Section type={'skills'} path="sections.skills" titleKey="name" subtitleKey="level" isEditable isHideable />
),
},
{
id: 'languages',
icon: <Language />,
component: <Section path="sections.languages" titleKey="name" subtitleKey="level" isEditable isHideable />,
component: (
<Section type={'languages'} path="sections.languages" titleKey="name" subtitleKey="level" isEditable isHideable />
),
},
{
id: 'interests',
icon: <Sailing />,
component: <Section path="sections.interests" titleKey="name" subtitleKey="keywords" isEditable isHideable />,
component: (
<Section
type={'interests'}
path="sections.interests"
titleKey="name"
subtitleKey="keywords"
isEditable
isHideable
/>
),
},
{
id: 'volunteer',
icon: <VolunteerActivism />,
component: (
<Section path="sections.volunteer" titleKey="organization" subtitleKey="position" isEditable isHideable />
<Section
type={'volunteer'}
path="sections.volunteer"
titleKey="organization"
subtitleKey="position"
isEditable
isHideable
/>
),
},
{
id: 'projects',
icon: <Coffee />,
component: <Section path="sections.projects" titleKey="name" subtitleKey="description" isEditable isHideable />,
component: (
<Section
type={'projects'}
path="sections.projects"
titleKey="name"
subtitleKey="description"
isEditable
isHideable
/>
),
},
{
id: 'references',
icon: <Groups />,
component: <Section path="sections.references" titleKey="name" subtitleKey="relationship" isEditable isHideable />,
component: (
<Section
type={'references'}
path="sections.references"
titleKey="name"
subtitleKey="relationship"
isEditable
isHideable
/>
),
},
];
@ -164,7 +241,19 @@ export const right: SidebarSection[] = [
},
];
export const getCustomSections = (sections: Record<string, SectionRecord>): Array<Required<SectionRecord>> => {
export const getSectionsByType = (sections: Record<string, SectionRecord>, type: SectionType): SectionRecord[] => {
if (isEmpty(sections)) return [];
return Object.entries(sections).reduce((acc, [id, section]) => {
if (section.type.startsWith(type) && section.isDuplicated) {
return [...acc, { ...section, id }];
}
return acc;
}, [] as SectionRecord[]);
};
export const getCustomSections = (sections: Record<string, SectionRecord>): SectionRecord[] => {
if (isEmpty(sections)) return [];
return Object.entries(sections).reduce((acc, [id, section]) => {
@ -173,7 +262,7 @@ export const getCustomSections = (sections: Record<string, SectionRecord>): Arra
}
return acc;
}, [] as Array<Required<SectionRecord>>);
}, [] as SectionRecord[]);
};
const sections = [...left, ...right];

View File

@ -169,7 +169,8 @@ const LoginModal: React.FC = () => {
<p className="text-xs">
<Trans t={t} i18nKey="modals.auth.login.recover-text">
In case you have forgotten your password, you can <a onClick={handleRecoverAccount}>recover your account here.</a>
In case you have forgotten your password, you can
<a onClick={handleRecoverAccount}>recover your account here.</a>
</Trans>
</p>
</BaseModal>

View File

@ -60,13 +60,14 @@ const CustomModal: React.FC = () => {
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume.present, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal['builder.sections.custom']);
const path: string = get(payload, 'path', '');
const path: string = get(payload, 'path', 'sections.custom');
const item: FormData = get(payload, 'item', null);
const isEditMode = useMemo(() => !!item, [item]);
const heading = useAppSelector((state) => get(state.resume.present, `${path}.name`));
const addText = useMemo(() => t<string>('builder.common.actions.add', { token: heading }), [t, heading]);
const editText = useMemo(() => t<string>('builder.common.actions.edit', { token: heading }), [t, heading]);
@ -260,9 +261,9 @@ const CustomModal: React.FC = () => {
multiline
minRows={3}
maxRows={6}
label={t<string>('builder.common.form.summary.label')}
className="col-span-2"
error={!!fieldState.error}
label={t<string>('builder.common.form.summary.label')}
helperText={fieldState.error?.message || <MarkdownSupported />}
{...field}
/>

View File

@ -2,7 +2,7 @@ import { joiResolver } from '@hookform/resolvers/joi';
import { Add, DriveFileRenameOutline } from '@mui/icons-material';
import { Button, TextField } from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers';
import { SectionPath, WorkExperience } from '@reactive-resume/schema';
import { WorkExperience } from '@reactive-resume/schema';
import dayjs from 'dayjs';
import Joi from 'joi';
import get from 'lodash/get';
@ -20,8 +20,6 @@ import { addItem, editItem } from '@/store/resume/resumeSlice';
type FormData = WorkExperience;
const path: SectionPath = 'sections.work';
const defaultState: FormData = {
name: '',
position: '',
@ -50,10 +48,12 @@ const WorkModal: React.FC = () => {
const dispatch = useAppDispatch();
const heading = useAppSelector((state) => get(state.resume.present, `${path}.name`));
const { open: isOpen, payload } = useAppSelector((state) => state.modal[`builder.${path}`]);
const { open: isOpen, payload } = useAppSelector((state) => state.modal['builder.sections.work']);
const path: string = get(payload, 'path', 'sections.work');
const item: FormData = get(payload, 'item', null);
const heading = useAppSelector((state) => get(state.resume.present, `${path}.name`));
const isEditMode = useMemo(() => !!item, [item]);
const addText = useMemo(() => t<string>('builder.common.actions.add', { token: heading }), [t, heading]);
@ -77,7 +77,7 @@ const WorkModal: React.FC = () => {
const handleClose = () => {
dispatch(
setModalState({
modal: `builder.${path}`,
modal: 'builder.sections.work',
state: { open: false },
})
);

View File

@ -10,75 +10,74 @@
"dependencies": {
"@beam-australia/react-env": "^3.1.1",
"@date-io/dayjs": "^2.16.0",
"@emotion/css": "^11.10.0",
"@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4",
"@emotion/css": "^11.10.5",
"@emotion/react": "^11.10.5",
"@emotion/styled": "^11.10.5",
"@hello-pangea/dnd": "^16.0.1",
"@hookform/resolvers": "2.9.9",
"@hookform/resolvers": "2.9.10",
"@monaco-editor/react": "^4.4.6",
"@mui/icons-material": "^5.10.9",
"@mui/lab": "^5.0.0-alpha.103",
"@mui/material": "^5.10.9",
"@mui/system": "^5.10.9",
"@mui/x-date-pickers": "5.0.4",
"@next/env": "^12.3.1",
"@react-oauth/google": "^0.2.8",
"@reduxjs/toolkit": "^1.8.6",
"axios": "^1.1.2",
"@mui/icons-material": "^5.10.15",
"@mui/lab": "^5.0.0-alpha.109",
"@mui/material": "^5.10.15",
"@mui/system": "^5.10.15",
"@mui/x-date-pickers": "5.0.8",
"@next/env": "^13.0.5",
"@react-oauth/google": "^0.5.0",
"@reduxjs/toolkit": "^1.9.0",
"axios": "^1.2.0",
"clsx": "^1.2.1",
"dayjs": "^1.11.5",
"dayjs": "^1.11.6",
"downloadjs": "^1.4.7",
"joi": "^17.6.3",
"joi": "^17.7.0",
"lodash": "^4.17.21",
"md5-hex": "^4.0.0",
"monaco-editor": "^0.34.0",
"monaco-editor": "^0.34.1",
"nanoid": "^3.3.4",
"next": "12.3.1",
"next-i18next": "^12.1.0",
"next": "13.0.5",
"next-i18next": "^13.0.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.37.0",
"react-hook-form": "^7.39.5",
"react-hot-toast": "2.4.0",
"react-hotkeys-hook": "^3.4.7",
"react-icons": "^4.6.0",
"react-markdown": "^8.0.3",
"react-query": "^3.39.2",
"react-redux": "^8.0.4",
"react-redux": "^8.0.5",
"react-zoom-pan-pinch": "^2.1.3",
"redux": "^4.2.0",
"redux-persist": "^6.0.0",
"redux-saga": "^1.2.1",
"redux-undo": "^1.0.1",
"remark-gfm": "^3.0.1",
"sharp": "^0.31.1",
"sharp": "^0.31.2",
"uuid": "^9.0.0",
"webfontloader": "^1.6.28"
},
"devDependencies": {
"@babel/core": "^7.19.3",
"@babel/core": "^7.20.2",
"@reactive-resume/schema": "workspace:*",
"eslint-plugin-unused-imports": "^2.0.0",
"@tailwindcss/typography": "^0.5.7",
"@tailwindcss/typography": "^0.5.8",
"@types/downloadjs": "^1.4.3",
"@types/lodash": "^4.14.186",
"@types/node": "^18.11.0",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"@types/lodash": "^4.14.190",
"@types/node": "^18.11.9",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"@types/react-redux": "^7.1.24",
"@types/tailwindcss": "^3.0.11",
"@types/uuid": "^8.3.4",
"@types/webfontloader": "^1.6.35",
"autoprefixer": "^10.4.12",
"autoprefixer": "^10.4.13",
"csstype": "^3.1.1",
"eslint-config-next": "^12.3.1",
"eslint-plugin-tailwindcss": "^3.6.2",
"next-sitemap": "^3.1.25",
"postcss": "^8.4.18",
"sass": "^1.55.0",
"tailwindcss": "^3.1.8",
"typescript": "^4.8.4"
"eslint-config-next": "^13.0.5",
"eslint-plugin-tailwindcss": "^3.7.0",
"eslint-plugin-unused-imports": "^2.0.0",
"next-sitemap": "^3.1.32",
"postcss": "^8.4.19",
"sass": "^1.56.1",
"tailwindcss": "^3.2.4",
"typescript": "^4.9.3"
}
}

View File

@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material';
import { ButtonBase } from '@mui/material';
import { Resume } from '@reactive-resume/schema';
import clsx from 'clsx';
import dayjs from 'dayjs';
import download from 'downloadjs';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
@ -60,10 +61,12 @@ const Preview: NextPage<Props> = ({ username, slug, resume: initialData }) => {
}, [dispatch, initialData]);
useEffect(() => {
if (!isEmpty(resume) && router.locale !== resume.metadata.locale) {
const locale = get(resume, 'metadata.locale', 'en');
if (!isEmpty(resume) && router.locale !== locale) {
const { pathname, asPath, query } = router;
router.push({ pathname, query }, asPath, { locale: resume.metadata.locale });
router.push({ pathname, query }, asPath, { locale });
}
}, [resume, router]);
@ -96,7 +99,9 @@ const Preview: NextPage<Props> = ({ username, slug, resume: initialData }) => {
const handleDownload = async () => {
try {
const url = await mutateAsync({ username, slug });
const updatedAt = get(resume, 'updatedAt');
const url = await mutateAsync({ username, slug, lastUpdated: dayjs(updatedAt).unix().toString() });
download(url);
} catch {

View File

@ -27,7 +27,7 @@ type Props = {
export const getServerSideProps: GetServerSideProps<Props | Promise<Props>, QueryParams> = async ({
query,
locale,
locale = 'en',
}) => {
const { username, slug, secretKey } = query as QueryParams;
@ -35,7 +35,7 @@ export const getServerSideProps: GetServerSideProps<Props | Promise<Props>, Quer
if (isEmpty(secretKey)) throw new Error('There is no secret key!');
const resume = await fetchResumeByIdentifier({ username, slug, options: { secretKey } });
const displayLocale = resume.metadata.locale || locale || 'en';
const displayLocale = get(resume, 'metadata.locale') ?? locale;
return {
props: {

View File

@ -21,7 +21,7 @@ import WrapperRegistry from '@/wrappers/index';
const App = ({ Component, pageProps }: AppProps): JSX.Element => (
<>
<Head>
<title>Reactive Resume</title>
<title>Reactive Resume | A free and open source resume builder</title>
<meta
name="description"

View File

@ -2,7 +2,7 @@ import { NextPage } from 'next';
import NextDocument, { DocumentContext, Head, Html, Main, NextScript } from 'next/document';
const Document: NextPage = () => (
<Html>
<Html lang="en">
<Head />
<body>

View File

@ -17,13 +17,11 @@ import { fetchResumes } from '@/services/resume';
import { useAppDispatch } from '@/store/hooks';
import styles from '@/styles/pages/Dashboard.module.scss';
export const getStaticProps: GetStaticProps = async ({ locale = 'en' }) => {
return {
props: {
...(await serverSideTranslations(locale, ['common', 'modals', 'dashboard'])),
},
};
};
export const getStaticProps: GetStaticProps = async ({ locale = 'en' }) => ({
props: {
...(await serverSideTranslations(locale, ['common', 'modals', 'dashboard'])),
},
});
const Dashboard: NextPage = () => {
const { t } = useTranslation();
@ -48,9 +46,7 @@ const Dashboard: NextPage = () => {
<header>
<Link href="/">
<a>
<Logo size={40} />
</a>
<Logo size={40} />
</Link>
<Avatar size={40} />
@ -58,15 +54,15 @@ const Dashboard: NextPage = () => {
<main className={styles.resumes}>
<ResumeCard
modal="dashboard.create-resume"
icon={Add}
modal="dashboard.create-resume"
title={t<string>('dashboard.create-resume.title')}
subtitle={t<string>('dashboard.create-resume.subtitle')}
/>
<ResumeCard
modal="dashboard.import-external"
icon={ImportExport}
modal="dashboard.import-external"
title={t<string>('dashboard.import-external.title')}
subtitle={t<string>('dashboard.import-external.subtitle')}
/>

View File

@ -22,13 +22,11 @@ import styles from '@/styles/pages/Home.module.scss';
import { DIGITALOCEAN_URL, DOCS_URL, DONATION_URL, GITHUB_URL } from '../constants';
export const getStaticProps: GetStaticProps = async ({ locale = 'en' }) => {
return {
props: {
...(await serverSideTranslations(locale, ['common', 'modals', 'landing'])),
},
};
};
export const getStaticProps: GetStaticProps = async ({ locale = 'en' }) => ({
props: {
...(await serverSideTranslations(locale, ['common', 'modals', 'landing'])),
},
});
const Home: NextPage = () => {
const { t } = useTranslation();
@ -39,11 +37,8 @@ const Home: NextPage = () => {
const isLoggedIn = useAppSelector((state) => state.auth.isLoggedIn);
const handleLogin = () => dispatch(setModalState({ modal: 'auth.login', state: { open: true } }));
const handleRegister = () => dispatch(setModalState({ modal: 'auth.register', state: { open: true } }));
const handleToggle = () => dispatch(setTheme({ theme: theme === 'light' ? 'dark' : 'light' }));
const handleLogout = () => dispatch(logout());
return (
@ -117,7 +112,13 @@ const Home: NextPage = () => {
<div className={styles.screenshots}>
{screenshots.map(({ src, alt }) => (
<a key={src} href={src} className={styles.image} target="_blank" rel="noreferrer">
<Image src={src} alt={alt} layout="fill" objectFit="cover" />
<Image
fill
src={src}
alt={alt}
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</a>
))}
</div>
@ -186,7 +187,13 @@ const Home: NextPage = () => {
<section className={styles.section}>
<a href={DIGITALOCEAN_URL} target="_blank" rel="noreferrer">
<Image src={`/images/sponsors/${theme=="dark"?"digitalocean":"digitaloceanLight"}.svg`} alt="Powered By DigitalOcean" width={200} height={40} />
<Image
src={`/images/sponsors/${theme == 'dark' ? 'digitalocean' : 'digitaloceanLight'}.svg`}
style={{ width: 200, height: 40, objectFit: 'contain' }}
alt="Powered By DigitalOcean"
width={200}
height={40}
/>
</a>
</section>

View File

@ -2,6 +2,7 @@ import { Download, Downloading } from '@mui/icons-material';
import { ButtonBase } from '@mui/material';
import { Resume } from '@reactive-resume/schema';
import clsx from 'clsx';
import dayjs from 'dayjs';
import download from 'downloadjs';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
@ -69,7 +70,11 @@ const Preview: NextPage<Props> = ({ shortId }) => {
const handleDownload = async () => {
try {
const url = await mutateAsync({ username: resume.user.username, slug: resume.slug });
const url = await mutateAsync({
username: resume.user.username,
slug: resume.slug,
lastUpdated: dayjs(resume.updatedAt).unix().toString(),
});
download(url);
} catch {

View File

@ -13,7 +13,7 @@
"help-text": "Dieser Abschnitt unterstützt <1>Markdown</1> Formatierung."
},
"date": {
"present": "Gegenwärtig"
"present": "gegenwärtig"
},
"subtitle": "Ein freier und Open-Source-Lebenslauf-Builder.",
"title": "Reaktives Lebenslauf",

View File

@ -3,7 +3,8 @@
"actions": {
"add": "Add New {{token}}",
"delete": "Delete {{token}}",
"edit": "Edit {{token}}"
"edit": "Edit {{token}}",
"duplicate": "Duplicate Section"
},
"columns": {
"heading": "Columns",
@ -244,7 +245,7 @@
"pdf": {
"loading": {
"primary": "Generating PDF",
"secondary": "Please wait as your PDF gets generated, this may take upto 15 seconds."
"secondary": "Please wait as your PDF gets generated, this may take up to 15 seconds."
},
"normal": {
"primary": "PDF",

View File

@ -20,7 +20,7 @@
"toast": {
"error": {
"upload-file-size": "Please upload only files under 2 megabytes.",
"upload-photo-size": "Please upload only photos under 2 megabytes, preferrably square."
"upload-photo-size": "Please upload only photos under 2 megabytes, preferably square."
},
"success": {
"resume-link-copied": "A link to your resume has been copied to your clipboard."

View File

@ -1,7 +1,7 @@
{
"common": {
"actions": {
"add": "Új hozzáadása {{token}}",
"add": "Új {{token}} hozzáadása",
"delete": "{{token}} törlése",
"edit": "{{token}} szerkesztése"
},
@ -20,7 +20,7 @@
"label": "E-mail cím"
},
"end-date": {
"help-text": "Hagyja üresen ezt a mezőt, ha még mindig tart van",
"help-text": "Hagyja üresen ezt a mezőt, ha még folyamatban van",
"label": "Befejezés dátuma"
},
"keywords": {
@ -51,7 +51,7 @@
"label": "Összegzés"
},
"title": {
"label": "Cím"
"label": "Titulus"
},
"url": {
"label": "Honlap"
@ -63,30 +63,30 @@
"list": {
"actions": {
"delete": "Törlés",
"duplicate": "Duplikálás",
"duplicate": "Másolás",
"edit": "Szerkesztés"
},
"empty-text": "Ez a lista üres."
},
"tooltip": {
"delete-item": "Biztosan törli ezt az elemet? Ez egy visszafordíthatatlan művelet.",
"delete-item": "Biztosan törli ezt az elemet? Ez a művelet nem visszavonható.",
"delete-section": "Szakasz törlése",
"rename-section": "Szakasz átnevezése",
"toggle-visibility": "Láthatóság váltása"
"toggle-visibility": "Láthatóság ki/be"
}
},
"controller": {
"tooltip": {
"center-artboard": "Központi rajztábla",
"copy-link": "Link másolása az önéletrajzba",
"copy-link": "Önéletrajz link másolása",
"export-pdf": "Exportálás PDF-be",
"toggle-orientation": "Oldaltájolás váltása",
"toggle-page-break-line": "Oldaltörés vonal váltása",
"toggle-sidebars": "Az oldalsávok váltása",
"toggle-orientation": "Oldaltájolás",
"toggle-page-break-line": "Oldaltörés vonal ki/be",
"toggle-sidebars": "Oldalsávok ki/be",
"zoom-in": "Nagyítás",
"zoom-out": "Kicsinyítés",
"undo": "Undo",
"redo": "Redo"
"undo": "Visszavonás",
"redo": "Mégis"
}
},
"header": {
@ -96,7 +96,7 @@
"rename": "Átnevezés",
"share-link": "Link megosztása",
"tooltips": {
"delete": "Biztos, hogy törölni szeretné ezt az önéletrajzot? Ez egy visszafordíthatatlan művelet.",
"delete": "Biztos, hogy törölni szeretné ezt az önéletrajzot? Ez a művelet nem visszavonható.",
"share-link": "Az önéletrajz láthatóságát nyilvánosra kell változtatnia, hogy mások számára is látható legyen."
}
}
@ -138,7 +138,7 @@
"heading": "Alak"
},
"size": {
"heading": "Méret (px-ben)"
"heading": "Méret (pixel)"
}
},
"photo-upload": {
@ -189,7 +189,7 @@
"label": "Irányítószám"
},
"region": {
"label": "Vidék"
"label": "Régió"
}
},
"profiles": {
@ -248,7 +248,7 @@
},
"normal": {
"primary": "PDF",
"secondary": "Töltse le önéletrajzának PDF formátumát, amelyet kinyomtathat és elküldhet álmai munkahelyére. Ez a fájl nem importálható vissza további szerkesztéshez."
"secondary": "Töltse le önéletrajzát PDF formátumban, amelyet kinyomtathat és elküldhet álmai munkahelyére. Ez a fájl nem importálható vissza további szerkesztéshez."
}
}
},
@ -265,7 +265,7 @@
"heading": "Hibák? Funkciókérés?"
},
"donate": {
"body": "Ha tetszett a Reactive Resume, kérjük, fontolja meg, hogy amennyit csak tud, adományozzon arra, hogy az alkalmazás folyamatosan működjön, hirdetések nélkül és örökké ingyenesen.",
"body": "Ha elégedett a Reactive Resume alkalmazással, kérjük, fontolja meg, hogy tetszőleges összeggel támogassa munkánkat, hogy továbbra is ingyenes és hirdetésmentes lehessen.",
"button": "Vegyél nekem egy kávét",
"heading": "Adományozzon a Reactive Resume-nak"
},
@ -277,12 +277,12 @@
"global": {
"date": {
"primary": "Dátum",
"secondary": "Az alkalmazásban használható dátumformátum"
"secondary": "Az alkalmazásban használt dátumformátum"
},
"heading": "Globális",
"language": {
"primary": "Nyelv",
"secondary": "Az alkalmazásban használható megjelenítési nyelv"
"secondary": "Az alkalmazásban használt megjelenítési nyelv"
},
"theme": {
"primary": "Téma"
@ -292,7 +292,7 @@
"page": {
"format": {
"primary": "Papírméret",
"secondary": "Meghatározza az önéletrajzi oldalak méreteit"
"secondary": "Meghatározza az önéletrajz oldalméreteit"
},
"break-line": {
"primary": "Törésvonal",
@ -301,7 +301,7 @@
"heading": "oldal",
"orientation": {
"disabled": "Nincs hatása, ha csak egy oldal van",
"primary": "Irányultság",
"primary": "Tájolás",
"secondary": "Az oldalak vízszintes vagy függőleges megjelenítése"
}
},
@ -320,7 +320,7 @@
"sharing": {
"heading": "Megosztás",
"short-url": {
"label": "Rövid URL-t részesítsen előnyben"
"label": "Rövid URL előnyben részesítése"
},
"visibility": {
"subtitle": "A link birtokában bárki megtekintheti önéletrajzát",
@ -336,7 +336,7 @@
"label": "Háttér"
},
"primary": {
"label": "Elsődleges"
"label": "Elsődleges "
},
"text": {
"label": "Szöveg"
@ -356,7 +356,7 @@
"heading": "Tipográfia",
"widgets": {
"body": {
"label": "Test"
"label": "Szövegtörzs"
},
"headings": {
"label": "Címsorok"

View File

@ -6,14 +6,14 @@
}
},
"footer": {
"credit": "<1>Amruth Pillai szenvedélyes projektje</1>",
"credit": "<1>Amruth Pillai hobbi projektje</1>",
"license": "A közösség által, a közösségért."
},
"markdown": {
"help-text": "Ez a szakasz támogatja a <1>markdown</1> formázást."
},
"date": {
"present": "Ajándék"
"present": "Jelenleg is"
},
"subtitle": "Ingyenes és nyílt forráskódú önéletrajzkészítő.",
"title": "Reactive Resume",

View File

@ -4,18 +4,18 @@
"title": "Új önéletrajz létrehozása"
},
"import-external": {
"subtitle": "LinkedIn, JSON önéletrajz, reaktív önéletrajz",
"subtitle": "LinkedIn, JSON önéletrajz, Reactive Resume",
"title": "Importálás külső forrásokból"
},
"resume": {
"menu": {
"delete": "Törlés",
"duplicate": "Másolat",
"open": "Nyisd ki",
"open": "Megnyitás",
"rename": "Átnevezés",
"share-link": "Link megosztása",
"tooltips": {
"delete": "Biztos, hogy törölni szeretné ezt az önéletrajzot? Ez egy visszafordíthatatlan művelet.",
"delete": "Biztos, hogy törölni szeretné ezt az önéletrajzot? Ez a művelet nem visszavonható.",
"share-link": "Az önéletrajz láthatóságát nyilvánosra kell változtatnia, hogy mások számára is látható legyen."
}
},

View File

@ -1,6 +1,6 @@
{
"actions": {
"app": "Lépjen az App",
"app": "Alkalmazás indítása",
"login": "Belépés",
"logout": "Kijelentkezés",
"register": "Regisztráció"
@ -8,7 +8,7 @@
"features": {
"heading": "Jellemzők",
"list": {
"ads": "Nincs reklám",
"ads": "Reklámmentes",
"export": "Exportálja önéletrajzát JSON vagy PDF formátumba",
"free": "Ingyenes, örökre",
"import": "Adatok importálása a LinkedInből, JSON Resume",
@ -20,11 +20,11 @@
"links": {
"heading": "Linkek",
"links": {
"donate": "Adományoz",
"donate": "Adományozás",
"github": "Forráskód",
"docs": "Dokumentáció",
"privacy": "Adatvédelmi irányelvek",
"service": "Szolgáltatási feltételek"
"service": "Felhasználói feltételek"
}
},
"screenshots": {

View File

@ -2,7 +2,7 @@
"auth": {
"forgot-password": {
"actions": {
"send-email": "Jelszó visszaállítása e-mail küldése"
"send-email": "Jelszó helyreállító e-mail küldése"
},
"body": "Csak adja meg a helyreállítani kívánt fiókhoz társított e-mail címet.",
"form": {
@ -27,14 +27,14 @@
"label": "Felhasználónév"
}
},
"heading": "Jelentkezz be a fiókodba",
"heading": "Jelentkezzen be a fiókba",
"recover-text": "Ha elfelejtette jelszavát, <1>visszaállíthatja fiókját</1> itt.",
"register-text": "Ha nem rendelkezik fiókkal, <1>létrehozhat egy fiókot</1> itt."
},
"register": {
"actions": {
"register": "Regisztráció",
"google": "Regisztráljon a Google-nál"
"google": "Regisztráljon Google fiókkal"
},
"body": "Kérjük, adja meg személyes adatait fiók létrehozásához.",
"form": {
@ -87,7 +87,7 @@
"label": "Nyilvánosan elérhető?"
},
"slug": {
"label": "Meztelen csiga"
"label": "Saját URL"
}
},
"heading": "Új önéletrajz létrehozása"
@ -126,7 +126,7 @@
"label": "Név"
},
"slug": {
"label": "Meztelen csiga"
"label": "Saját URL"
}
},
"heading": "Nevezze át önéletrajzát"

View File

@ -14,7 +14,7 @@
"label": "Tanggal"
},
"description": {
"label": "Keterangan"
"label": "Deskripsi"
},
"email": {
"label": "Alamat Email"
@ -130,7 +130,7 @@
"label": "Batas"
},
"grayscale": {
"label": "Grayscale"
"label": "Tingkat keabuan"
},
"heading": "Efek"
},
@ -138,7 +138,7 @@
"heading": "Bentuk"
},
"size": {
"heading": "Besar (dalam px)"
"heading": "Ukuran (dalam px)"
}
},
"photo-upload": {
@ -158,7 +158,7 @@
"education": {
"form": {
"area-study": {
"label": "Area belajar"
"label": "Bidang Studi"
},
"courses": {
"label": "Kursus"
@ -170,7 +170,7 @@
"label": "Tingkatan"
},
"institution": {
"label": "Lembaga"
"label": "Institusi"
}
}
},

View File

@ -267,7 +267,7 @@
"donate": {
"body": "Als Je Reactive Resume graag gebruikt, kun je overwegen zoveel mogelijk te doneren om de app in de lucht te houden, zonder advertenties en voor altijd gratis.",
"button": "Betaal me een koffie",
"heading": "Doneer aan Reactiv Resume"
"heading": "Doneer aan Reactive Resume"
},
"github": "Broncode",
"docs": "Documentatie",

View File

@ -10,7 +10,7 @@
"license": "Door de gemeenschap, voor de gemeenschap."
},
"markdown": {
"help-text": "Deze sectie ondersteunt <1>html</1> opmaak."
"help-text": "Deze sectie ondersteunt <1>markdown</1> opmaak."
},
"date": {
"present": "Heden"
@ -20,7 +20,7 @@
"toast": {
"error": {
"upload-file-size": "Upload alleen bestanden onder de 2 megabytes.",
"upload-photo-size": "Upload alleen foto's onder de 2 megabytes, bij voorkeur vierkante."
"upload-photo-size": "Upload alleen foto's onder de 2 megabytes, bij voorkeur vierkant."
},
"success": {
"resume-link-copied": "Een link naar jouw CV is naar het klembord gekopieerd."

View File

@ -18,8 +18,8 @@ export type LoginWithGoogleParams = {
export type RegisterParams = {
name: string;
username: string;
email: string;
username: string;
password: string;
};

View File

@ -19,7 +19,6 @@ const axios = _axios.create({ baseURL });
axios.interceptors.request.use((config) => {
const { accessToken } = store.getState().auth;
// @ts-ignore
config.headers = {
...config.headers,
Authorization: `Bearer ${accessToken}`,

View File

@ -3,7 +3,12 @@ import axios from './axios';
export type PrintResumeAsPdfParams = {
username: string;
slug: string;
lastUpdated: string;
};
export const printResumeAsPdf = (printResumeAsPdfParams: PrintResumeAsPdfParams): Promise<string> =>
axios.get(`/printer/${printResumeAsPdfParams.username}/${printResumeAsPdfParams.slug}`).then((res) => res.data);
axios
.get(
`/printer/${printResumeAsPdfParams.username}/${printResumeAsPdfParams.slug}?lastUpdated=${printResumeAsPdfParams.lastUpdated}`
)
.then((res) => res.data);

View File

@ -34,7 +34,7 @@ const store = configureStore({
},
});
sagaMiddleware.run(syncSaga);
sagaMiddleware.run(() => syncSaga(store.dispatch));
export const persistor = persistStore(store);

View File

@ -9,6 +9,7 @@ export type ModalName =
| 'dashboard.import-external'
| 'dashboard.rename-resume'
| 'builder.sections.profile'
| 'builder.sections.work'
| `builder.sections.${string}`;
export type ModalState = {

View File

@ -1,4 +1,4 @@
import { ListItem, Profile, Resume, Section } from '@reactive-resume/schema';
import { ListItem, Profile, Resume, Section, SectionType } from '@reactive-resume/schema';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
@ -7,6 +7,8 @@ import pick from 'lodash/pick';
import set from 'lodash/set';
import { v4 as uuidv4 } from 'uuid';
import { getSectionsByType } from '@/config/sections';
type SetResumeStatePayload = { path: string; value: unknown };
type AddItemPayload = { path: string; value: ListItem };
@ -17,7 +19,7 @@ type DuplicateItemPayload = { path: string; value: ListItem };
type DeleteItemPayload = { path: string; value: ListItem };
type AddSectionPayload = { value: Section };
type AddSectionPayload = { value: Section; type: SectionType };
type DeleteSectionPayload = { path: string };
@ -38,7 +40,7 @@ export const resumeSlice = createSlice({
addItem: (state: Resume, action: PayloadAction<AddItemPayload>) => {
const { path, value } = action.payload;
const id = uuidv4();
const list = get(state, path, []);
const list: ListItem[] = get(state, path, []);
const item = merge(value, { id });
list.push(item);
@ -80,6 +82,15 @@ export const resumeSlice = createSlice({
state.sections[id] = value;
state.metadata.layout[0][0].push(id);
},
duplicateSection: (state: Resume, action: PayloadAction<AddSectionPayload>) => {
const { value, type } = action.payload;
const id = getSectionsByType(state.sections, type).length + 1;
value.name = value.name + '-' + id;
state.sections[`${type}-${id}`] = value;
state.metadata.layout[0][0].push(`${type}-${id}`);
},
deleteSection: (state: Resume, action: PayloadAction<DeleteSectionPayload>) => {
const { path } = action.payload;
const id = path.split('.')[1];
@ -119,6 +130,7 @@ export const {
duplicateItem,
deleteItem,
addSection,
duplicateSection,
deleteSection,
addPage,
deletePage,

View File

@ -3,7 +3,7 @@ import debounce from 'lodash/debounce';
import { select, takeLatest } from 'redux-saga/effects';
import { updateResume } from '@/services/resume';
import { RootState } from '@/store/index';
import { AppDispatch, RootState } from '@/store/index';
import {
addItem,
@ -12,23 +12,26 @@ import {
deleteSection,
duplicateItem,
editItem,
setResume,
setResumeState,
} from '../resume/resumeSlice';
const DEBOUNCE_WAIT = 1000;
const debouncedSync = debounce((resume: Resume) => updateResume(resume), DEBOUNCE_WAIT);
const debouncedSync = debounce(
(resume: Resume, dispatch: AppDispatch) => updateResume(resume).then((resume) => dispatch(setResume(resume))),
DEBOUNCE_WAIT
);
function* handleSync() {
function* handleSync(dispatch: AppDispatch) {
const resume: Resume = yield select((state: RootState) => state.resume.present);
debouncedSync(resume);
debouncedSync(resume, dispatch);
}
function* syncSaga() {
yield takeLatest(
[setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection],
handleSync
function* syncSaga(dispatch: AppDispatch) {
yield takeLatest([setResumeState, addItem, editItem, duplicateItem, deleteItem, addSection, deleteSection], () =>
handleSync(dispatch)
);
}

View File

@ -22,11 +22,19 @@
}
p {
@apply leading-relaxed;
@apply leading-normal;
}
a {
@apply cursor-pointer font-medium hover:underline;
@apply cursor-pointer font-medium;
}
.markdown {
@apply prose prose-sm leading-normal max-w-none prose-ul:p-0 prose-ul:my-0 prose-p:my-0;
ul li {
@apply ml-4 list-outside;
}
}
}

View File

@ -52,7 +52,7 @@
@apply grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6;
.image {
@apply relative h-64 rounded hover:opacity-75;
@apply relative h-48 rounded hover:opacity-75;
@apply border-2 dark:border-neutral-700;
}
}

View File

@ -17,7 +17,7 @@ const Castform: React.FC<PageProps> = ({ page }) => {
const isFirstPage = useMemo(() => page === 0, [page]);
const layout: string[][] = useAppSelector((state) => state.resume.present.metadata.layout[page]);
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
const color = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);

View File

@ -6,7 +6,7 @@ import { useMemo } from 'react';
import { useAppSelector } from '@/store/hooks';
const Heading: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const darkerPrimary = useMemo(() => darken(theme.primary, 0.2), [theme.primary]);
return (

View File

@ -19,7 +19,7 @@ export const MastheadSidebar: React.FC = () => {
const { name, headline, photo, email, phone, birthdate, website, location, profiles } = useAppSelector(
(state) => state.resume.present.basics
);
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
const color = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);
@ -35,34 +35,55 @@ export const MastheadSidebar: React.FC = () => {
/>
)}
<div>
<div className={clsx({ invert: contrast === 'light' })}>
<h1 className="mb-1">{name}</h1>
<p className="opacity-75">{headline}</p>
</div>
<div className={clsx('flex flex-col gap-2.5', css(`svg { color: ${color} }`))}>
<DataDisplay icon={<Room />} className="!gap-2 text-xs">
<DataDisplay icon={<Room />} className="!gap-2 text-xs" textClassName={clsx({ invert: contrast === 'light' })}>
{formatLocation(location)}
</DataDisplay>
<DataDisplay icon={<Cake />} className="!gap-2 text-xs">
<DataDisplay icon={<Cake />} className="!gap-2 text-xs" textClassName={clsx({ invert: contrast === 'light' })}>
{formatDateString(birthdate, dateFormat)}
</DataDisplay>
<DataDisplay icon={<Email />} className="!gap-2 text-xs" link={`mailto:${email}`}>
<DataDisplay
icon={<Email />}
className="!gap-2 text-xs"
link={`mailto:${email}`}
textClassName={clsx({ invert: contrast === 'light' })}
>
{email}
</DataDisplay>
<DataDisplay icon={<Phone />} className="!gap-2 text-xs" link={`tel:${phone}`}>
<DataDisplay
icon={<Phone />}
className="!gap-2 text-xs"
link={`tel:${phone}`}
textClassName={clsx({ invert: contrast === 'light' })}
>
{phone}
</DataDisplay>
<DataDisplay icon={<Public />} link={website && addHttp(website)} className="!gap-2 text-xs">
<DataDisplay
icon={<Public />}
link={website && addHttp(website)}
className="!gap-2 text-xs"
textClassName={clsx({ invert: contrast === 'light' })}
>
{website}
</DataDisplay>
{profiles.map(({ id, username, network, url }) => (
<DataDisplay key={id} icon={getProfileIcon(network)} link={url && addHttp(url)} className="!gap-2 text-xs">
<DataDisplay
key={id}
icon={getProfileIcon(network)}
link={url && addHttp(url)}
className="!gap-2 text-xs"
textClassName={clsx({ invert: contrast === 'light' })}
>
{username}
</DataDisplay>
))}

View File

@ -1,5 +1,6 @@
import { Email, Link, Phone } from '@mui/icons-material';
import { ListItem, Section as SectionType } from '@reactive-resume/schema';
import clsx from 'clsx';
import get from 'lodash/get';
import isArray from 'lodash/isArray';
import isEmpty from 'lodash/isEmpty';
@ -21,10 +22,12 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const layout: string[][][] = useAppSelector((state) => get(state.resume.present, 'metadata.layout'));
const sectionId = useMemo(() => section.id || path.replace('sections.', ''), [path, section]);
const isSidebarSection = useMemo(() => layout.some((row) => row[1].includes(sectionId)), [layout, sectionId]);
if (!section.visible) return null;
@ -35,7 +38,7 @@ const Section: React.FC<SectionProps> = ({
<Heading>{section.name}</Heading>
<div
className="grid items-start gap-4"
className={clsx('grid items-start gap-4', { invert: isSidebarSection })}
style={{ gridTemplateColumns: `repeat(${section.columns}, minmax(0, 1fr))` }}
>
{section.items.map((item: ListItem) => {
@ -44,13 +47,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} id={id} className="grid gap-1">
@ -76,8 +79,13 @@ const Section: React.FC<SectionProps> = ({
key={index}
className="mr-2 h-3 w-3 rounded-full border"
style={{
borderColor: 'var(--primary-color)',
backgroundColor: levelNum / (10 / 5) > index ? 'var(--primary-color)' : '',
borderColor: isSidebarSection ? 'var(--text-color)' : 'var(--primary-color)',
backgroundColor:
levelNum / (10 / 5) > index
? isSidebarSection
? 'var(--text-color)'
: 'var(--primary-color)'
: '',
}}
/>
))}
@ -94,7 +102,7 @@ const Section: React.FC<SectionProps> = ({
</DataDisplay>
)}
{keywords && <div>{keywords.join(', ')}</div>}
{keywords && <span>{keywords.join(', ')}</span>}
{(phone || email) && (
<div className="grid gap-1">

View File

@ -18,7 +18,7 @@ const Gengar: React.FC<PageProps> = ({ page }) => {
const isFirstPage = useMemo(() => page === 0, [page]);
const layout: string[][] = useAppSelector((state) => state.resume.present.metadata.layout[page]);
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
const backgroundColor: string = useMemo(() => alpha(theme.primary, 0.15), [theme.primary]);
const color = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);

View File

@ -4,7 +4,7 @@ import get from 'lodash/get';
import { useAppSelector } from '@/store/hooks';
const Heading: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
return (
<h3

View File

@ -20,7 +20,7 @@ export const MastheadSidebar: React.FC = () => {
const { name, headline, photo, email, phone, birthdate, website, location, profiles } = useAppSelector(
(state) => state.resume.present.basics
);
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
const iconColor = useMemo(() => (contrast === 'dark' ? theme.text : theme.background), [theme, contrast]);
@ -36,34 +36,55 @@ export const MastheadSidebar: React.FC = () => {
/>
)}
<div>
<div className={clsx({ invert: contrast === 'light' })}>
<h1 className="mb-1">{name}</h1>
<p className="opacity-75">{headline}</p>
</div>
<div className={clsx('flex flex-col gap-2.5', css(`svg { color: ${iconColor} }`))}>
<DataDisplay icon={<Room />} className="!gap-2 text-xs">
<DataDisplay icon={<Room />} className="!gap-2 text-xs" textClassName={clsx({ invert: contrast === 'light' })}>
{formatLocation(location)}
</DataDisplay>
<DataDisplay icon={<Cake />} className="!gap-2 text-xs">
<DataDisplay icon={<Cake />} className="!gap-2 text-xs" textClassName={clsx({ invert: contrast === 'light' })}>
{formatDateString(birthdate, dateFormat)}
</DataDisplay>
<DataDisplay icon={<Email />} className="!gap-2 text-xs" link={`mailto:${email}`}>
<DataDisplay
icon={<Email />}
className="!gap-2 text-xs"
link={`mailto:${email}`}
textClassName={clsx({ invert: contrast === 'light' })}
>
{email}
</DataDisplay>
<DataDisplay icon={<Phone />} className="!gap-2 text-xs" link={`tel:${phone}`}>
<DataDisplay
icon={<Phone />}
className="!gap-2 text-xs"
link={`tel:${phone}`}
textClassName={clsx({ invert: contrast === 'light' })}
>
{phone}
</DataDisplay>
<DataDisplay icon={<Public />} link={website && addHttp(website)} className="!gap-2 text-xs">
<DataDisplay
icon={<Public />}
link={website && addHttp(website)}
className="!gap-2 text-xs"
textClassName={clsx({ invert: contrast === 'light' })}
>
{website}
</DataDisplay>
{profiles.map(({ id, username, network, url }) => (
<DataDisplay key={id} icon={getProfileIcon(network)} link={url && addHttp(url)} className="!gap-2 text-xs">
<DataDisplay
key={id}
icon={getProfileIcon(network)}
link={url && addHttp(url)}
className="!gap-2 text-xs"
textClassName="invert"
>
{username}
</DataDisplay>
))}

View File

@ -21,7 +21,7 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const primaryColor: string = useAppSelector((state) => get(state.resume.present, 'metadata.theme.primary'));
@ -45,13 +45,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} id={id} className="grid gap-1">

View File

@ -13,7 +13,7 @@ type Props = {
};
const BadgeDisplay: React.FC<Props> = ({ items }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
if (!isArray(items) || isEmpty(items)) return null;
@ -21,15 +21,8 @@ const BadgeDisplay: React.FC<Props> = ({ items }) => {
return (
<ul className="mt-1 flex flex-wrap gap-2 text-xs">
{items.map((item) => (
<li
key={item}
className="rounded-sm px-2 py-0.5"
style={{
color: contrast === 'dark' ? theme.text : theme.background,
backgroundColor: alpha(theme.primary, 0.75),
}}
>
{item}
<li key={item} className="rounded-sm px-2 py-0.5" style={{ backgroundColor: alpha(theme.primary, 0.75) }}>
<span style={{ color: contrast === 'dark' ? theme.text : theme.background }}>{item}</span>
</li>
))}
</ul>

View File

@ -4,7 +4,7 @@ import get from 'lodash/get';
import { useAppSelector } from '@/store/hooks';
const Heading: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
return (
<h3

View File

@ -22,7 +22,7 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const primaryColor: string = useAppSelector((state) => get(state.resume.present, 'metadata.theme.primary'));
@ -46,13 +46,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} id={id} className="grid gap-1">

View File

@ -12,7 +12,7 @@ type Props = {
};
const BadgeDisplay: React.FC<Props> = ({ items }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
if (!isArray(items) || isEmpty(items)) return null;
@ -20,15 +20,8 @@ const BadgeDisplay: React.FC<Props> = ({ items }) => {
return (
<ul className="my-1 flex flex-wrap items-start justify-center gap-1.5">
{items.map((item) => (
<li
key={item}
className="rounded-lg px-2 py-0.5 text-xs"
style={{
color: contrast === 'dark' ? theme.text : theme.background,
backgroundColor: theme.primary,
}}
>
{item}
<li key={item} className="rounded-lg px-2 py-0.5 text-xs" style={{ backgroundColor: theme.primary }}>
<span style={{ color: contrast === 'dark' ? theme.text : theme.background }}>{item}</span>
</li>
))}
</ul>

View File

@ -21,7 +21,7 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const primaryColor: string = useAppSelector((state) => get(state.resume.present, 'metadata.theme.primary'));
@ -45,13 +45,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} id={id} className="grid gap-1">

View File

@ -1,5 +1,5 @@
.container {
@apply grid grid-cols-2 gap-4 px-6 py-4;
@apply grid grid-cols-2 gap-4 px-6 py-4 items-start;
.main {
@apply grid gap-4;

View File

@ -4,7 +4,7 @@ import get from 'lodash/get';
import { useAppSelector } from '@/store/hooks';
const Heading: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
return (
<h2

View File

@ -16,7 +16,7 @@ const Masthead: React.FC = () => {
const { name, photo, headline, summary, email, phone, birthdate, website, location, profiles } = useAppSelector(
(state) => state.resume.present.basics
);
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
return (
<div>

View File

@ -21,7 +21,7 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const primaryColor: string = useAppSelector((state) => get(state.resume.present, 'metadata.theme.primary'));
@ -45,13 +45,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} className="mb-2 grid gap-1">

View File

@ -4,7 +4,7 @@ import get from 'lodash/get';
import { useAppSelector } from '@/store/hooks';
const Heading: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
return (
<h4 className="mb-2 font-bold uppercase" style={{ color: theme.primary }}>

View File

@ -21,7 +21,7 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const primaryColor: string = useAppSelector((state) => get(state.resume.present, 'metadata.theme.primary'));
@ -45,13 +45,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} id={id} className="grid gap-1">

View File

@ -4,7 +4,7 @@ import get from 'lodash/get';
import { useAppSelector } from '@/store/hooks';
const Heading: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
return (
<h3

View File

@ -1,5 +1,6 @@
import { Cake, Email, Phone, Public, Room } from '@mui/icons-material';
import { ThemeConfig } from '@reactive-resume/schema';
import clsx from 'clsx';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import { useMemo } from 'react';
@ -62,7 +63,7 @@ export const MastheadSidebar: React.FC = () => {
};
export const MastheadMain: React.FC = () => {
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {}));
const theme: ThemeConfig = useAppSelector((state) => get(state.resume.present, 'metadata.theme', {} as ThemeConfig));
const contrast = useMemo(() => getContrastColor(theme.primary), [theme.primary]);
const { name, summary, headline } = useAppSelector((state) => state.resume.present.basics);
@ -72,14 +73,14 @@ export const MastheadMain: React.FC = () => {
className="grid gap-2 p-4"
style={{ color: contrast === 'dark' ? theme.text : theme.background, backgroundColor: theme.primary }}
>
<div>
<div className={clsx({ invert: contrast === 'light' })}>
<h1>{name}</h1>
<p className="opacity-75">{headline}</p>
</div>
<hr className="opacity-25" />
<Markdown>{summary}</Markdown>
<Markdown className={clsx({ invert: contrast === 'light' })}>{summary}</Markdown>
</div>
);
};

View File

@ -21,7 +21,7 @@ const Section: React.FC<SectionProps> = ({
headlinePath = 'headline',
keywordsPath = 'keywords',
}) => {
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {}));
const section: SectionType = useAppSelector((state) => get(state.resume.present, path, {} as SectionType));
const dateFormat: string = useAppSelector((state) => get(state.resume.present, 'metadata.date.format'));
const primaryColor: string = useAppSelector((state) => get(state.resume.present, 'metadata.theme.primary'));
@ -45,13 +45,13 @@ const Section: React.FC<SectionProps> = ({
subtitle = parseListItemPath(item, subtitlePath),
headline = parseListItemPath(item, headlinePath),
keywords: string[] = get(item, keywordsPath),
url: string = get(item, 'url'),
summary: string = get(item, 'summary'),
level: string = get(item, 'level'),
levelNum: number = get(item, 'levelNum'),
phone: string = get(item, 'phone'),
email: string = get(item, 'email'),
date = formatDateString(get(item, 'date'), dateFormat);
url: string = get(item, 'url', ''),
level: string = get(item, 'level', ''),
phone: string = get(item, 'phone', ''),
email: string = get(item, 'email', ''),
summary: string = get(item, 'summary', ''),
levelNum: number = get(item, 'levelNum', 0),
date = formatDateString(get(item, 'date', ''), dateFormat);
return (
<div key={id} id={id} className="grid gap-1">

View File

@ -1,3 +1,4 @@
import { find } from 'lodash';
import get from 'lodash/get';
import React from 'react';
import { validate } from 'uuid';
@ -44,11 +45,21 @@ const sectionMap = (Section: React.FC<SectionProps>): Record<string, JSX.Element
});
export const getSectionById = (id: string, Section: React.FC<SectionProps>): JSX.Element => {
// Check if section id is a custom section (an uuid)
if (validate(id)) {
return <Section key={id} path={`sections.${id}`} />;
}
return get(sectionMap(Section), id);
// Check if section id is a predefined seciton in config
const predefinedSection = get(sectionMap(Section), id);
if (predefinedSection) {
return predefinedSection;
}
// Other ways section should be a cloned section
const section = find(sectionMap(Section), (element, key) => id.includes(key));
return React.cloneElement(section!, { path: `sections.${id}` });
};
export default sectionMap;

View File

@ -5,16 +5,17 @@ type Props = {
icon?: JSX.Element;
link?: string;
className?: string;
textClassName?: string;
};
const DataDisplay: React.FC<React.PropsWithChildren<Props>> = ({ icon, link, className, children }) => {
const DataDisplay: React.FC<React.PropsWithChildren<Props>> = ({ icon, link, className, textClassName, children }) => {
if (isEmpty(children)) return null;
if (!isEmpty(link)) {
return (
<div className={clsx('inline-flex items-center gap-1', className)}>
{icon}
<a href={link} target="_blank" rel="noreferrer">
<a href={link} target="_blank" rel="noreferrer" className={textClassName}>
{children}
</a>
</div>
@ -24,7 +25,7 @@ const DataDisplay: React.FC<React.PropsWithChildren<Props>> = ({ icon, link, cla
return (
<div className={clsx('inline-flex items-center gap-1', className)}>
{icon}
<span>{children}</span>
<span className={textClassName}>{children}</span>
</div>
);
};

View File

@ -30,7 +30,7 @@ export const formatDateString = (date: string | DateRange, formatStr: string): s
if (isString(date)) {
if (!dayjs(date).isValid()) return null;
return dayjs(date).utc(true).format(formatStr);
return dayjs(date).format(formatStr);
}
// If `date` is a DateRange
@ -38,9 +38,13 @@ export const formatDateString = (date: string | DateRange, formatStr: string): s
if (!dayjs(date.start).isValid()) return null;
if (!isEmpty(date.end) && dayjs(date.end).isValid()) {
return `${dayjs(date.start).utc(true).format(formatStr)} - ${dayjs(date.end).utc(true).format(formatStr)}`;
if (dayjs(date.start).isSame(date.end)) {
return dayjs(date.start).format(formatStr);
}
return `${dayjs(date.start).utc(true).format(formatStr)} - ${presentString}`;
if (!isEmpty(date.end) && dayjs(date.end).isValid()) {
return `${dayjs(date.start).format(formatStr)} - ${dayjs(date.end).format(formatStr)}`;
}
return `${dayjs(date.start).format(formatStr)} - ${presentString}`;
};

View File

@ -4,10 +4,10 @@ import { RgbColor } from 'react-colorful';
import { hexColorPattern } from '@/config/colors';
export const generateTypographyStyles = ({ family, size }: Typography): string => `
font-size: ${size.body}px;
font-family: ${family.body};
font-size: ${size.body}px !important;
font-family: ${family.body} !important;
svg { font-size: ${size.body}px; }
p, li, svg { font-size: ${size.body}px !important; line-height: ${size.body * 1.5}px !important; }
h1,
h2,
@ -15,22 +15,38 @@ export const generateTypographyStyles = ({ family, size }: Typography): string =
h4,
h5,
h6 {
font-weight: bold;
font-family: ${family.heading};
font-weight: bold !important;
font-family: ${family.heading} !important;
}
h1 { font-size: ${size.heading}px; line-height: ${size.heading}px; }
h2 { font-size: ${size.heading / 1.5}px; line-height: ${size.heading / 1.5}px; }
h3 { font-size: ${size.heading / 2}px; line-height: ${size.heading / 2}px; }
h4 { font-size: ${size.heading / 2.5}px; line-height: ${size.heading / 2.5}px; }
h5 { font-size: ${size.heading / 3}px; line-height: ${size.heading / 3}px; }
h6 { font-size: ${size.heading / 3.5}px; line-height: ${size.heading / 3.5}px; }
h1 { font-size: ${size.heading}px !important; line-height: ${size.heading}px !important; }
h2 { font-size: ${size.heading / 1.5}px !important; line-height: ${size.heading / 1.5}px !important; }
h3 { font-size: ${size.heading / 2}px !important; line-height: ${size.heading / 2}px !important; }
h4 { font-size: ${size.heading / 2.5}px !important; line-height: ${size.heading / 2.5}px !important; }
h5 { font-size: ${size.heading / 3}px !important; line-height: ${size.heading / 3}px !important; }
h6 { font-size: ${size.heading / 3.5}px !important; line-height: ${size.heading / 3.5}px !important; }
`;
export const generateThemeStyles = ({ text, background, primary }: ThemeConfig): string => `
color: ${text};
background-color: ${background};
--primary-color: ${primary};
--text-color: ${text} !important;
--primary-color: ${primary} !important;
--background-color: ${background} !important;
color: var(--text-color);
background-color: var(--background-color);
span,
h1,
h2,
h3,
h4,
h5,
h6,
li,
p,
a {
color: var(--text-color);
}
svg {
color: var(--primary-color);

View File

@ -9,9 +9,9 @@ const FontWrapper: React.FC<React.PropsWithChildren<unknown>> = ({ children }) =
const loadFonts = useCallback(async () => {
const WebFont = (await import('webfontloader')).default;
const families = Object.values<string[]>(typography.family).reduce(
const families = Object.values(typography.family).reduce(
(acc, family) => [...acc, `${family}:400,600,700`],
[]
[] as string[]
);
WebFont.load({ google: { families } });

View File

@ -1,17 +0,0 @@
import { useHotkeys } from 'react-hotkeys-hook';
import { toggleSidebar } from '@/store/build/buildSlice';
import { useAppDispatch } from '@/store/hooks';
const HotkeysWrapper: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
const dispatch = useAppDispatch();
useHotkeys('ctrl+/, cmd+/', () => {
dispatch(toggleSidebar({ sidebar: 'left' }));
dispatch(toggleSidebar({ sidebar: 'right' }));
});
return <>{children}</>;
};
export default HotkeysWrapper;

View File

@ -1,17 +1,14 @@
import DateWrapper from './DateWrapper';
import FontWrapper from './FontWrapper';
import HotkeysWrapper from './HotkeysWrapper';
import ThemeWrapper from './ThemeWrapper';
const WrapperRegistry: React.FC<React.PropsWithChildren<unknown>> = ({ children }) => {
return (
<ThemeWrapper>
<FontWrapper>
<HotkeysWrapper>
<DateWrapper>
<>{children}</>
</DateWrapper>
</HotkeysWrapper>
<DateWrapper>
<>{children}</>
</DateWrapper>
</FontWrapper>
</ThemeWrapper>
);

View File

@ -3,7 +3,6 @@ version: "3.8"
services:
postgres:
image: postgres:alpine
container_name: postgres
restart: always
ports:
- 5432:5432
@ -25,7 +24,6 @@ services:
# build:
# context: .
# dockerfile: ./server/Dockerfile
container_name: server
restart: always
ports:
- 3100:3100
@ -58,13 +56,13 @@ services:
- STORAGE_URL_PREFIX=
- STORAGE_ACCESS_KEY=
- STORAGE_SECRET_KEY=
- PDF_DELETION_TIME=
client:
image: amruthpillai/reactive-resume:client-latest
# build:
# context: .
# dockerfile: ./client/Dockerfile
container_name: client
restart: always
ports:
- 3000:3000
@ -76,4 +74,4 @@ services:
- PUBLIC_GOOGLE_CLIENT_ID=
volumes:
pgdata:
pgdata:

View File

@ -1,6 +1,6 @@
{
"name": "reactive-resume",
"version": "3.6.8",
"version": "3.6.12",
"private": true,
"scripts": {
"dev": "env-cmd --silent turbo run dev",
@ -17,17 +17,17 @@
],
"dependencies": {
"env-cmd": "^10.1.0",
"turbo": "^1.5.6"
"turbo": "^1.6.3"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.40.0",
"@typescript-eslint/parser": "^5.40.0",
"eslint": "^8.25.0",
"@typescript-eslint/eslint-plugin": "^5.44.0",
"@typescript-eslint/parser": "^5.44.0",
"eslint": "^8.28.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-simple-import-sort": "^8.0.0",
"prettier": "^2.7.1",
"prettier": "^2.8.0",
"standard-version": "^9.5.0",
"typescript": "^4.8.4"
"typescript": "^4.9.3"
},
"resolutions": {
"@types/react": "17.0.2",

3211
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -9,7 +9,7 @@
"build": "tsc"
},
"devDependencies": {
"eslint": "^8.25.0",
"typescript": "^4.8.4"
"eslint": "^8.28.0",
"typescript": "^4.9.3"
}
}

View File

@ -9,8 +9,8 @@ export type PageConfig = {
export type ThemeConfig = {
text: string;
background: string;
primary: string;
background: string;
};
export type TypeCategory = 'heading' | 'body';

View File

@ -125,7 +125,22 @@ export type ListItem =
| WorkExperience
| Custom;
export type SectionType = 'basic' | 'custom';
export type SectionType =
| 'basic'
| 'location'
| 'profiles'
| 'education'
| 'awards'
| 'certifications'
| 'publications'
| 'skills'
| 'languages'
| 'interests'
| 'volunteer'
| 'projects'
| 'references'
| 'custom'
| 'work';
export type SectionPath = `sections.${string}`;
@ -136,4 +151,5 @@ export type Section = {
columns: number;
visible: boolean;
items: ListItem[];
isDuplicated?: boolean;
};

View File

@ -8,29 +8,29 @@
"start": "node dist/main"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.188.0",
"@nestjs/axios": "^0.1.0",
"@nestjs/common": "^9.1.4",
"@aws-sdk/client-s3": "^3.216.0",
"@nestjs/axios": "^1.0.0",
"@nestjs/common": "^9.2.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.1.4",
"@nestjs/core": "^9.2.0",
"@nestjs/jwt": "^9.0.0",
"@nestjs/mapped-types": "^1.2.0",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.1.4",
"@nestjs/platform-express": "^9.2.0",
"@nestjs/schedule": "^2.1.0",
"@nestjs/serve-static": "^3.0.0",
"@nestjs/terminus": "^9.1.2",
"@nestjs/terminus": "^9.1.3",
"@nestjs/typeorm": "^9.0.1",
"@types/passport": "^1.0.11",
"bcryptjs": "^2.4.3",
"cache-manager": "^5.0.1",
"cache-manager": "^5.1.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.13.2",
"cookie-parser": "^1.4.6",
"csvtojson": "^2.0.10",
"dayjs": "^1.11.5",
"google-auth-library": "^8.5.2",
"joi": "^17.6.3",
"dayjs": "^1.11.6",
"google-auth-library": "^8.7.0",
"joi": "^17.7.0",
"lodash": "^4.17.21",
"multer": "^1.4.4",
"nanoid": "^3.3.4",
@ -41,7 +41,7 @@
"passport-local": "^1.0.0",
"pdf-lib": "^1.17.1",
"pg": "^8.8.0",
"playwright-chromium": "^1.27.1",
"playwright-chromium": "^1.28.1",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^7.5.7",
@ -49,24 +49,24 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^9.1.4",
"@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3",
"@reactive-resume/schema": "workspace:*",
"@types/bcryptjs": "^2.4.2",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.14",
"@types/lodash": "^4.14.186",
"@types/lodash": "^4.14.190",
"@types/multer": "^1.4.7",
"@types/node": "^18.11.0",
"@types/node": "^18.11.9",
"@types/nodemailer": "^6.4.6",
"@types/passport-jwt": "^3.0.7",
"@types/passport-local": "^1.0.34",
"prettier": "^2.7.1",
"prettier": "^2.8.0",
"source-map-support": "^0.5.21",
"ts-loader": "^9.4.1",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^4.8.4",
"webpack": "^5.74.0"
"typescript": "^4.9.3",
"webpack": "^5.75.0"
}
}

View File

@ -0,0 +1,5 @@
import { registerAs } from '@nestjs/config';
export default registerAs('cache', () => ({
pdfDeletionTime: parseInt(process.env.PDF_DELETION_TIME, 10) || 4 * 24 * 60 * 60 * 1000, // 4 days
}));

View File

@ -4,6 +4,7 @@ import Joi from 'joi';
import appConfig from './app.config';
import authConfig from './auth.config';
import cacheConfig from './cache.config';
import databaseConfig from './database.config';
import googleConfig from './google.config';
import mailConfig from './mail.config';
@ -52,12 +53,17 @@ const validationSchema = Joi.object({
STORAGE_URL_PREFIX: Joi.string().allow(''),
STORAGE_ACCESS_KEY: Joi.string().allow(''),
STORAGE_SECRET_KEY: Joi.string().allow(''),
// Cache
PDF_DELETION_TIME: Joi.number()
.default(4 * 24 * 60 * 60 * 1000) // 4 days
.allow(''),
});
@Module({
imports: [
NestConfigModule.forRoot({
load: [appConfig, authConfig, databaseConfig, googleConfig, mailConfig, storageConfig],
load: [appConfig, authConfig, cacheConfig, databaseConfig, googleConfig, mailConfig, storageConfig],
validationSchema: validationSchema,
}),
],

View File

@ -3,11 +3,7 @@ import { HealthCheck, HealthCheckService, HttpHealthIndicator, TypeOrmHealthIndi
@Controller('health')
export class HealthController {
constructor(
private health: HealthCheckService,
private db: TypeOrmHealthIndicator,
private http: HttpHealthIndicator
) {}
constructor(private health: HealthCheckService, private db: TypeOrmHealthIndicator) {}
@Get()
@HealthCheck()

View File

@ -35,6 +35,7 @@ export class IntegrationsService {
async linkedIn(userId: number, path: string): Promise<ResumeEntity> {
let archive: StreamZip.StreamZipAsync;
let isArchiveValid = false;
try {
archive = new StreamZip.async({ file: path });
@ -48,6 +49,9 @@ export class IntegrationsService {
slug: `imported-from-linkedin-${timestamp}`,
});
// Check if archive is valid
isArchiveValid = await archive.entries().then((entries) => Object.keys(entries).length > 0);
// Profile
try {
const profileCSV = (await archive.entryData('Profile.csv')).toString();
@ -261,7 +265,7 @@ export class IntegrationsService {
throw new HttpException('You must upload a valid zip archive downloaded from LinkedIn.', HttpStatus.BAD_REQUEST);
} finally {
await unlink(path);
!isEmpty(archive) && archive.close();
isArchiveValid && archive.close();
}
}

View File

@ -8,6 +8,7 @@ import { AppModule } from './app.module';
const bootstrap = async () => {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);
// Middleware
app.enableCors({ credentials: true });
@ -17,9 +18,8 @@ const bootstrap = async () => {
// Pipes
app.useGlobalPipes(new ValidationPipe({ transform: true }));
const configService = app.get(ConfigService);
// Server Port
const port = configService.get<number>('app.port');
await app.listen(port);
Logger.log(`🚀 Server is up and running!`);

View File

@ -1,4 +1,4 @@
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, InternalServerErrorException, Param, Query } from '@nestjs/common';
import { PrinterService } from './printer.service';
@ -7,7 +7,11 @@ export class PrinterController {
constructor(private readonly printerService: PrinterService) {}
@Get('/:username/:slug')
printAsPdf(@Param('username') username: string, @Param('slug') slug: string): Promise<string> {
return this.printerService.printAsPdf(username, slug);
printAsPdf(
@Param('username') username: string,
@Param('slug') slug: string,
@Query('lastUpdated') lastUpdated: string
): Promise<string> {
return this.printerService.printAsPdf(username, slug, lastUpdated);
}
}

View File

@ -2,14 +2,11 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PageConfig } from '@reactive-resume/schema';
import { mkdir, unlink, writeFile } from 'fs/promises';
import { nanoid } from 'nanoid';
import { access, mkdir, readdir, unlink, writeFile } from 'fs/promises';
import { join } from 'path';
import { PDFDocument } from 'pdf-lib';
import { Browser, chromium } from 'playwright-chromium';
export const DELETION_TIME = 10 * 1000; // 10 seconds
@Injectable()
export class PrinterService implements OnModuleInit, OnModuleDestroy {
private browser: Browser;
@ -26,68 +23,92 @@ export class PrinterService implements OnModuleInit, OnModuleDestroy {
await this.browser.close();
}
async printAsPdf(username: string, slug: string): Promise<string> {
const url = this.configService.get<string>('app.url');
async printAsPdf(username: string, slug: string, lastUpdated: string): Promise<string> {
const serverUrl = this.configService.get<string>('app.serverUrl');
const secretKey = this.configService.get<string>('app.secretKey');
const page = await this.browser.newPage();
await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
await page.waitForSelector('html.wf-active');
const pageFormat: PageConfig['format'] = await page.$$eval(
'[data-page]',
(pages) => pages[0].getAttribute('data-format') as PageConfig['format']
);
const resumePages = await page.$$eval('[data-page]', (pages) =>
pages.map((page, index) => ({
pageNumber: index + 1,
innerHTML: page.innerHTML,
height: page.clientHeight,
}))
);
const pdf = await PDFDocument.create();
const directory = join(__dirname, '..', 'assets/exports');
const filename = `RxResume_PDFExport_${nanoid()}.pdf`;
const filename = `RxResume_PDFExport_${username}_${slug}_${lastUpdated}.pdf`;
const publicUrl = `${serverUrl}/assets/exports/${filename}`;
for (let index = 0; index < resumePages.length; index++) {
await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]);
const buffer = await page.pdf({
printBackground: true,
height: resumePages[index].height,
width: pageFormat === 'A4' ? '210mm' : '216mm',
try {
// check if file already exists
await access(join(directory, filename));
} catch {
// delete old files and scheduler jobs
const activeSchedulerTimeouts = this.schedulerRegistry.getTimeouts();
await readdir(directory).then(async (files) => {
await Promise.all(
files.map(async (file) => {
if (file.startsWith(`RxResume_PDFExport_${username}_${slug}`)) {
await unlink(join(directory, file));
if (activeSchedulerTimeouts[`delete-${file}`]) {
this.schedulerRegistry.deleteTimeout(`delete-${file}`);
}
}
})
);
});
const pageDoc = await PDFDocument.load(buffer);
const copiedPages = await pdf.copyPages(pageDoc, [0]);
// create file as it doesn't exist
const url = this.configService.get<string>('app.url');
const secretKey = this.configService.get<string>('app.secretKey');
const pdfDeletionTime = this.configService.get<number>('cache.pdfDeletionTime');
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage));
}
const page = await this.browser.newPage();
await page.close();
await page.goto(`${url}/${username}/${slug}/printer?secretKey=${secretKey}`);
await page.waitForSelector('html.wf-active');
const pdfBytes = await pdf.save();
const pageFormat: PageConfig['format'] = await page.$$eval(
'[data-page]',
(pages) => pages[0].getAttribute('data-format') as PageConfig['format']
);
await mkdir(directory, { recursive: true });
await writeFile(join(directory, filename), pdfBytes);
const resumePages = await page.$$eval('[data-page]', (pages) =>
pages.map((page, index) => ({
pageNumber: index + 1,
innerHTML: page.innerHTML,
height: page.clientHeight,
}))
);
// Delete PDF artifacts after DELETION_TIME ms
const timeout = setTimeout(async () => {
try {
await unlink(join(directory, filename));
const pdf = await PDFDocument.create();
this.schedulerRegistry.deleteTimeout(`delete-${filename}`);
} catch {
// pass through
for (let index = 0; index < resumePages.length; index++) {
await page.evaluate((page) => (document.body.innerHTML = page.innerHTML), resumePages[index]);
const buffer = await page.pdf({
printBackground: true,
height: resumePages[index].height,
width: pageFormat === 'A4' ? '210mm' : '216mm',
});
const pageDoc = await PDFDocument.load(buffer);
const copiedPages = await pdf.copyPages(pageDoc, [0]);
copiedPages.forEach((copiedPage) => pdf.addPage(copiedPage));
}
}, DELETION_TIME);
this.schedulerRegistry.addTimeout(`delete-${filename}`, timeout);
await page.close();
const pdfBytes = await pdf.save();
await mkdir(directory, { recursive: true });
await writeFile(join(directory, filename), pdfBytes);
// Delete PDF artifacts after `pdfDeletionTime` ms
const timeout = setTimeout(async () => {
try {
await unlink(join(directory, filename));
this.schedulerRegistry.deleteTimeout(`delete-${filename}`);
} catch {
// pass through
}
}, pdfDeletionTime);
this.schedulerRegistry.addTimeout(`delete-${filename}`, timeout);
}
return publicUrl;
}

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