diff --git a/package.json b/package.json index b987857b..4be2a19c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "cookies-next": "^2.1.1", "cypress": "13.6.2", "ics": "^3.7.2", + "jszip": "^3.10.1", "luxon": "^3.3.0", "next": "^13.2.5-canary.30", "next-pwa": "^5.6.0", diff --git a/src/components/admin/Resume/index.tsx b/src/components/admin/Resume/index.tsx new file mode 100644 index 00000000..72268845 --- /dev/null +++ b/src/components/admin/Resume/index.tsx @@ -0,0 +1,46 @@ +import { GifSafeImage, Typography } from '@/components/common'; +import { config } from '@/lib'; +import { PublicResume } from '@/lib/types/apiResponses'; +import { getFileName, getProfilePicture } from '@/lib/utils'; +import Link from 'next/link'; +import { BsDownload } from 'react-icons/bs'; +import styles from './style.module.scss'; + +interface ResumeProps { + resume: PublicResume; +} + +const Resume = ({ resume }: ResumeProps) => { + const fileName = getFileName(resume.url, 'resume.pdf'); + + return ( +
+ {resume.user ? ( + + + + {resume.user.firstName} {resume.user.lastName} + +

+ {resume.user.major} ({resume.user.graduationYear}) +

+ + ) : null} + + +

{fileName}

+

+ Uploaded {new Date(resume.lastUpdated).toLocaleDateString('en-US', { dateStyle: 'full' })} +

+ +
+ ); +}; + +export default Resume; diff --git a/src/components/admin/Resume/style.module.scss b/src/components/admin/Resume/style.module.scss new file mode 100644 index 00000000..b1fafaf8 --- /dev/null +++ b/src/components/admin/Resume/style.module.scss @@ -0,0 +1,73 @@ +@use 'src/styles/vars.scss' as vars; + +.wrapper { + background-color: var(--theme-elevated-background); + border: 1px solid var(--theme-elevated-stroke); + border-radius: 0.5rem; + display: flex; + gap: 1rem; + padding: 1rem; + word-break: break-word; + + @media (max-width: vars.$breakpoint-md) { + flex-direction: column; + gap: 2rem; + } + + .user, + .resume { + align-items: center; + display: grid; + gap: 0.5rem 1rem; + grid-template-areas: + 'image name' + 'image info'; + grid-template-columns: auto 1fr; + + .image { + grid-area: image; + } + + .name { + grid-area: name; + } + + &:hover .name { + text-decoration: underline; + } + + .info { + grid-area: info; + } + } + + .user { + flex: 1 0 0; + + .image { + border-radius: 5rem; + object-fit: contain; + } + } + + .resume { + flex: 1.5 0 0; + + .image { + height: 1.5rem; + width: 1.5rem; + + @media (max-width: vars.$breakpoint-md) { + width: 3rem; + } + } + + .name { + color: var(--theme-blue-text-button); + } + + .info { + color: var(--theme-text-on-background-2); + } + } +} diff --git a/src/components/admin/Resume/style.module.scss.d.ts b/src/components/admin/Resume/style.module.scss.d.ts new file mode 100644 index 00000000..fe443828 --- /dev/null +++ b/src/components/admin/Resume/style.module.scss.d.ts @@ -0,0 +1,14 @@ +export type Styles = { + image: string; + info: string; + name: string; + resume: string; + user: string; + wrapper: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/src/lib/api/ResumeAPI.ts b/src/lib/api/ResumeAPI.ts index adddd0e0..92b71706 100644 --- a/src/lib/api/ResumeAPI.ts +++ b/src/lib/api/ResumeAPI.ts @@ -1,10 +1,11 @@ import { config } from '@/lib'; import type { UUID } from '@/lib/types'; import { PatchResumeRequest } from '@/lib/types/apiRequests'; -import type { - PatchResumeResponse, - PublicResume, - UpdateResumeResponse, +import { + GetVisibleResumesResponse, + type PatchResumeResponse, + type PublicResume, + type UpdateResumeResponse, } from '@/lib/types/apiResponses'; import axios from 'axios'; @@ -76,3 +77,20 @@ export const deleteResume = async (token: string, uuid: UUID): Promise => }, }); }; + +/** + * Get all visible resumes + * @param token Authorization bearer token. User needs to be admin or + * sponsorship manager + */ +export const getResumes = async (token: string): Promise => { + const requestUrl = `${config.api.baseUrl}${config.api.endpoints.resume}`; + + const response = await axios.get(requestUrl, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + return response.data.resumes; +}; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ae653cf2..9ab7ac56 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -24,7 +24,7 @@ import { type StaticImport, type StaticRequire, } from 'next/dist/shared/lib/get-img-props'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo } from 'react'; /** * Get next `num` years from today in a number array to generate dropdown options for future selections @@ -170,18 +170,15 @@ export const isSrcAGif = (src: string | StaticImport): boolean => { * @returns The object URL. Defaults an empty string if `file` is empty. */ export function useObjectUrl(file?: Blob | null): string { - const [url, setUrl] = useState(''); + const url = useMemo(() => (file ? URL.createObjectURL(file) : ''), [file]); useEffect(() => { - if (!file) { - return undefined; - } - const url = URL.createObjectURL(file); - setUrl(url); return () => { - URL.revokeObjectURL(url); + if (url !== '') { + URL.revokeObjectURL(url); + } }; - }, [file]); + }, [url]); return url; } @@ -463,3 +460,10 @@ export function seededRandom(a: number, b: number, c: number, d: number): () => /* eslint-enable no-bitwise, no-param-reassign */ }; } + +/** + * Gets the file name from a URL by taking the last part of the URL. + */ +export function getFileName(url: string, defaultName: string): string { + return decodeURIComponent(url.split('/').at(-1) ?? defaultName); +} diff --git a/src/pages/admin/resumes.tsx b/src/pages/admin/resumes.tsx new file mode 100644 index 00000000..5f8aebea --- /dev/null +++ b/src/pages/admin/resumes.tsx @@ -0,0 +1,102 @@ +import Resume from '@/components/admin/Resume'; +import { PaginationControls, Typography } from '@/components/common'; +import { config } from '@/lib'; +import { ResumeAPI } from '@/lib/api'; +import withAccessType, { GetServerSidePropsWithAuth } from '@/lib/hoc/withAccessType'; +import { PermissionService } from '@/lib/services'; +import type { PublicResume } from '@/lib/types/apiResponses'; +import { getFileName, useObjectUrl } from '@/lib/utils'; +import styles from '@/styles/pages/resumes.module.scss'; +import JSZip from 'jszip'; +import { useEffect, useRef, useState } from 'react'; +import { BsDownload } from 'react-icons/bs'; + +const ROWS_PER_PAGE = 25; + +type DownloadState = + | { stage: 'idle' } + | { stage: 'downloading'; loaded: number } + | { stage: 'ready'; blob: Blob }; + +interface AdminResumePageProps { + resumes: PublicResume[]; +} + +const AdminResumePage = ({ resumes }: AdminResumePageProps) => { + const [page, setPage] = useState(0); + const [downloadState, setDownloadState] = useState({ stage: 'idle' }); + const zipUrl = useObjectUrl(downloadState.stage === 'ready' ? downloadState.blob : null); + const downloadButton = useRef(null); + + useEffect(() => { + if (zipUrl !== '') { + downloadButton.current?.click(); + } + }, [zipUrl]); + + return ( +
+ + Resumes + {downloadState.stage === 'idle' ? ( + + ) : null} + {downloadState.stage === 'downloading' ? ( + + Downloading {resumes.length - downloadState.loaded} of {resumes.length}... + + ) : null} + {downloadState.stage === 'ready' ? ( + + Download All + + ) : null} + + {resumes.slice(page * ROWS_PER_PAGE, (page + 1) * ROWS_PER_PAGE).map(resume => ( + + ))} + setPage(page)} + pages={Math.ceil(resumes.length / ROWS_PER_PAGE)} + /> +
+ ); +}; + +export default AdminResumePage; + +const getServerSidePropsFunc: GetServerSidePropsWithAuth = async ({ + authToken, +}) => { + const resumes = await ResumeAPI.getResumes(authToken); + return { props: { title: 'View Resumes', resumes } }; +}; + +export const getServerSideProps = withAccessType( + getServerSidePropsFunc, + PermissionService.canViewResumes, + { redirectTo: config.homeRoute } +); diff --git a/src/styles/pages/resumes.module.scss b/src/styles/pages/resumes.module.scss new file mode 100644 index 00000000..e8258a22 --- /dev/null +++ b/src/styles/pages/resumes.module.scss @@ -0,0 +1,30 @@ +.page { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 0 auto; + max-width: 60rem; + + .header { + align-items: center; + display: flex; + justify-content: space-between; + + .button { + align-items: center; + background-color: var(--theme-primary-2); + border-radius: 0.5rem; + color: var(--theme-background); + display: flex; + font-size: 1rem; + gap: 1rem; + line-height: 1.5; + padding: 0.5rem 1rem; + } + + .downloading { + color: var(--theme-text-on-background-2); + font-size: 1rem; + } + } +} diff --git a/src/styles/pages/resumes.module.scss.d.ts b/src/styles/pages/resumes.module.scss.d.ts new file mode 100644 index 00000000..a2166310 --- /dev/null +++ b/src/styles/pages/resumes.module.scss.d.ts @@ -0,0 +1,12 @@ +export type Styles = { + button: string; + downloading: string; + header: string; + page: string; +}; + +export type ClassNames = keyof Styles; + +declare const styles: Styles; + +export default styles; diff --git a/yarn.lock b/yarn.lock index 97397072..08b034e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2976,6 +2976,11 @@ core-util-is@1.0.2: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -4551,6 +4556,11 @@ ignore@^5.1.1, ignore@^5.2.0, ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.0.tgz#67418ae40d34d6999c95ff56016759c718c82f78" integrity sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg== +immediate@~3.0.5: + version "3.0.6" + resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" + integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== + immutable@^4.0.0: version "4.3.4" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.4.tgz#2e07b33837b4bb7662f288c244d1ced1ef65a78f" @@ -4592,7 +4602,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4881,6 +4891,11 @@ isarray@^2.0.5: resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -5058,6 +5073,16 @@ jssha@^3.1.2: object.assign "^4.1.4" object.values "^1.1.6" +jszip@^3.10.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" + integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g== + dependencies: + lie "~3.3.0" + pako "~1.0.2" + readable-stream "~2.3.6" + setimmediate "^1.0.5" + keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -5105,6 +5130,13 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" +lie@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a" + integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ== + dependencies: + immediate "~3.0.5" + lines-and-columns@^1.1.6: version "1.2.4" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" @@ -5638,6 +5670,11 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pako@~1.0.2: + version "1.0.11" + resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" + integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -5903,6 +5940,11 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.4.1, pretty-bytes@^5.6.0: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -6081,6 +6123,19 @@ readable-stream@^3.1.1, readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@~2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -6303,6 +6358,11 @@ safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + safe-regex-test@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" @@ -6405,6 +6465,11 @@ set-function-name@^2.0.0, set-function-name@^2.0.1: functions-have-names "^1.2.3" has-property-descriptors "^1.0.0" +setimmediate@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" + integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== + sharp@^0.32.6: version "0.32.6" resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.32.6.tgz#6ad30c0b7cd910df65d5f355f774aa4fce45732a" @@ -6659,6 +6724,13 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + stringify-object@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" @@ -7270,7 +7342,7 @@ url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==