[resumes][fix] fix resumes starring lag + add zoom controls (#359)

* [resumes][fix] Fix star button delay

* [resumes][feat] add zoom controls for pdf
This commit is contained in:
Peirong
2022-10-11 16:36:09 +08:00
committed by GitHub
parent 6a6c939953
commit a905f31b2c
7 changed files with 114 additions and 62 deletions

View File

@ -1,7 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist'; import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import {
ArrowLeftIcon,
ArrowRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui'; import { Button, Spinner } from '@tih/ui';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`; pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
@ -13,6 +18,7 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) { export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0); const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1); const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1);
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => { const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages); setNumPages(pdf.numPages);
@ -20,14 +26,36 @@ export default function ResumePdf({ url }: Props) {
return ( return (
<div> <div>
<div className="group relative">
<Document <Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-scroll" className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-auto"
file={url} file={url}
loading={<Spinner display="block" label="" size="lg" />} loading={<Spinner display="block" size="lg" />}
noData="" noData=""
onLoadSuccess={onPdfLoadSuccess}> onLoadSuccess={onPdfLoadSuccess}>
<Page pageNumber={pageNumber} /> <Page pageNumber={pageNumber} scale={scale} width={750} />
<div className="absolute top-2 right-5 hidden hover:block group-hover:block">
<Button
className="rounded-r-none focus:ring-0 focus:ring-offset-0"
disabled={scale === 0.5}
icon={MagnifyingGlassMinusIcon}
isLabelHidden={true}
label="Zoom Out"
variant="tertiary"
onClick={() => setScale(scale - 0.25)}
/>
<Button
className="rounded-l-none focus:ring-0 focus:ring-offset-0"
disabled={scale === 1.5}
icon={MagnifyingGlassPlusIcon}
isLabelHidden={true}
label="Zoom In"
variant="tertiary"
onClick={() => setScale(scale + 0.25)}
/>
</div>
</Document> </Document>
</div>
<div className="flex flex-row items-center justify-between p-4"> <div className="flex flex-row items-center justify-between p-4">
<Button <Button

View File

@ -22,7 +22,7 @@ export default function CommentListItems({ comments, isLoading }: Props) {
} }
return ( return (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll"> <div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
{comments.map((comment) => ( {comments.map((comment) => (
<Comment <Comment
key={comment.id} key={comment.id}

View File

@ -77,7 +77,7 @@ export default function CommentsForm({
}; };
return ( return (
<div className="h-[calc(100vh-13rem)] overflow-y-scroll"> <div className="h-[calc(100vh-13rem)] overflow-y-auto">
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2> <h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
<p className="text-gray-800"> <p className="text-gray-800">
Please fill in at least one section to submit your review Please fill in at least one section to submit your review

View File

@ -4,7 +4,7 @@ import Error from 'next/error';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { import {
AcademicCapIcon, AcademicCapIcon,
BriefcaseIcon, BriefcaseIcon,
@ -22,12 +22,11 @@ import { trpc } from '~/utils/trpc';
export default function ResumeReviewPage() { export default function ResumeReviewPage() {
const ErrorPage = ( const ErrorPage = (
<Error statusCode={404} title="Requested resume does not exist." /> <Error statusCode={404} title="Requested resume does not exist" />
); );
const { data: session } = useSession(); const { data: session } = useSession();
const router = useRouter(); const router = useRouter();
const { resumeId } = router.query; const { resumeId } = router.query;
const trpcContext = trpc.useContext();
// Safe to assert resumeId type as string because query is only sent if so // Safe to assert resumeId type as string because query is only sent if so
const detailsQuery = trpc.useQuery( const detailsQuery = trpc.useQuery(
['resumes.resume.findOne', { resumeId: resumeId as string }], ['resumes.resume.findOne', { resumeId: resumeId as string }],
@ -35,31 +34,59 @@ export default function ResumeReviewPage() {
enabled: typeof resumeId === 'string', enabled: typeof resumeId === 'string',
}, },
); );
const starMutation = trpc.useMutation('resumes.star.user.create_or_delete', { const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() { onError() {
trpcContext.invalidateQueries(['resumes.resume.findOne']); setStarDetails({
isStarred: false,
numStars: starDetails.numStars - 1,
});
}, },
}); });
const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onError() {
setStarDetails({
isStarred: true,
numStars: starDetails.numStars + 1,
});
},
});
const [starDetails, setStarDetails] = useState({
isStarred: false,
numStars: 0,
});
useEffect(() => { useEffect(() => {
if (detailsQuery.data?.stars.length) { if (detailsQuery?.data !== undefined) {
document.getElementById('star-button')?.focus(); setStarDetails({
} else { isStarred: !!detailsQuery.data?.stars.length,
document.getElementById('star-button')?.blur(); numStars: detailsQuery.data?._count.stars ?? 0,
});
} }
}, [detailsQuery.data?.stars]); }, [detailsQuery.data]);
const onStarButtonClick = () => { const onStarButtonClick = () => {
setStarDetails({
isStarred: !starDetails.isStarred,
numStars: starDetails.isStarred
? starDetails.numStars - 1
: starDetails.numStars + 1,
});
// Star button only rendered if resume exists // Star button only rendered if resume exists
// Star button only clickable if user exists // Star button only clickable if user exists
if (starDetails.isStarred) {
unstarMutation.mutate({
resumeId: resumeId as string,
});
} else {
starMutation.mutate({ starMutation.mutate({
resumeId: resumeId as string, resumeId: resumeId as string,
}); });
}
}; };
return ( return (
<> <>
{detailsQuery.isError && ErrorPage} {(detailsQuery.isError || detailsQuery.data === null) && ErrorPage}
{detailsQuery.isLoading && ( {detailsQuery.isLoading && (
<div className="w-full pt-4"> <div className="w-full pt-4">
{' '} {' '}
@ -71,14 +98,19 @@ export default function ResumeReviewPage() {
<Head> <Head>
<title>{detailsQuery.data.title}</title> <title>{detailsQuery.data.title}</title>
</Head> </Head>
<main className="h-[calc(100vh-2rem)] flex-1 overflow-y-scroll p-4"> <main className="h-[calc(100vh-2rem)] flex-1 overflow-y-auto p-4">
<div className="flex flex-row space-x-8"> <div className="flex flex-row space-x-8">
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> <h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title} {detailsQuery.data.title}
</h1> </h1>
<button <button
className="isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:z-10 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" className={clsx(
disabled={session?.user === null} starDetails.isStarred
? 'z-10 border-indigo-500 outline-none ring-1 ring-indigo-500'
: '',
'isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white',
)}
disabled={session?.user === undefined}
id="star-button" id="star-button"
type="button" type="button"
onClick={onStarButtonClick}> onClick={onStarButtonClick}>
@ -86,7 +118,7 @@ export default function ResumeReviewPage() {
<StarIcon <StarIcon
aria-hidden="true" aria-hidden="true"
className={clsx( className={clsx(
detailsQuery.data?.stars.length starDetails.isStarred
? 'text-orange-400' ? 'text-orange-400'
: 'text-gray-400', : 'text-gray-400',
'-ml-1 mr-2 h-5 w-5', '-ml-1 mr-2 h-5 w-5',
@ -96,7 +128,7 @@ export default function ResumeReviewPage() {
Star Star
</span> </span>
<span className="relative -ml-px inline-flex"> <span className="relative -ml-px inline-flex">
{detailsQuery.data._count.stars} {starDetails.numStars}
</span> </span>
</button> </button>
</div> </div>

View File

@ -28,7 +28,7 @@ export const appRouter = createRouter()
.merge('companies.', companiesRouter) .merge('companies.', companiesRouter)
.merge('resumes.resume.', resumesRouter) .merge('resumes.resume.', resumesRouter)
.merge('resumes.resume.user.', resumesResumeUserRouter) .merge('resumes.resume.user.', resumesResumeUserRouter)
.merge('resumes.star.user.', resumesStarUserRouter) .merge('resumes.resume.', resumesStarUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter) .merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter) .merge('resumes.reviews.user.', resumesReviewsUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.comments.', questionsAnswerCommentRouter)

View File

@ -62,9 +62,11 @@ export const resumesRouter = createRouter()
}, },
stars: { stars: {
where: { where: {
OR: {
userId, userId,
}, },
}, },
},
user: { user: {
select: { select: {
name: true, name: true,

View File

@ -2,38 +2,14 @@ import { z } from 'zod';
import { createProtectedRouter } from '../context'; import { createProtectedRouter } from '../context';
export const resumesStarUserRouter = createProtectedRouter().mutation( export const resumesStarUserRouter = createProtectedRouter()
'create_or_delete', .mutation('unstar', {
{
input: z.object({ input: z.object({
resumeId: z.string(), resumeId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const { resumeId } = input; const { resumeId } = input;
// Update_star will only be called if user is logged in const userId = ctx.session.user.id;
const userId = ctx.session!.user!.id;
// Use the resumeId and resumeProfileId to check if star exists
const resumesStar = await ctx.prisma.resumesStar.findUnique({
select: {
id: true,
},
where: {
userId_resumeId: {
resumeId,
userId,
},
},
});
if (resumesStar === null) {
return await ctx.prisma.resumesStar.create({
data: {
resumeId,
userId,
},
});
}
return await ctx.prisma.resumesStar.delete({ return await ctx.prisma.resumesStar.delete({
where: { where: {
userId_resumeId: { userId_resumeId: {
@ -43,5 +19,19 @@ export const resumesStarUserRouter = createProtectedRouter().mutation(
}, },
}); });
}, },
})
.mutation('star', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const { resumeId } = input;
const userId = ctx.session.user.id;
return await ctx.prisma.resumesStar.create({
data: {
resumeId,
userId,
}, },
); });
},
});