commit
0dae9fd7cb
@ -0,0 +1,74 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userId` on the `ResumesComment` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `userId` on the `ResumesCommentVote` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `userId` on the `ResumesResume` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `userId` on the `ResumesStar` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[commentId,resumesProfileId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[resumeId,resumesProfileId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `resumesProfileId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `resumesProfileId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `resumesProfileId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `resumesProfileId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesComment" DROP COLUMN "userId",
|
||||
ADD COLUMN "resumesProfileId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesCommentVote" DROP COLUMN "userId",
|
||||
ADD COLUMN "resumesProfileId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesResume" DROP COLUMN "userId",
|
||||
ADD COLUMN "resumesProfileId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesStar" DROP COLUMN "userId",
|
||||
ADD COLUMN "resumesProfileId" TEXT NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ResumesProfile" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "ResumesProfile_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResumesProfile_userId_key" ON "ResumesProfile"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResumesCommentVote_commentId_resumesProfileId_key" ON "ResumesCommentVote"("commentId", "resumesProfileId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResumesStar_resumeId_resumesProfileId_key" ON "ResumesStar"("resumeId", "resumesProfileId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesProfile" ADD CONSTRAINT "ResumesProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey" FOREIGN KEY ("resumesProfileId") REFERENCES "ResumesProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,73 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `resumesProfileId` on the `ResumesComment` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `resumesProfileId` on the `ResumesCommentVote` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `resumesProfileId` on the `ResumesResume` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `resumesProfileId` on the `ResumesStar` table. All the data in the column will be lost.
|
||||
- You are about to drop the `ResumesProfile` table. If the table is not empty, all the data it contains will be lost.
|
||||
- A unique constraint covering the columns `[userId,commentId]` on the table `ResumesCommentVote` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[userId,resumeId]` on the table `ResumesStar` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `userId` to the `ResumesComment` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `userId` to the `ResumesCommentVote` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `userId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `userId` to the `ResumesStar` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesComment" DROP CONSTRAINT "ResumesComment_resumesProfileId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesCommentVote" DROP CONSTRAINT "ResumesCommentVote_resumesProfileId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesProfile" DROP CONSTRAINT "ResumesProfile_userId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesResume" DROP CONSTRAINT "ResumesResume_resumesProfileId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "ResumesStar" DROP CONSTRAINT "ResumesStar_resumesProfileId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "ResumesCommentVote_commentId_resumesProfileId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "ResumesStar_resumeId_resumesProfileId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesComment" DROP COLUMN "resumesProfileId",
|
||||
ADD COLUMN "userId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesCommentVote" DROP COLUMN "resumesProfileId",
|
||||
ADD COLUMN "userId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesResume" DROP COLUMN "resumesProfileId",
|
||||
ADD COLUMN "userId" TEXT NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ResumesStar" DROP COLUMN "resumesProfileId",
|
||||
ADD COLUMN "userId" TEXT NOT NULL;
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "ResumesProfile";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResumesCommentVote_userId_commentId_key" ON "ResumesCommentVote"("userId", "commentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResumesStar_userId_resumeId_key" ON "ResumesStar"("userId", "resumeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesStar" ADD CONSTRAINT "ResumesStar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ResumesCommentVote" ADD CONSTRAINT "ResumesCommentVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
Binary file not shown.
@ -0,0 +1,52 @@
|
||||
import { useState } from 'react';
|
||||
import { Document, Page, pdfjs } from 'react-pdf';
|
||||
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
|
||||
import { Button, Spinner } from '@tih/ui';
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.js`;
|
||||
|
||||
type Props = Readonly<{
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export default function ResumePdf({ url }: Props) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pageNumber] = useState(1);
|
||||
|
||||
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
|
||||
setNumPages(pdf.numPages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Document
|
||||
className="h-[calc(100vh-17rem)] overflow-scroll"
|
||||
file={url}
|
||||
loading={<Spinner display="block" label="" size="lg" />}
|
||||
onLoadSuccess={onPdfLoadSuccess}>
|
||||
<Page pageNumber={pageNumber} />
|
||||
</Document>
|
||||
|
||||
<div className="flex flex-row items-center justify-between p-4">
|
||||
<Button
|
||||
disabled={pageNumber === 1}
|
||||
icon={ArrowLeftIcon}
|
||||
isLabelHidden={true}
|
||||
label="Previous"
|
||||
variant="tertiary"
|
||||
/>
|
||||
<p className="text-md text-gray-600">
|
||||
Page {pageNumber} of {numPages}
|
||||
</p>
|
||||
<Button
|
||||
disabled={pageNumber === numPages}
|
||||
icon={ArrowRightIcon}
|
||||
isLabelHidden={true}
|
||||
label="Next"
|
||||
variant="tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,3 +1,13 @@
|
||||
import { Badge } from '@tih/ui';
|
||||
|
||||
export default function ResumeReviewsTitle() {
|
||||
return <h1 className="text-center text-4xl font-bold">Resume Reviews</h1>;
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Resume Reviews</h1>
|
||||
<Badge
|
||||
label="Check out reviewed resumes or look for resumes to review"
|
||||
variant="info"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,44 @@
|
||||
import Link from 'next/link';
|
||||
import type { UrlObject } from 'url';
|
||||
import { ChevronRightIcon } from '@heroicons/react/20/solid';
|
||||
import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
import type { Resume } from '~/types/resume';
|
||||
|
||||
type Props = Readonly<{
|
||||
href: UrlObject | string;
|
||||
resumeInfo: Resume;
|
||||
}>;
|
||||
|
||||
export default function BrowseListItem({ href, resumeInfo }: Props) {
|
||||
return (
|
||||
<Link href={href}>
|
||||
<div className="flex justify-between border-b border-slate-200 p-4">
|
||||
<div>
|
||||
{resumeInfo.title}
|
||||
<div className="mt-2 flex items-center justify-start text-xs text-indigo-500">
|
||||
{resumeInfo.role}
|
||||
<div className="ml-6 rounded-md border border-indigo-500 p-1">
|
||||
{resumeInfo.experience}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-start text-xs text-slate-500">
|
||||
<div className="flex gap-2 pr-8">
|
||||
<ChatBubbleLeftIcon className="w-4" />
|
||||
{resumeInfo.numComments} comments
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<StarIcon className="w-4" />
|
||||
{resumeInfo.numStars} stars
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center text-sm text-slate-500">
|
||||
{/* TODO: Replace hardcoded days ago with calculated days ago*/}
|
||||
Uploaded 2 days ago by {resumeInfo.user}
|
||||
</div>
|
||||
<ChevronRightIcon className="w-8" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
type Props = Readonly<{
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
export default function FilterPill({ title, onClick }: Props) {
|
||||
return (
|
||||
<button
|
||||
className="rounded-xl border border-indigo-500 border-transparent bg-white px-2 py-1 text-xs font-medium text-indigo-500 focus:bg-indigo-500 focus:text-white"
|
||||
type="button"
|
||||
onClick={onClick}>
|
||||
{title}
|
||||
</button>
|
||||
);
|
||||
}
|
@ -0,0 +1,69 @@
|
||||
export const SORT_OPTIONS = [
|
||||
{ current: true, href: '#', name: 'Latest' },
|
||||
{ current: false, href: '#', name: 'Popular' },
|
||||
{ current: false, href: '#', name: 'Top Comments' },
|
||||
];
|
||||
|
||||
export const TOP_HITS = [
|
||||
{ href: '#', name: 'Unreviewed' },
|
||||
{ href: '#', name: 'Fresh Grad' },
|
||||
{ href: '#', name: 'GOATs' },
|
||||
{ href: '#', name: 'US Only' },
|
||||
];
|
||||
|
||||
export const ROLES = [
|
||||
{
|
||||
checked: false,
|
||||
label: 'Full-Stack Engineer',
|
||||
value: 'Full-Stack Engineer',
|
||||
},
|
||||
{ checked: false, label: 'Frontend Engineer', value: 'frontend-engineer' },
|
||||
{ checked: false, label: 'Backend Engineer', value: 'backend-engineer' },
|
||||
{ checked: false, label: 'DevOps Engineer', value: 'devops-engineer' },
|
||||
{ checked: false, label: 'iOS Engineer', value: 'ios-engineer' },
|
||||
{ checked: false, label: 'Android Engineer', value: 'android-engineer' },
|
||||
];
|
||||
|
||||
export const EXPERIENCE = [
|
||||
{ checked: false, label: 'Freshman', value: 'freshman' },
|
||||
{ checked: false, label: 'Sophomore', value: 'sophomore' },
|
||||
{ checked: false, label: 'Junior', value: 'junior' },
|
||||
{ checked: false, label: 'Senior', value: 'senior' },
|
||||
{ checked: false, label: 'Fresh Grad (0-1 years)', value: 'freshgrad' },
|
||||
];
|
||||
|
||||
export const LOCATION = [
|
||||
{ checked: false, label: 'Singapore', value: 'singapore' },
|
||||
{ checked: false, label: 'United States', value: 'usa' },
|
||||
{ checked: false, label: 'India', value: 'india' },
|
||||
];
|
||||
|
||||
export const TEST_RESUMES = [
|
||||
{
|
||||
createdAt: new Date(),
|
||||
experience: 'Fresh Grad (0-1 years)',
|
||||
numComments: 9,
|
||||
numStars: 1,
|
||||
role: 'Backend Engineer',
|
||||
title: 'Rejected from multiple companies, please help...:(',
|
||||
user: 'Git Ji Ra',
|
||||
},
|
||||
{
|
||||
createdAt: new Date(),
|
||||
experience: 'Fresh Grad (0-1 years)',
|
||||
numComments: 9,
|
||||
numStars: 1,
|
||||
role: 'Backend Engineer',
|
||||
title: 'Rejected from multiple companies, please help...:(',
|
||||
user: 'Git Ji Ra',
|
||||
},
|
||||
{
|
||||
createdAt: new Date(),
|
||||
experience: 'Fresh Grad (0-1 years)',
|
||||
numComments: 9,
|
||||
numStars: 1,
|
||||
role: 'Backend Engineer',
|
||||
title: 'Rejected from multiple companies, please help...:(',
|
||||
user: 'Git Ji Ra',
|
||||
},
|
||||
];
|
@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import type { SubmitHandler } from 'react-hook-form';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Button, Dialog, TextInput } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
type CommentsFormProps = Readonly<{
|
||||
resumeId: string;
|
||||
setShowCommentsForm: (show: boolean) => void;
|
||||
}>;
|
||||
|
||||
type IFormInput = {
|
||||
education: string;
|
||||
experience: string;
|
||||
general: string;
|
||||
projects: string;
|
||||
skills: string;
|
||||
};
|
||||
|
||||
type InputKeys = keyof IFormInput;
|
||||
|
||||
export default function CommentsForm({
|
||||
resumeId,
|
||||
setShowCommentsForm,
|
||||
}: CommentsFormProps) {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { isDirty },
|
||||
} = useForm<IFormInput>({
|
||||
defaultValues: {
|
||||
education: '',
|
||||
experience: '',
|
||||
general: '',
|
||||
projects: '',
|
||||
skills: '',
|
||||
},
|
||||
});
|
||||
|
||||
const trpcContext = trpc.useContext();
|
||||
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create', {
|
||||
onSuccess: () => {
|
||||
// New review added, invalidate query to trigger refetch
|
||||
trpcContext.invalidateQueries(['resumes.reviews.list']);
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Give a feedback to the user if the action succeeds/fails
|
||||
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
|
||||
return await reviewCreateMutation.mutate(
|
||||
{
|
||||
resumeId,
|
||||
...data,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Redirect back to comments section
|
||||
setShowCommentsForm(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
if (isDirty) {
|
||||
setShowDialog(true);
|
||||
} else {
|
||||
setShowCommentsForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onValueChange = (section: InputKeys, value: string) => {
|
||||
setValue(section, value.trim(), { shouldDirty: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-13rem)] overflow-y-scroll">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
|
||||
<p className="text-gray-800">
|
||||
Please fill in at least one section to submit your review
|
||||
</p>
|
||||
|
||||
<form
|
||||
className="w-full space-y-8 divide-y divide-gray-200"
|
||||
onSubmit={handleSubmit(onSubmit)}>
|
||||
{/* TODO: Convert TextInput to TextArea */}
|
||||
<div className="mt-4 space-y-4">
|
||||
<TextInput
|
||||
{...(register('general'), {})}
|
||||
label="General"
|
||||
placeholder="General comments about the resume"
|
||||
type="text"
|
||||
onChange={(value) => onValueChange('general', value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
{...(register('education'), {})}
|
||||
label="Education"
|
||||
placeholder="Comments about the Education section"
|
||||
type="text"
|
||||
onChange={(value) => onValueChange('education', value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
{...(register('experience'), {})}
|
||||
label="Experience"
|
||||
placeholder="Comments about the Experience section"
|
||||
type="text"
|
||||
onChange={(value) => onValueChange('experience', value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
{...(register('projects'), {})}
|
||||
label="Projects"
|
||||
placeholder="Comments about the Projects section"
|
||||
type="text"
|
||||
onChange={(value) => onValueChange('projects', value)}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
{...(register('skills'), {})}
|
||||
label="Skills"
|
||||
placeholder="Comments about the Skills section"
|
||||
type="text"
|
||||
onChange={(value) => onValueChange('skills', value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
label="Cancel"
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<Button
|
||||
disabled={!isDirty}
|
||||
label="Submit"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Dialog
|
||||
isShown={showDialog}
|
||||
primaryButton={
|
||||
<Button
|
||||
display="block"
|
||||
label="OK"
|
||||
variant="primary"
|
||||
onClick={() => setShowCommentsForm(false)}
|
||||
/>
|
||||
}
|
||||
secondaryButton={
|
||||
<Button
|
||||
display="block"
|
||||
label="Cancel"
|
||||
variant="tertiary"
|
||||
onClick={() => setShowDialog(false)}
|
||||
/>
|
||||
}
|
||||
title="Are you sure you want to leave?"
|
||||
onClose={() => {
|
||||
setShowDialog(false);
|
||||
}}>
|
||||
<div>Note that your review will not be saved!</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
import { Tabs } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
import Comment from './comment/Comment';
|
||||
import CommentsListButton from './CommentsListButton';
|
||||
import { COMMENTS_SECTIONS } from './constants';
|
||||
|
||||
type CommentsListProps = Readonly<{
|
||||
resumeId: string;
|
||||
setShowCommentsForm: (show: boolean) => void;
|
||||
}>;
|
||||
|
||||
export default function CommentsList({
|
||||
resumeId,
|
||||
setShowCommentsForm,
|
||||
}: CommentsListProps) {
|
||||
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
|
||||
const { data: session } = useSession();
|
||||
|
||||
// Fetch the most updated comments to render
|
||||
const commentsQuery = trpc.useQuery([
|
||||
'resumes.reviews.list',
|
||||
{ resumeId, section: tab },
|
||||
]);
|
||||
|
||||
// TODO: Add loading prompt
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<CommentsListButton setShowCommentsForm={setShowCommentsForm} />
|
||||
<Tabs
|
||||
label="comments"
|
||||
tabs={COMMENTS_SECTIONS}
|
||||
value={tab}
|
||||
onChange={(value) => setTab(value)}
|
||||
/>
|
||||
|
||||
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
|
||||
{commentsQuery.data?.map((comment) => {
|
||||
return (
|
||||
<Comment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
userId={session?.user?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { signIn, useSession } from 'next-auth/react';
|
||||
import { Button } from '@tih/ui';
|
||||
|
||||
type CommentsListButtonProps = {
|
||||
setShowCommentsForm: (show: boolean) => void;
|
||||
};
|
||||
|
||||
export default function CommentsListButton({
|
||||
setShowCommentsForm,
|
||||
}: CommentsListButtonProps) {
|
||||
const { data: session, status } = useSession();
|
||||
const isSessionLoading = status === 'loading';
|
||||
|
||||
// Don't render anything
|
||||
if (isSessionLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Not signed in
|
||||
if (session == null) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<p>
|
||||
<a
|
||||
className="text-primary-800 hover:text-primary-500"
|
||||
href="/api/auth/signin"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
signIn();
|
||||
}}>
|
||||
Sign in
|
||||
</a>{' '}
|
||||
to join discussion
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Signed in. Return Add review button
|
||||
return (
|
||||
<Button
|
||||
display="block"
|
||||
label="Add your review"
|
||||
variant="tertiary"
|
||||
onClick={() => setShowCommentsForm(true)}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import CommentsForm from './CommentsForm';
|
||||
import CommentsList from './CommentsList';
|
||||
|
||||
type ICommentsSectionProps = {
|
||||
resumeId: string;
|
||||
};
|
||||
|
||||
export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
|
||||
const [showCommentsForm, setShowCommentsForm] = useState(false);
|
||||
|
||||
return showCommentsForm ? (
|
||||
<CommentsForm
|
||||
resumeId={resumeId}
|
||||
setShowCommentsForm={setShowCommentsForm}
|
||||
/>
|
||||
) : (
|
||||
<CommentsList
|
||||
resumeId={resumeId}
|
||||
setShowCommentsForm={setShowCommentsForm}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import CommentBody from './CommentBody';
|
||||
import CommentCard from './CommentCard';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type CommentProps = {
|
||||
comment: ResumeComment;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export default function Comment({ comment, userId }: CommentProps) {
|
||||
const isCommentOwner = userId === comment.user.userId;
|
||||
return (
|
||||
<CommentCard isCommentOwner={isCommentOwner}>
|
||||
<CommentBody comment={comment} isCommentOwner={isCommentOwner} />
|
||||
</CommentCard>
|
||||
);
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import {
|
||||
ArrowDownCircleIcon,
|
||||
ArrowUpCircleIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { FaceSmileIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type CommentBodyProps = {
|
||||
comment: ResumeComment;
|
||||
isCommentOwner?: boolean;
|
||||
};
|
||||
|
||||
export default function CommentBody({
|
||||
comment,
|
||||
isCommentOwner,
|
||||
}: CommentBodyProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-row space-x-2 p-1 align-top">
|
||||
{comment.user.image ? (
|
||||
<img
|
||||
alt={comment.user.name ?? 'Reviewer'}
|
||||
className="mt-1 h-8 w-8 rounded-full"
|
||||
src={comment.user.image!}
|
||||
/>
|
||||
) : (
|
||||
<FaceSmileIcon className="h-8 w-8 rounded-full" />
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col space-y-1">
|
||||
{/* Name and creation time */}
|
||||
<div className="flex flex-row justify-between">
|
||||
<div className="font-medium">
|
||||
{comment.user.name ?? 'Reviewer ABC'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{comment.createdAt.toLocaleString('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-sm">{comment.description}</div>
|
||||
|
||||
{/* Upvote and edit */}
|
||||
<div className="flex flex-row space-x-1 pt-1 align-middle">
|
||||
{/* TODO: Implement upvote */}
|
||||
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||
<div className="text-xs">{comment.numVotes}</div>
|
||||
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
|
||||
|
||||
{/* TODO: Implement edit */}
|
||||
{isCommentOwner ? (
|
||||
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
|
||||
Edit
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type CommentCardProps = {
|
||||
children: ReactNode;
|
||||
isCommentOwner?: boolean;
|
||||
};
|
||||
|
||||
export default function CommentCard({
|
||||
isCommentOwner,
|
||||
children,
|
||||
}: CommentCardProps) {
|
||||
// Used two different <div> to allow customisation of owner comments
|
||||
return isCommentOwner ? (
|
||||
<div className="border-primary-300 float-right w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-primary-300 float-left w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import { ResumesSection } from '@prisma/client';
|
||||
|
||||
export const COMMENTS_SECTIONS = [
|
||||
{
|
||||
label: 'General',
|
||||
value: ResumesSection.GENERAL,
|
||||
},
|
||||
{
|
||||
label: 'Education',
|
||||
value: ResumesSection.EDUCATION,
|
||||
},
|
||||
{
|
||||
label: 'Experience',
|
||||
value: ResumesSection.EXPERIENCE,
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
value: ResumesSection.PROJECTS,
|
||||
},
|
||||
{
|
||||
label: 'Skills',
|
||||
value: ResumesSection.SKILLS,
|
||||
},
|
||||
];
|
@ -0,0 +1,147 @@
|
||||
import clsx from 'clsx';
|
||||
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
|
||||
import Error from 'next/error';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
BriefcaseIcon,
|
||||
CalendarIcon,
|
||||
InformationCircleIcon,
|
||||
MapPinIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { Spinner } from '@tih/ui';
|
||||
|
||||
import CommentsSection from '~/components/resumes/comments/CommentsSection';
|
||||
import ResumePdf from '~/components/resumes/ResumePdf';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
export default function ResumeReviewPage() {
|
||||
const ErrorPage = (
|
||||
<Error statusCode={404} title="Requested resume does not exist." />
|
||||
);
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const { resumeId } = router.query;
|
||||
const utils = trpc.useContext();
|
||||
// Safe to assert resumeId type as string because query is only sent if so
|
||||
const detailsQuery = trpc.useQuery(
|
||||
['resumes.details.find', { resumeId: resumeId as string }],
|
||||
{
|
||||
enabled: typeof resumeId === 'string' && session?.user?.id !== undefined,
|
||||
},
|
||||
);
|
||||
const starMutation = trpc.useMutation('resumes.details.update_star', {
|
||||
onSuccess() {
|
||||
utils.invalidateQueries();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (detailsQuery.data?.stars.length) {
|
||||
document.getElementById('star-button')?.focus();
|
||||
} else {
|
||||
document.getElementById('star-button')?.blur();
|
||||
}
|
||||
}, [detailsQuery.data?.stars]);
|
||||
|
||||
const onStarButtonClick = () => {
|
||||
// Star button only rendered if resume exists
|
||||
// Star button only clickable if user exists
|
||||
starMutation.mutate({
|
||||
resumeId: resumeId as string,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{detailsQuery.isError && ErrorPage}
|
||||
{detailsQuery.isLoading && <Spinner display="block" label="" size="lg" />}
|
||||
{detailsQuery.isFetched && detailsQuery.data && (
|
||||
<main className="flex-1 p-4">
|
||||
<div className="flex flex-row md:space-x-8">
|
||||
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
|
||||
{detailsQuery.data.title}
|
||||
</h1>
|
||||
<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"
|
||||
disabled={session?.user === null}
|
||||
id="star-button"
|
||||
type="button"
|
||||
onClick={onStarButtonClick}>
|
||||
<span className="relative inline-flex">
|
||||
<StarIcon
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
detailsQuery.data?.stars.length
|
||||
? 'text-orange-400'
|
||||
: 'text-gray-400',
|
||||
'-ml-1 mr-2 h-5 w-5',
|
||||
)}
|
||||
id="star-icon"
|
||||
/>
|
||||
Star
|
||||
</span>
|
||||
<span className="relative -ml-px inline-flex">
|
||||
{detailsQuery.data._count.stars}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col pt-1 sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-8">
|
||||
<div className="mt-2 flex items-center text-sm text-gray-500">
|
||||
<BriefcaseIcon
|
||||
aria-hidden="true"
|
||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
/>
|
||||
{detailsQuery.data.role}
|
||||
</div>
|
||||
<div className="flex items-center pt-2 text-sm text-gray-500">
|
||||
<MapPinIcon
|
||||
aria-hidden="true"
|
||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
/>
|
||||
{detailsQuery.data.location}
|
||||
</div>
|
||||
<div className="flex items-center pt-2 text-sm text-gray-500">
|
||||
<AcademicCapIcon
|
||||
aria-hidden="true"
|
||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
/>
|
||||
{detailsQuery.data.experience}
|
||||
</div>
|
||||
<div className="flex items-center pt-2 text-sm text-gray-500">
|
||||
<CalendarIcon
|
||||
aria-hidden="true"
|
||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
/>
|
||||
{`Uploaded ${formatDistanceToNow(
|
||||
new Date(detailsQuery.data.createdAt),
|
||||
{ addSuffix: true },
|
||||
)} by ${detailsQuery.data.user.name}`}
|
||||
</div>
|
||||
</div>
|
||||
{detailsQuery.data.additionalInfo && (
|
||||
<div className="flex items-center pt-2 text-sm text-gray-500">
|
||||
<InformationCircleIcon
|
||||
aria-hidden="true"
|
||||
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
|
||||
/>
|
||||
{detailsQuery.data.additionalInfo}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full w-full flex-row py-4">
|
||||
<div className="w-1/2">
|
||||
<ResumePdf url={detailsQuery.data.url} />
|
||||
</div>
|
||||
<div className="mx-8 w-1/2">
|
||||
<CommentsSection resumeId={resumeId as string} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,11 +1,244 @@
|
||||
import clsx from 'clsx';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Disclosure, Menu, Transition } from '@headlessui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from '@heroicons/react/20/solid';
|
||||
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import { Tabs, TextInput } from '@tih/ui';
|
||||
|
||||
import BrowseListItem from '~/components/resumes/browse/BrowseListItem';
|
||||
import {
|
||||
EXPERIENCE,
|
||||
LOCATION,
|
||||
ROLES,
|
||||
SORT_OPTIONS,
|
||||
TOP_HITS,
|
||||
} from '~/components/resumes/browse/constants';
|
||||
import FilterPill from '~/components/resumes/browse/FilterPill';
|
||||
|
||||
const filters = [
|
||||
{
|
||||
id: 'roles',
|
||||
name: 'Roles',
|
||||
options: ROLES,
|
||||
},
|
||||
{
|
||||
id: 'experience',
|
||||
name: 'Experience',
|
||||
options: EXPERIENCE,
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
name: 'Location',
|
||||
options: LOCATION,
|
||||
},
|
||||
];
|
||||
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
export default function ResumeHomePage() {
|
||||
const [tabsValue, setTabsValue] = useState('all');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const resumesQuery = trpc.useQuery(['resumes.resume.list']);
|
||||
|
||||
return (
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<main className="h-full flex-1 overflow-y-auto">
|
||||
<div className="ml-4 py-4">
|
||||
<ResumeReviewsTitle />
|
||||
</div>
|
||||
<div className="mt-4 flex items-start">
|
||||
<div className="w-screen sm:px-4 md:px-8">
|
||||
<div className="grid grid-cols-12">
|
||||
<div className="col-span-2 self-end">
|
||||
<h1 className="mb-4 tracking-tight text-gray-900">Filters</h1>
|
||||
</div>
|
||||
<div className="col-span-10">
|
||||
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
|
||||
<div className="col-span-7">
|
||||
<Tabs
|
||||
label="Resume Browse Tabs"
|
||||
tabs={[
|
||||
{
|
||||
label: 'All Resumes',
|
||||
value: 'all',
|
||||
},
|
||||
{
|
||||
label: 'Starred Resumes',
|
||||
value: 'starred',
|
||||
},
|
||||
{
|
||||
label: 'My Resumes',
|
||||
value: 'my',
|
||||
},
|
||||
]}
|
||||
value={tabsValue}
|
||||
onChange={setTabsValue}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3 self-end">
|
||||
<form>
|
||||
<TextInput
|
||||
label=""
|
||||
placeholder="Search Resumes"
|
||||
startAddOn={MagnifyingGlassIcon}
|
||||
startAddOnType="icon"
|
||||
type="text"
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div className="col-span-1 justify-self-center">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<Menu.Button className="group inline-flex justify-center text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Sort
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="-mr-1 ml-1 h-5 w-5 flex-shrink-0 text-gray-400 group-hover:text-gray-500"
|
||||
/>
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items className="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-white shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<Menu.Item key={option.name}>
|
||||
{({ active }) => (
|
||||
<a
|
||||
className={clsx(
|
||||
option.current
|
||||
? 'font-medium text-gray-900'
|
||||
: 'text-gray-500',
|
||||
active ? 'bg-gray-100' : '',
|
||||
'block px-4 py-2 text-sm',
|
||||
)}
|
||||
href={option.href}>
|
||||
{option.name}
|
||||
</a>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<button
|
||||
className="rounded-md bg-indigo-500 py-1 px-3 text-sm text-white"
|
||||
type="button">
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-12">
|
||||
<div className="col-span-2">
|
||||
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
|
||||
<form>
|
||||
<h3 className="sr-only">Categories</h3>
|
||||
<ul
|
||||
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900"
|
||||
role="list">
|
||||
{TOP_HITS.map((category) => (
|
||||
<li key={category.name}>
|
||||
{/* TODO: Replace onClick with filtering function */}
|
||||
<FilterPill
|
||||
title={category.name}
|
||||
onClick={() => true}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{filters.map((section) => (
|
||||
<Disclosure
|
||||
key={section.id}
|
||||
as="div"
|
||||
className="border-b border-gray-200 py-6">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<h3 className="-my-3 flow-root">
|
||||
<Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-gray-400 hover:text-gray-500">
|
||||
<span className="font-medium text-gray-900">
|
||||
{section.name}
|
||||
</span>
|
||||
<span className="ml-6 flex items-center">
|
||||
{open ? (
|
||||
<MinusIcon
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<PlusIcon
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</Disclosure.Button>
|
||||
</h3>
|
||||
<Disclosure.Panel className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{section.options.map((option, optionIdx) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className="flex items-center">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
defaultChecked={option.checked}
|
||||
defaultValue={option.value}
|
||||
id={`filter-${section.id}-${optionIdx}`}
|
||||
name={`${section.id}[]`}
|
||||
type="checkbox"
|
||||
/>
|
||||
<label
|
||||
className="ml-3 text-sm text-gray-600"
|
||||
htmlFor={`filter-${section.id}-${optionIdx}`}>
|
||||
{option.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
))}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{resumesQuery.isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<div className="col-span-10 pr-8">
|
||||
<ul role="list">
|
||||
{resumesQuery.data?.map((resumeObj) => (
|
||||
<li key={resumeObj.id}>
|
||||
<BrowseListItem href="#" resumeInfo={resumeObj} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createRouter } from './context';
|
||||
|
||||
export const resumesDetailsRouter = createRouter()
|
||||
.query('find', {
|
||||
input: z.object({
|
||||
resumeId: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { resumeId } = input;
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
// Use the resumeId to query all related information of a single resume
|
||||
// from Resumesresume:
|
||||
return await ctx.prisma.resumesResume.findUnique({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
stars: true,
|
||||
},
|
||||
},
|
||||
stars: {
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
id: resumeId,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('update_star', {
|
||||
input: z.object({
|
||||
resumeId: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const { resumeId } = input;
|
||||
// Update_star will only be called if user is logged in
|
||||
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({
|
||||
where: {
|
||||
userId_resumeId: {
|
||||
resumeId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createProtectedRouter } from './context';
|
||||
|
||||
export const resumesResumeUserRouter = createProtectedRouter().mutation(
|
||||
'create',
|
||||
{
|
||||
// TODO: Use enums for experience, location, role
|
||||
input: z.object({
|
||||
additionalInfo: z.string().optional(),
|
||||
experience: z.string(),
|
||||
location: z.string(),
|
||||
role: z.string(),
|
||||
title: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user.id;
|
||||
// TODO: Store file in file storage and retrieve URL
|
||||
return await ctx.prisma.resumesResume.create({
|
||||
data: {
|
||||
...input,
|
||||
url: '',
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
@ -0,0 +1,72 @@
|
||||
import { z } from 'zod';
|
||||
import { ResumesSection } from '@prisma/client';
|
||||
|
||||
import { createRouter } from './context';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
export const resumeReviewsRouter = createRouter().query('list', {
|
||||
input: z.object({
|
||||
resumeId: z.string(),
|
||||
section: z.nativeEnum(ResumesSection),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
const { resumeId, section } = input;
|
||||
|
||||
// For this resume, we retrieve every comment's information, along with:
|
||||
// The user's name and image to render
|
||||
// Number of votes, and whether the user (if-any) has voted
|
||||
const comments = await ctx.prisma.resumesComment.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
votes: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
image: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
votes: {
|
||||
take: 1,
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
where: {
|
||||
resumeId,
|
||||
section,
|
||||
},
|
||||
});
|
||||
|
||||
return comments.map((data) => {
|
||||
const hasVoted = data.votes.length > 0;
|
||||
const numVotes = data._count.votes;
|
||||
|
||||
const comment: ResumeComment = {
|
||||
createdAt: data.createdAt,
|
||||
description: data.description,
|
||||
hasVoted,
|
||||
id: data.id,
|
||||
numVotes,
|
||||
resumeId: data.resumeId,
|
||||
section: data.section,
|
||||
updatedAt: data.updatedAt,
|
||||
user: {
|
||||
image: data.user.image,
|
||||
name: data.user.name,
|
||||
userId: data.userId,
|
||||
},
|
||||
};
|
||||
|
||||
return comment;
|
||||
});
|
||||
},
|
||||
});
|
@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import { ResumesSection } from '@prisma/client';
|
||||
|
||||
import { createProtectedRouter } from './context';
|
||||
|
||||
type IResumeCommentInput = Readonly<{
|
||||
description: string;
|
||||
resumeId: string;
|
||||
section: ResumesSection;
|
||||
userId: string;
|
||||
}>;
|
||||
|
||||
export const resumesReviewsUserRouter = createProtectedRouter().mutation(
|
||||
'create',
|
||||
{
|
||||
input: z.object({
|
||||
education: z.string(),
|
||||
experience: z.string(),
|
||||
general: z.string(),
|
||||
projects: z.string(),
|
||||
resumeId: z.string(),
|
||||
skills: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
const { resumeId, education, experience, general, projects, skills } =
|
||||
input;
|
||||
|
||||
// For each section, convert them into ResumesComment model if provided
|
||||
const comments: Array<IResumeCommentInput> = [
|
||||
{ description: education, section: ResumesSection.EDUCATION },
|
||||
{ description: experience, section: ResumesSection.EXPERIENCE },
|
||||
{ description: general, section: ResumesSection.GENERAL },
|
||||
{ description: projects, section: ResumesSection.PROJECTS },
|
||||
{ description: skills, section: ResumesSection.SKILLS },
|
||||
]
|
||||
.filter(({ description }) => {
|
||||
return description.trim().length > 0;
|
||||
})
|
||||
.map(({ description, section }) => {
|
||||
return {
|
||||
description,
|
||||
resumeId,
|
||||
section,
|
||||
userId,
|
||||
};
|
||||
});
|
||||
|
||||
return await ctx.prisma.resumesComment.createMany({
|
||||
data: comments,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
@ -0,0 +1,42 @@
|
||||
import { createRouter } from './context';
|
||||
|
||||
import type { Resume } from '~/types/resume';
|
||||
|
||||
export const resumesRouter = createRouter().query('list', {
|
||||
async resolve({ ctx }) {
|
||||
const resumesData = await ctx.prisma.resumesResume.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
comments: true,
|
||||
stars: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
return resumesData.map((r) => {
|
||||
const resume: Resume = {
|
||||
additionalInfo: r.additionalInfo,
|
||||
createdAt: r.createdAt,
|
||||
experience: r.experience,
|
||||
id: r.id,
|
||||
location: r.location,
|
||||
numComments: r._count.comments,
|
||||
numStars: r._count.stars,
|
||||
role: r.role,
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
user: r.user.name!,
|
||||
};
|
||||
return resume;
|
||||
});
|
||||
},
|
||||
});
|
@ -0,0 +1,21 @@
|
||||
import type { ResumesSection } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Returned by `resumeReviewsRouter` (query for 'resumes.reviews.list') and received as prop by `Comment` in `CommentsList`
|
||||
* frontend-friendly representation of the query
|
||||
*/
|
||||
export type ResumeComment = {
|
||||
createdAt: Date;
|
||||
description: string;
|
||||
hasVoted: boolean;
|
||||
id: string;
|
||||
numVotes: number;
|
||||
resumeId: string;
|
||||
section: ResumesSection;
|
||||
updatedAt: Date;
|
||||
user: {
|
||||
image: string?;
|
||||
name: string?;
|
||||
userId: string;
|
||||
};
|
||||
};
|
@ -0,0 +1,13 @@
|
||||
export type Resume = {
|
||||
additionalInfo: string?;
|
||||
createdAt: Date;
|
||||
experience: string;
|
||||
id: string;
|
||||
location: string;
|
||||
numComments: number;
|
||||
numStars: number;
|
||||
role: string;
|
||||
title: string;
|
||||
url: string;
|
||||
user: string;
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
import type { ComponentMeta } from '@storybook/react';
|
||||
import { Collapsible } from '@tih/ui';
|
||||
|
||||
export default {
|
||||
argTypes: {
|
||||
children: {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
label: {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
},
|
||||
component: Collapsible,
|
||||
title: 'Collapsible',
|
||||
} as ComponentMeta<typeof Collapsible>;
|
||||
|
||||
export const Basic = {
|
||||
args: {
|
||||
children:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.',
|
||||
label: 'Reveal more content below',
|
||||
},
|
||||
};
|
||||
|
||||
export function AccordionLayout() {
|
||||
return (
|
||||
<div className="divide-y divide-slate-200">
|
||||
<div className="py-2">
|
||||
<Collapsible label="What is your name?">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad
|
||||
minim veniam, quis nostrud exercitation ullamco laboris nisi ut
|
||||
aliquip ex ea commodo consequat.
|
||||
</Collapsible>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Collapsible label="What is your age?">
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum
|
||||
dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est
|
||||
laborum.
|
||||
</Collapsible>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import type { ComponentMeta } from '@storybook/react';
|
||||
import { HorizontalDivider } from '@tih/ui';
|
||||
|
||||
export default {
|
||||
argTypes: {},
|
||||
component: HorizontalDivider,
|
||||
title: 'HorizontalDivider',
|
||||
} as ComponentMeta<typeof HorizontalDivider>;
|
||||
|
||||
export function Basic() {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
|
||||
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
||||
commodo consequat.
|
||||
</p>
|
||||
<HorizontalDivider />
|
||||
<p className="text-sm">
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum
|
||||
dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,234 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ComponentMeta } from '@storybook/react';
|
||||
import { Pagination } from '@tih/ui';
|
||||
|
||||
export default {
|
||||
argTypes: {},
|
||||
component: Pagination,
|
||||
title: 'Pagination',
|
||||
} as ComponentMeta<typeof Pagination>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
function emptyFunction() {}
|
||||
|
||||
export function Basic({
|
||||
current,
|
||||
end,
|
||||
start,
|
||||
pagePadding,
|
||||
}: Pick<
|
||||
React.ComponentProps<typeof Pagination>,
|
||||
'current' | 'end' | 'pagePadding' | 'start'
|
||||
>) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Pagination
|
||||
current={current}
|
||||
end={end}
|
||||
label="Pagination"
|
||||
pagePadding={pagePadding}
|
||||
start={start}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
current: 3,
|
||||
end: 10,
|
||||
pagePadding: 1,
|
||||
start: 1,
|
||||
};
|
||||
|
||||
export function Interaction() {
|
||||
const [currentPage, setCurrentPage] = useState(5);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageRanges() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Pagination
|
||||
current={5}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={1}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={2}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={9}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={10}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={1}
|
||||
end={1}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={1}
|
||||
end={1}
|
||||
label="Pagination"
|
||||
pagePadding={2}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={1}
|
||||
end={2}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={2}
|
||||
end={2}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={1}
|
||||
end={3}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={2}
|
||||
end={3}
|
||||
label="Pagination"
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PagePadding() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Pagination
|
||||
current={5}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
pagePadding={2}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={5}
|
||||
end={20}
|
||||
label="Pagination"
|
||||
pagePadding={2}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={10}
|
||||
end={20}
|
||||
label="Pagination"
|
||||
pagePadding={2}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={10}
|
||||
end={20}
|
||||
label="Pagination"
|
||||
pagePadding={3}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={5}
|
||||
end={10}
|
||||
label="Pagination"
|
||||
pagePadding={3}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Pagination
|
||||
current={1}
|
||||
end={1}
|
||||
label="Pagination"
|
||||
pagePadding={2}
|
||||
start={1}
|
||||
onSelect={emptyFunction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ComponentMeta } from '@storybook/react';
|
||||
import type { TextAreaResize } from '@tih/ui';
|
||||
import { TextArea } from '@tih/ui';
|
||||
|
||||
const textAreaResize: ReadonlyArray<TextAreaResize> = [
|
||||
'vertical',
|
||||
'horizontal',
|
||||
'none',
|
||||
'both',
|
||||
];
|
||||
|
||||
export default {
|
||||
argTypes: {
|
||||
autoComplete: {
|
||||
control: 'text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
errorMessage: {
|
||||
control: 'text',
|
||||
},
|
||||
isLabelHidden: {
|
||||
control: 'boolean',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
name: {
|
||||
control: 'text',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
},
|
||||
readOnly: {
|
||||
control: 'boolean',
|
||||
},
|
||||
required: {
|
||||
control: 'boolean',
|
||||
},
|
||||
resize: {
|
||||
control: { type: 'select' },
|
||||
options: textAreaResize,
|
||||
},
|
||||
rows: {
|
||||
control: 'number',
|
||||
},
|
||||
},
|
||||
component: TextArea,
|
||||
title: 'TextArea',
|
||||
} as ComponentMeta<typeof TextArea>;
|
||||
|
||||
export const Basic = {
|
||||
args: {
|
||||
label: 'Comment',
|
||||
placeholder: 'Type your comment here',
|
||||
},
|
||||
};
|
||||
|
||||
export function HiddenLabel() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
isLabelHidden={true}
|
||||
label="Name"
|
||||
placeholder="John Doe"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Disabled() {
|
||||
return (
|
||||
<TextArea
|
||||
disabled={true}
|
||||
label="Comment"
|
||||
placeholder="You can't type here, it's disabled."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Required() {
|
||||
return (
|
||||
<TextArea label="Required input" placeholder="John Doe" required={true} />
|
||||
);
|
||||
}
|
||||
|
||||
export function Error() {
|
||||
const [value, setValue] = useState('1234');
|
||||
|
||||
return (
|
||||
<TextArea
|
||||
errorMessage={value.length < 6 ? 'Your comment is too short' : undefined}
|
||||
label="Leave a reply"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadOnly() {
|
||||
return (
|
||||
<TextArea
|
||||
label="Leave a reply"
|
||||
readOnly={true}
|
||||
value="But you can't change this"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Rows() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TextArea label="Reply" placeholder="Leave a reply" rows={4} />
|
||||
<TextArea label="Reply" placeholder="Leave a reply" rows={10} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Resize() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TextArea
|
||||
label="Vertical resizing"
|
||||
placeholder="Leave a reply"
|
||||
resize="vertical"
|
||||
/>
|
||||
<TextArea
|
||||
label="Horizontal resizing"
|
||||
placeholder="Leave a reply"
|
||||
resize="horizontal"
|
||||
/>
|
||||
<TextArea label="No resizing" placeholder="Leave a reply" resize="none" />
|
||||
<TextArea
|
||||
label="Both resizing"
|
||||
placeholder="Leave a reply"
|
||||
resize="both"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Disclosure } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
type Props = Readonly<{
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
export default function Collapsible({ children, defaultOpen, label }: Props) {
|
||||
return (
|
||||
<Disclosure defaultOpen={defaultOpen}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Disclosure.Button className="text-primary-900 hover:bg-primary-100 focus-visible:ring-primary-500 -mx-2.5 box-content flex w-full justify-between rounded-lg px-2.5 py-2 text-left text-sm font-medium focus:outline-none focus-visible:ring focus-visible:ring-opacity-75">
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'text-primary-500 mr-1 -ml-1 h-5 w-5',
|
||||
open && 'rotate-180 transform',
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1">{label}</span>
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel className="pt-1 pb-2 text-sm text-gray-500">
|
||||
{children}
|
||||
</Disclosure.Panel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Props = Readonly<{
|
||||
className?: string;
|
||||
}>;
|
||||
|
||||
export default function HorizontalDivider({ className }: Props) {
|
||||
return (
|
||||
<hr
|
||||
aria-hidden={true}
|
||||
className={clsx('my-2 h-0 border-t border-slate-200', className)}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
import clsx from 'clsx';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
type Props = Readonly<{
|
||||
current: number;
|
||||
end: number;
|
||||
label: string;
|
||||
onSelect: (page: number, event: React.MouseEvent<HTMLElement>) => void;
|
||||
pagePadding?: number;
|
||||
start: number;
|
||||
}>;
|
||||
|
||||
function PaginationPage({
|
||||
isCurrent = false,
|
||||
label,
|
||||
onClick,
|
||||
}: Readonly<{
|
||||
isCurrent?: boolean;
|
||||
label: number;
|
||||
onClick: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
}>) {
|
||||
return (
|
||||
<button
|
||||
aria-current={isCurrent}
|
||||
className={clsx(
|
||||
'focus:ring-primary-500 focus:border-primary-500 relative inline-flex items-center border px-4 py-2 text-sm font-medium focus:z-20 focus:outline-none focus:ring-1',
|
||||
isCurrent
|
||||
? 'border-primary-500 bg-primary-50 text-primary-600 z-10'
|
||||
: 'border-slate-300 bg-white text-slate-500 hover:bg-slate-50',
|
||||
)}
|
||||
disabled={isCurrent}
|
||||
type="button"
|
||||
onClick={onClick}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis() {
|
||||
return (
|
||||
<span className="relative inline-flex items-center border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
current,
|
||||
end,
|
||||
label,
|
||||
onSelect,
|
||||
pagePadding = 1,
|
||||
start = 1,
|
||||
}: Props) {
|
||||
const pageNumberSet = new Set();
|
||||
const pageNumberList: Array<number | string> = [];
|
||||
const elements: Array<ReactElement> = [];
|
||||
let lastAddedPage = 0;
|
||||
|
||||
function addPage(page: number) {
|
||||
if (page < start || page > end) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pageNumberSet.has(page)) {
|
||||
lastAddedPage = page;
|
||||
pageNumberList.push(page);
|
||||
pageNumberSet.add(page);
|
||||
elements.push(
|
||||
<PaginationPage
|
||||
isCurrent={current === page}
|
||||
label={page}
|
||||
onClick={(event) => {
|
||||
onSelect(page, event);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = start; i <= start + pagePadding; i++) {
|
||||
addPage(i);
|
||||
}
|
||||
|
||||
if (lastAddedPage < current - pagePadding) {
|
||||
elements.push(<PaginationEllipsis />);
|
||||
}
|
||||
|
||||
for (let i = current - pagePadding; i <= current + pagePadding; i++) {
|
||||
addPage(i);
|
||||
}
|
||||
|
||||
if (lastAddedPage < end - pagePadding) {
|
||||
elements.push(<PaginationEllipsis />);
|
||||
}
|
||||
|
||||
for (let i = end - pagePadding; i <= end; i++) {
|
||||
addPage(i);
|
||||
}
|
||||
|
||||
const isPrevButtonDisabled = current === start;
|
||||
const isNextButtonDisabled = current === end;
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="isolate inline-flex -space-x-px rounded-md shadow-sm">
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={clsx(
|
||||
'relative inline-flex items-center rounded-l-md border border-slate-300 px-2 py-2 text-sm font-medium focus:z-20',
|
||||
isPrevButtonDisabled
|
||||
? 'text-slate-300'
|
||||
: 'focus:ring-primary-500 focus:border-primary-500 bg-white text-slate-500 hover:bg-slate-50 focus:outline-none focus:ring-1',
|
||||
)}
|
||||
disabled={isPrevButtonDisabled}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onSelect(current - 1, event);
|
||||
}}>
|
||||
<ChevronLeftIcon aria-hidden="true" className="h-5 w-5" />
|
||||
</button>
|
||||
{elements}
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={clsx(
|
||||
'relative inline-flex items-center rounded-r-md border border-slate-300 px-2 py-2 text-sm font-medium focus:z-20',
|
||||
isNextButtonDisabled
|
||||
? 'text-slate-300'
|
||||
: 'focus:ring-primary-500 focus:border-primary-500 bg-white text-slate-500 hover:bg-slate-50 focus:outline-none focus:ring-1',
|
||||
)}
|
||||
disabled={isNextButtonDisabled}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onSelect(current + 1, event);
|
||||
}}>
|
||||
<ChevronRightIcon aria-hidden="true" className="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
@ -0,0 +1,141 @@
|
||||
import clsx from 'clsx';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
ForwardedRef,
|
||||
TextareaHTMLAttributes,
|
||||
} from 'react';
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
|
||||
type Attributes = Pick<
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||
| 'autoComplete'
|
||||
| 'autoFocus'
|
||||
| 'disabled'
|
||||
| 'maxLength'
|
||||
| 'minLength'
|
||||
| 'name'
|
||||
| 'onBlur'
|
||||
| 'onFocus'
|
||||
| 'placeholder'
|
||||
| 'readOnly'
|
||||
| 'required'
|
||||
| 'rows'
|
||||
>;
|
||||
|
||||
export type TextAreaResize = 'both' | 'horizontal' | 'none' | 'vertical';
|
||||
|
||||
type Props = Readonly<{
|
||||
defaultValue?: string;
|
||||
errorMessage?: React.ReactNode;
|
||||
id?: string;
|
||||
isLabelHidden?: boolean;
|
||||
label: string;
|
||||
onBlur?: (event: FocusEvent<HTMLTextAreaElement>) => void;
|
||||
onChange?: (value: string, event: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
resize?: TextAreaResize;
|
||||
value?: string;
|
||||
}> &
|
||||
Readonly<Attributes>;
|
||||
|
||||
type State = 'error' | 'normal';
|
||||
|
||||
const stateClasses: Record<
|
||||
State,
|
||||
Readonly<{
|
||||
textArea: string;
|
||||
}>
|
||||
> = {
|
||||
error: {
|
||||
textArea:
|
||||
'border-danger-300 focus:ring-danger-500 focus:border-danger-500 text-danger-900 placeholder-danger-300',
|
||||
},
|
||||
normal: {
|
||||
textArea:
|
||||
'border-slate-300 focus:border-primary-500 focus:ring-primary-500 placeholder:text-slate-400',
|
||||
},
|
||||
};
|
||||
|
||||
const resizeClasses: Record<TextAreaResize, string> = {
|
||||
both: 'resize',
|
||||
horizontal: 'resize-x',
|
||||
none: 'resize-none',
|
||||
vertical: 'resize-y',
|
||||
};
|
||||
|
||||
function TextArea(
|
||||
{
|
||||
defaultValue,
|
||||
disabled,
|
||||
errorMessage,
|
||||
id: idParam,
|
||||
isLabelHidden,
|
||||
label,
|
||||
resize = 'vertical',
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
...props
|
||||
}: Props,
|
||||
ref: ForwardedRef<HTMLTextAreaElement>,
|
||||
) {
|
||||
const hasError = errorMessage != null;
|
||||
const generatedId = useId();
|
||||
const id = idParam ?? generatedId;
|
||||
const errorId = useId();
|
||||
const state: State = hasError ? 'error' : 'normal';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
className={clsx(
|
||||
isLabelHidden
|
||||
? 'sr-only'
|
||||
: 'mb-1 block text-sm font-medium text-gray-700',
|
||||
)}
|
||||
htmlFor={id}>
|
||||
{label}
|
||||
{required && (
|
||||
<span aria-hidden="true" className="text-danger-500">
|
||||
{' '}
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div>
|
||||
<textarea
|
||||
ref={ref}
|
||||
aria-describedby={hasError ? errorId : undefined}
|
||||
aria-invalid={hasError ? true : undefined}
|
||||
className={clsx(
|
||||
'block w-full rounded-md sm:text-sm',
|
||||
stateClasses[state].textArea,
|
||||
disabled && 'bg-slate-100',
|
||||
resizeClasses[resize],
|
||||
)}
|
||||
defaultValue={defaultValue}
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
name="comment"
|
||||
required={required}
|
||||
value={value != null ? value : undefined}
|
||||
onChange={(event) => {
|
||||
if (!onChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(event.target.value, event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(TextArea);
|
Loading…
Reference in new issue