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==