Skip to content

Commit

Permalink
Admin view resumes page (#248)
Browse files Browse the repository at this point in the history
* Admin view resumes page

* Add pagination

* Zip all resumes
  • Loading branch information
SheepTester authored May 5, 2024
1 parent da16ced commit 8219c00
Show file tree
Hide file tree
Showing 10 changed files with 387 additions and 15 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions src/components/admin/Resume/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.wrapper}>
{resume.user ? (
<Link href={`${config.userProfileRoute}${resume.user.handle}`} className={styles.user}>
<GifSafeImage
src={getProfilePicture(resume.user)}
width={48}
height={48}
alt={`Profile picture for ${resume.user.firstName} ${resume.user.lastName}`}
className={styles.image}
/>
<Typography variant="label/large" className={styles.name}>
{resume.user.firstName} {resume.user.lastName}
</Typography>
<p className={styles.info}>
{resume.user.major} ({resume.user.graduationYear})
</p>
</Link>
) : null}
<Link href={resume.url} className={styles.resume}>
<BsDownload className={styles.image} />
<p className={styles.name}>{fileName}</p>
<p className={styles.info}>
Uploaded {new Date(resume.lastUpdated).toLocaleDateString('en-US', { dateStyle: 'full' })}
</p>
</Link>
</div>
);
};

export default Resume;
73 changes: 73 additions & 0 deletions src/components/admin/Resume/style.module.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
14 changes: 14 additions & 0 deletions src/components/admin/Resume/style.module.scss.d.ts
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 22 additions & 4 deletions src/lib/api/ResumeAPI.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -76,3 +77,20 @@ export const deleteResume = async (token: string, uuid: UUID): Promise<void> =>
},
});
};

/**
* Get all visible resumes
* @param token Authorization bearer token. User needs to be admin or
* sponsorship manager
*/
export const getResumes = async (token: string): Promise<PublicResume[]> => {
const requestUrl = `${config.api.baseUrl}${config.api.endpoints.resume}`;

const response = await axios.get<GetVisibleResumesResponse>(requestUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});

return response.data.resumes;
};
22 changes: 13 additions & 9 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
102 changes: 102 additions & 0 deletions src/pages/admin/resumes.tsx
Original file line number Diff line number Diff line change
@@ -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<DownloadState>({ stage: 'idle' });
const zipUrl = useObjectUrl(downloadState.stage === 'ready' ? downloadState.blob : null);
const downloadButton = useRef<HTMLAnchorElement>(null);

useEffect(() => {
if (zipUrl !== '') {
downloadButton.current?.click();
}
}, [zipUrl]);

return (
<div className={styles.page}>
<Typography variant="title/large" component="h1" className={styles.header}>
Resumes
{downloadState.stage === 'idle' ? (
<button
type="button"
className={styles.button}
onClick={async () => {
const zip = new JSZip();
let loaded = 0;
setDownloadState({ stage: 'downloading', loaded });
await Promise.all(
resumes.map(resume =>
fetch(resume.url)
.then(r => r.blob())
.then(blob => {
loaded += 1;
setDownloadState({ stage: 'downloading', loaded });
zip.file(getFileName(resume.url, `${resume.uuid}.pdf`), blob);
})
)
);
setDownloadState({ stage: 'ready', blob: await zip.generateAsync({ type: 'blob' }) });
}}
>
<BsDownload /> Download All
</button>
) : null}
{downloadState.stage === 'downloading' ? (
<span className={styles.downloading}>
Downloading {resumes.length - downloadState.loaded} of {resumes.length}...
</span>
) : null}
{downloadState.stage === 'ready' ? (
<a href={zipUrl} className={styles.button} ref={downloadButton} download="resumes.zip">
<BsDownload /> Download All
</a>
) : null}
</Typography>
{resumes.slice(page * ROWS_PER_PAGE, (page + 1) * ROWS_PER_PAGE).map(resume => (
<Resume key={resume.uuid} resume={resume} />
))}
<PaginationControls
page={page}
onPage={page => setPage(page)}
pages={Math.ceil(resumes.length / ROWS_PER_PAGE)}
/>
</div>
);
};

export default AdminResumePage;

const getServerSidePropsFunc: GetServerSidePropsWithAuth<AdminResumePageProps> = async ({
authToken,
}) => {
const resumes = await ResumeAPI.getResumes(authToken);
return { props: { title: 'View Resumes', resumes } };
};

export const getServerSideProps = withAccessType(
getServerSidePropsFunc,
PermissionService.canViewResumes,
{ redirectTo: config.homeRoute }
);
30 changes: 30 additions & 0 deletions src/styles/pages/resumes.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
12 changes: 12 additions & 0 deletions src/styles/pages/resumes.module.scss.d.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 8219c00

Please sign in to comment.