commit
260582ed12
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `seenAt` to the `QuestionsQuestionEncounter` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "QuestionsQuestionEncounter" ADD COLUMN "seenAt" TIMESTAMP(3) NOT NULL;
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.6 KiB |
@ -0,0 +1,24 @@
|
||||
import {
|
||||
BriefcaseIcon,
|
||||
CurrencyDollarIcon,
|
||||
DocumentTextIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
type GlobalNavigationItem = Readonly<{
|
||||
href: string;
|
||||
icon: (props: React.ComponentProps<'svg'>) => JSX.Element;
|
||||
name: string;
|
||||
}>;
|
||||
export type GlobalNavigationItems = ReadonlyArray<GlobalNavigationItem>;
|
||||
|
||||
const globalNavigation: GlobalNavigationItems = [
|
||||
{ href: '/offers', icon: CurrencyDollarIcon, name: 'Offers' },
|
||||
{
|
||||
href: '/questions',
|
||||
icon: BriefcaseIcon,
|
||||
name: 'Questions',
|
||||
},
|
||||
{ href: '/resumes', icon: DocumentTextIcon, name: 'Resumes' },
|
||||
];
|
||||
|
||||
export default globalNavigation;
|
@ -0,0 +1,22 @@
|
||||
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
||||
|
||||
const navigation: ProductNavigationItems = [
|
||||
{ href: '/offers', name: 'Offers' },
|
||||
{ href: '/questions', name: 'Question Bank' },
|
||||
{
|
||||
children: [
|
||||
{ href: '/resumes', name: 'View Resumes' },
|
||||
{ href: '/resumes/submit', name: 'Submit Resume' },
|
||||
],
|
||||
href: '#',
|
||||
name: 'Resumes',
|
||||
},
|
||||
];
|
||||
|
||||
const config = {
|
||||
navigation,
|
||||
showGlobalNav: true,
|
||||
title: 'Tech Interview Handbook',
|
||||
};
|
||||
|
||||
export default config;
|
@ -0,0 +1,132 @@
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import { Fragment } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { HorizontalDivider } from '@tih/ui';
|
||||
|
||||
import type { GlobalNavigationItems } from './GlobalNavigation';
|
||||
import type { ProductNavigationItems } from './ProductNavigation';
|
||||
|
||||
type Props = Readonly<{
|
||||
globalNavigationItems: GlobalNavigationItems;
|
||||
isShown?: boolean;
|
||||
productNavigationItems: ProductNavigationItems;
|
||||
productTitle: string;
|
||||
setIsShown: (isShown: boolean) => void;
|
||||
}>;
|
||||
|
||||
export default function MobileNavigation({
|
||||
globalNavigationItems,
|
||||
isShown,
|
||||
productNavigationItems,
|
||||
productTitle,
|
||||
setIsShown,
|
||||
}: Props) {
|
||||
return (
|
||||
<Transition.Root as={Fragment} show={isShown}>
|
||||
<Dialog as="div" className="relative z-20 md:hidden" onClose={setIsShown}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition-opacity ease-linear duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-linear duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 z-40 flex">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transition ease-in-out duration-300 transform"
|
||||
enterFrom="-translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transition ease-in-out duration-300 transform"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="-translate-x-full">
|
||||
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="absolute top-1 right-0 -mr-14 p-1">
|
||||
<button
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-white"
|
||||
type="button"
|
||||
onClick={() => setIsShown(false)}>
|
||||
<XMarkIcon
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 text-white"
|
||||
/>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<Link href="/">
|
||||
<img
|
||||
alt="Tech Interview Handbook"
|
||||
className="h-8 w-auto"
|
||||
src="/logo.svg"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">
|
||||
<div className="mb-2 px-3 py-2 font-medium">{productTitle}</div>
|
||||
<nav className="flex flex-col">
|
||||
<div className="space-y-1">
|
||||
{productNavigationItems.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
className={clsx(
|
||||
'text-slate-700 hover:bg-slate-100',
|
||||
'group flex items-center rounded-md py-2 px-3 text-sm font-medium',
|
||||
)}
|
||||
href={item.href}>
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3">
|
||||
<HorizontalDivider />
|
||||
</div>
|
||||
<div className="mb-2 px-3 py-2 text-sm font-medium">
|
||||
Other Products
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{globalNavigationItems.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
className={clsx(
|
||||
'text-slate-700 hover:bg-slate-100',
|
||||
'group flex items-center rounded-md py-2 px-3 text-sm font-medium',
|
||||
)}
|
||||
href={item.href}>
|
||||
<item.icon
|
||||
aria-hidden="true"
|
||||
className={clsx(
|
||||
'text-slate-500 group-hover:text-slate-700',
|
||||
'mr-3 h-6 w-6',
|
||||
)}
|
||||
/>
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
<div aria-hidden="true" className="w-14 flex-shrink-0">
|
||||
{/* Dummy element to force sidebar to shrink to fit close icon */}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import clsx from 'clsx';
|
||||
import Link from 'next/link';
|
||||
import { Fragment } from 'react';
|
||||
import { Menu, Transition } from '@headlessui/react';
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
type NavigationItem = Readonly<{
|
||||
children?: ReadonlyArray<NavigationItem>;
|
||||
href: string;
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
export type ProductNavigationItems = ReadonlyArray<NavigationItem>;
|
||||
|
||||
type Props = Readonly<{
|
||||
items: ProductNavigationItems;
|
||||
title: string;
|
||||
}>;
|
||||
|
||||
export default function ProductNavigation({ items, title }: Props) {
|
||||
return (
|
||||
<nav aria-label="Global" className="flex space-x-8">
|
||||
<span className="text-primary-700 text-sm font-medium">{title}</span>
|
||||
<div className="hidden space-x-8 md:flex">
|
||||
{items.map((item) =>
|
||||
item.children != null && item.children.length > 0 ? (
|
||||
<Menu key={item.name} as="div" className="relative text-left">
|
||||
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
|
||||
<span>{item.name}</span>
|
||||
<ChevronDownIcon
|
||||
aria-hidden="true"
|
||||
className="ml-1 h-5 w-5 text-slate-500"
|
||||
/>
|
||||
</Menu.Button>
|
||||
<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 left-0 z-10 mt-2 w-40 origin-top-left rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="py-1">
|
||||
{item.children.map((child) => (
|
||||
<Menu.Item key={child.name}>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
className={clsx(
|
||||
active ? 'bg-slate-100' : '',
|
||||
'block px-4 py-2 text-sm text-slate-700',
|
||||
)}
|
||||
href={child.href}>
|
||||
{child.name}
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
) : (
|
||||
<Link
|
||||
key={item.name}
|
||||
className="hover:text-primary-600 text-sm font-medium text-slate-900"
|
||||
href={item.href}>
|
||||
{item.name}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
||||
|
||||
const navigation: ProductNavigationItems = [
|
||||
{
|
||||
children: [
|
||||
{ href: '#', name: 'Technical Support' },
|
||||
{ href: '#', name: 'Sales' },
|
||||
{ href: '#', name: 'General' },
|
||||
],
|
||||
href: '#',
|
||||
name: 'Inboxes',
|
||||
},
|
||||
{ children: [], href: '#', name: 'Reporting' },
|
||||
{ children: [], href: '#', name: 'Settings' },
|
||||
];
|
||||
|
||||
const config = {
|
||||
navigation,
|
||||
showGlobalNav: true,
|
||||
title: 'Offers',
|
||||
};
|
||||
|
||||
export default config;
|
@ -1,56 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
const navigation = [
|
||||
{ href: '/questions/landing', name: '*Landing*' },
|
||||
{ href: '/questions', name: 'Home' },
|
||||
{ href: '#', name: 'My Lists' },
|
||||
{ href: '#', name: 'My Questions' },
|
||||
{ href: '#', name: 'History' },
|
||||
];
|
||||
|
||||
export default function NavBar() {
|
||||
return (
|
||||
<header className="bg-indigo-600">
|
||||
<nav aria-label="Top" className="max-w-8xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex w-full items-center justify-between border-b border-indigo-500 py-3 lg:border-none">
|
||||
<div className="flex items-center">
|
||||
<a className="flex items-center" href="/questions">
|
||||
<span className="sr-only">TIH Question Bank</span>
|
||||
<img alt="TIH Logo" className="h-10 w-auto" src="/logo.svg" />
|
||||
<span className="ml-4 font-bold text-white">
|
||||
TIH Question Bank
|
||||
</span>
|
||||
</a>
|
||||
<div className="ml-8 hidden space-x-6 lg:block">
|
||||
{navigation.map((link) => (
|
||||
<Link
|
||||
key={link.name}
|
||||
className="font-sm text-sm text-white hover:text-indigo-50"
|
||||
href={link.href}>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-8 space-x-4">
|
||||
<a
|
||||
className="inline-block rounded-md border border-transparent bg-indigo-500 py-2 px-4 text-base font-medium text-white hover:bg-opacity-75"
|
||||
href="#">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center space-x-6 py-4 lg:hidden">
|
||||
{navigation.map((link) => (
|
||||
<Link
|
||||
key={link.name}
|
||||
className="text-base font-medium text-white hover:text-indigo-50"
|
||||
href={link.href}>
|
||||
{link.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
||||
|
||||
const navigation: ProductNavigationItems = [
|
||||
{ href: '/questions', name: 'Home' },
|
||||
{ href: '#', name: 'My Lists' },
|
||||
{ href: '#', name: 'My Questions' },
|
||||
{ href: '#', name: 'History' },
|
||||
];
|
||||
|
||||
const config = {
|
||||
navigation,
|
||||
showGlobalNav: true,
|
||||
title: 'Questions Bank',
|
||||
};
|
||||
|
||||
export default config;
|
@ -0,0 +1,23 @@
|
||||
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
|
||||
|
||||
const navigation: ProductNavigationItems = [
|
||||
{
|
||||
children: [
|
||||
{ href: '#', name: 'Technical Support' },
|
||||
{ href: '#', name: 'Sales' },
|
||||
{ href: '#', name: 'General' },
|
||||
],
|
||||
href: '#',
|
||||
name: 'Inboxes',
|
||||
},
|
||||
{ children: [], href: '#', name: 'Reporting' },
|
||||
{ children: [], href: '#', name: 'Settings' },
|
||||
];
|
||||
|
||||
const config = {
|
||||
navigation,
|
||||
showGlobalNav: false,
|
||||
title: 'Resumes',
|
||||
};
|
||||
|
||||
export default config;
|
@ -0,0 +1,35 @@
|
||||
import { Spinner } from '@tih/ui';
|
||||
|
||||
import ResumseListItem from './ResumeListItem';
|
||||
|
||||
import type { Resume } from '~/types/resume';
|
||||
|
||||
type Props = Readonly<{
|
||||
isLoading: boolean;
|
||||
resumes: Array<Resume>;
|
||||
}>;
|
||||
|
||||
export default function ResumeListItems({ isLoading, resumes }: Props) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="col-span-10 pt-4">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col-span-10 pr-8">
|
||||
<ul role="list">
|
||||
{resumes.map((resumeObj: Resume) => (
|
||||
<li key={resumeObj.id}>
|
||||
<ResumseListItem
|
||||
href={`resumes/${resumeObj.id}`}
|
||||
resumeInfo={resumeObj}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { Spinner } from '@tih/ui';
|
||||
|
||||
import Comment from './comment/Comment';
|
||||
|
||||
import type { ResumeComment } from '~/types/resume-comments';
|
||||
|
||||
type Props = Readonly<{
|
||||
comments: Array<ResumeComment>;
|
||||
isLoading: boolean;
|
||||
}>;
|
||||
|
||||
export default function CommentListItems({ comments, isLoading }: Props) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="col-span-10 pt-4">
|
||||
<Spinner display="block" size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
|
||||
{comments.map((comment) => (
|
||||
<Comment
|
||||
key={comment.id}
|
||||
comment={comment}
|
||||
userId={session?.user?.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { Typeahead } from '@tih/ui';
|
||||
|
||||
import { trpc } from '~/utils/trpc';
|
||||
|
||||
type Props = Readonly<{
|
||||
disabled?: boolean;
|
||||
onSelect: (option: TypeaheadOption) => void;
|
||||
}>;
|
||||
|
||||
export default function CompaniesTypeahead({ disabled, onSelect }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const companies = trpc.useQuery([
|
||||
'companies.list',
|
||||
{
|
||||
name: query,
|
||||
},
|
||||
]);
|
||||
|
||||
const { data } = companies;
|
||||
|
||||
return (
|
||||
<Typeahead
|
||||
disabled={disabled}
|
||||
label="Company"
|
||||
noResultsMessage="No companies found"
|
||||
nullable={true}
|
||||
options={
|
||||
data?.map(({ id, name }) => ({
|
||||
id,
|
||||
label: name,
|
||||
value: id,
|
||||
})) ?? []
|
||||
}
|
||||
onQueryChange={setQuery}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
import { Select } from '@tih/ui';
|
||||
|
||||
type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
|
||||
export type MonthYear = Readonly<{
|
||||
month: Month;
|
||||
year: number;
|
||||
}>;
|
||||
|
||||
type Props = Readonly<{
|
||||
onChange: (value: MonthYear) => void;
|
||||
value: MonthYear;
|
||||
}>;
|
||||
|
||||
const MONTH_OPTIONS = [
|
||||
{
|
||||
label: 'January',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: 'February',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: 'March',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: 'April',
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
label: 'May',
|
||||
value: 5,
|
||||
},
|
||||
{
|
||||
label: 'June',
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
label: 'July',
|
||||
value: 7,
|
||||
},
|
||||
{
|
||||
label: 'August',
|
||||
value: 8,
|
||||
},
|
||||
{
|
||||
label: 'September',
|
||||
value: 9,
|
||||
},
|
||||
{
|
||||
label: 'October',
|
||||
value: 10,
|
||||
},
|
||||
{
|
||||
label: 'November',
|
||||
value: 11,
|
||||
},
|
||||
{
|
||||
label: 'December',
|
||||
value: 12,
|
||||
},
|
||||
];
|
||||
|
||||
const NUM_YEARS = 5;
|
||||
const YEAR_OPTIONS = Array.from({ length: NUM_YEARS }, (_, i) => {
|
||||
const year = new Date().getFullYear() - NUM_YEARS + i + 1;
|
||||
return {
|
||||
label: String(year),
|
||||
value: year,
|
||||
};
|
||||
});
|
||||
|
||||
export default function MonthYearPicker({ value, onChange }: Props) {
|
||||
return (
|
||||
<div className="flex space-x-4">
|
||||
<Select
|
||||
label="Month"
|
||||
options={MONTH_OPTIONS}
|
||||
value={value.month}
|
||||
onChange={(newMonth) =>
|
||||
onChange({ month: Number(newMonth) as Month, year: value.year })
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="Year"
|
||||
options={YEAR_OPTIONS}
|
||||
value={value.year}
|
||||
onChange={(newYear) =>
|
||||
onChange({ month: value.month, year: Number(newYear) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export const RESUME_STORAGE_KEY = 'resumes';
|
@ -0,0 +1,65 @@
|
||||
import formidable from 'formidable';
|
||||
import * as fs from 'fs';
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import { supabase } from '~/utils/supabase';
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const form = formidable({ keepExtensions: true });
|
||||
form.parse(req, async (err, fields, files) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { key } = fields;
|
||||
const { file } = files;
|
||||
|
||||
const parsedFile: formidable.File =
|
||||
file instanceof Array ? file[0] : file;
|
||||
const filePath = `${Date.now()}-${parsedFile.originalFilename}`;
|
||||
const convertedFile = fs.readFileSync(parsedFile.filepath);
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from(key as string)
|
||||
.upload(filePath, convertedFile);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
url: filePath,
|
||||
});
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
const { key, url } = req.query;
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from(`public/${key as string}`)
|
||||
.download(url as string);
|
||||
|
||||
if (error || data == null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const arrayBuffer = await data.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
res.status(200).send(buffer);
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createRouter } from './context';
|
||||
|
||||
export const companiesRouter = createRouter().query('list', {
|
||||
input: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
return await ctx.prisma.company.findMany({
|
||||
orderBy: {
|
||||
name: 'desc',
|
||||
},
|
||||
take: 10,
|
||||
where: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@ -0,0 +1,264 @@
|
||||
import { z } from 'zod';
|
||||
import {QuestionsQuestionType, Vote } from '@prisma/client';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { createProtectedRouter } from './context';
|
||||
|
||||
import type { Question } from '~/types/questions';
|
||||
|
||||
export const questionsQuestionRouter = createProtectedRouter()
|
||||
.query('getQuestionsByFilter', {
|
||||
input: z.object({
|
||||
company: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
questionType: z.nativeEnum(QuestionsQuestionType),
|
||||
role: z.string().optional(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const questionsData = await ctx.prisma.questionsQuestion.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
answers: true,
|
||||
comments: true,
|
||||
},
|
||||
},
|
||||
encounters: {
|
||||
select: {
|
||||
company: true,
|
||||
location: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
votes: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
where: {
|
||||
questionType: input.questionType,
|
||||
},
|
||||
});
|
||||
return questionsData
|
||||
.filter((data) => {
|
||||
for (let i = 0; i < data.encounters.length; i++) {
|
||||
const encounter = data.encounters[i]
|
||||
const matchCompany = (!input.company || (encounter.company === input.company));
|
||||
const matchLocation = (!input.location || (encounter.location === input.location));
|
||||
const matchRole = (!input.company || (encounter.role === input.role));
|
||||
if (matchCompany && matchLocation && matchRole) {return true};
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((data) => {
|
||||
const votes:number = data.votes.reduce(
|
||||
(previousValue:number, currentValue) => {
|
||||
let result:number = previousValue;
|
||||
|
||||
switch(currentValue.vote) {
|
||||
case Vote.UPVOTE:
|
||||
result += 1
|
||||
break;
|
||||
case Vote.DOWNVOTE:
|
||||
result -= 1
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
let userName = "";
|
||||
|
||||
if (data.user) {
|
||||
userName = data.user.name!;
|
||||
}
|
||||
|
||||
const question: Question = {
|
||||
company: "",
|
||||
content: data.content,
|
||||
id: data.id,
|
||||
location: "",
|
||||
numAnswers: data._count.answers,
|
||||
numComments: data._count.comments,
|
||||
numVotes: votes,
|
||||
role: "",
|
||||
updatedAt: data.updatedAt,
|
||||
user: userName,
|
||||
};
|
||||
return question;
|
||||
});
|
||||
}
|
||||
})
|
||||
.mutation('create', {
|
||||
input: z.object({
|
||||
content: z.string(),
|
||||
questionType: z.nativeEnum(QuestionsQuestionType),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
return await ctx.prisma.questionsQuestion.create({
|
||||
data: {
|
||||
...input,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('update', {
|
||||
input: z.object({
|
||||
content: z.string().optional(),
|
||||
id: z.string(),
|
||||
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
|
||||
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
const questionToUpdate = await ctx.prisma.questionsQuestion.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (questionToUpdate?.id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'User have no authorization to record.',
|
||||
// Optional: pass the original error to retain stack trace
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.prisma.questionsQuestion.update({
|
||||
data: {
|
||||
...input,
|
||||
},
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('delete', {
|
||||
input: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
const questionToDelete = await ctx.prisma.questionsQuestion.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (questionToDelete?.id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'User have no authorization to record.',
|
||||
// Optional: pass the original error to retain stack trace
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.prisma.questionsQuestion.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.query('getVote', {
|
||||
input: z.object({
|
||||
questionId: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
const {questionId} = input
|
||||
|
||||
return await ctx.prisma.questionsQuestionVote.findUnique({
|
||||
where: {
|
||||
questionId_userId : {questionId,userId }
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('createVote', {
|
||||
input: z.object({
|
||||
questionId: z.string(),
|
||||
vote: z.nativeEnum(Vote),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
return await ctx.prisma.questionsQuestionVote.create({
|
||||
data: {
|
||||
...input,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('updateVote', {
|
||||
input: z.object({
|
||||
id: z.string(),
|
||||
vote: z.nativeEnum(Vote),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
const {id, vote} = input
|
||||
|
||||
const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (voteToUpdate?.id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'User have no authorization to record.',
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.prisma.questionsQuestionVote.update({
|
||||
data: {
|
||||
vote,
|
||||
},
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
.mutation('deleteVote', {
|
||||
input: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
async resolve({ ctx, input }) {
|
||||
const userId = ctx.session?.user?.id;
|
||||
|
||||
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},});
|
||||
|
||||
if (voteToDelete?.id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'User have no authorization to record.',
|
||||
});
|
||||
}
|
||||
|
||||
return await ctx.prisma.questionsQuestionVote.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@ -1,28 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
@ -1,42 +0,0 @@
|
||||
import { createRouter } from './context';
|
||||
|
||||
import type { Resume } from '~/types/resume';
|
||||
|
||||
export const resumesRouter = createRouter().query('all', {
|
||||
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,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createRouter } from '../context';
|
||||
|
||||
import type { Resume } from '~/types/resume';
|
||||
|
||||
export const resumesRouter = createRouter()
|
||||
.query('findAll', {
|
||||
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;
|
||||
});
|
||||
},
|
||||
})
|
||||
.query('findOne', {
|
||||
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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
import { ResumesSection } from '@prisma/client';
|
||||
|
||||
import { createProtectedRouter } from './context';
|
||||
import { createProtectedRouter } from '../context';
|
||||
|
||||
type IResumeCommentInput = Readonly<{
|
||||
description: string;
|
@ -1,7 +1,19 @@
|
||||
export type AnswerComment = {
|
||||
export type Question = {
|
||||
// TODO: company, location, role maps
|
||||
company: string;
|
||||
content: string;
|
||||
id: string;
|
||||
location: string;
|
||||
numAnswers: number;
|
||||
numComments: number;
|
||||
numVotes: number;
|
||||
role: string;
|
||||
updatedAt: Date;
|
||||
user: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AnswerComment = {
|
||||
content: string;
|
||||
id: string;
|
||||
numVotes: number;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
import { env } from '~/env/server.mjs';
|
||||
|
||||
const { SUPABASE_URL, SUPABASE_ANON_KEY } = env;
|
||||
|
||||
// Create a single supabase client for interacting with the file storage
|
||||
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
@ -0,0 +1,79 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ComponentMeta } from '@storybook/react';
|
||||
import type { TypeaheadOption } from '@tih/ui';
|
||||
import { Typeahead } from '@tih/ui';
|
||||
|
||||
export default {
|
||||
argTypes: {
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
isLabelHidden: {
|
||||
control: 'boolean',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
noResultsMessage: {
|
||||
control: 'text',
|
||||
},
|
||||
},
|
||||
component: Typeahead,
|
||||
parameters: {
|
||||
docs: {
|
||||
iframeHeight: 400,
|
||||
inlineStories: false,
|
||||
},
|
||||
},
|
||||
title: 'Typeahead',
|
||||
} as ComponentMeta<typeof Typeahead>;
|
||||
|
||||
export function Basic({
|
||||
disabled,
|
||||
isLabelHidden,
|
||||
label,
|
||||
}: Pick<
|
||||
React.ComponentProps<typeof Typeahead>,
|
||||
'disabled' | 'isLabelHidden' | 'label'
|
||||
>) {
|
||||
const people = [
|
||||
{ id: '1', label: 'Wade Cooper', value: '1' },
|
||||
{ id: '2', label: 'Arlene Mccoy', value: '2' },
|
||||
{ id: '3', label: 'Devon Webb', value: '3' },
|
||||
{ id: '4', label: 'Tom Cook', value: '4' },
|
||||
{ id: '5', label: 'Tanya Fox', value: '5' },
|
||||
{ id: '6', label: 'Hellen Schmidt', value: '6' },
|
||||
];
|
||||
const [selectedEntry, setSelectedEntry] = useState<TypeaheadOption>(
|
||||
people[0],
|
||||
);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filteredPeople =
|
||||
query === ''
|
||||
? people
|
||||
: people.filter((person) =>
|
||||
person.label
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.includes(query.toLowerCase().replace(/\s+/g, '')),
|
||||
);
|
||||
|
||||
return (
|
||||
<Typeahead
|
||||
disabled={disabled}
|
||||
isLabelHidden={isLabelHidden}
|
||||
label={label}
|
||||
options={filteredPeople}
|
||||
value={selectedEntry}
|
||||
onQueryChange={setQuery}
|
||||
onSelect={setSelectedEntry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Basic.args = {
|
||||
disabled: false,
|
||||
isLabelHidden: false,
|
||||
label: 'Author',
|
||||
};
|
@ -0,0 +1,131 @@
|
||||
import clsx from 'clsx';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Combobox, Transition } from '@headlessui/react';
|
||||
import { ChevronUpDownIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
export type TypeaheadOption = Readonly<{
|
||||
// String value to uniquely identify the option.
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
type Props = Readonly<{
|
||||
disabled?: boolean;
|
||||
isLabelHidden?: boolean;
|
||||
label: string;
|
||||
noResultsMessage?: string;
|
||||
nullable?: boolean;
|
||||
onQueryChange: (
|
||||
value: string,
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => void;
|
||||
onSelect: (option: TypeaheadOption) => void;
|
||||
options: ReadonlyArray<TypeaheadOption>;
|
||||
value?: TypeaheadOption;
|
||||
}>;
|
||||
|
||||
export default function Typeahead({
|
||||
disabled = false,
|
||||
isLabelHidden,
|
||||
label,
|
||||
noResultsMessage = 'No results',
|
||||
nullable = false,
|
||||
options,
|
||||
onQueryChange,
|
||||
value,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
return (
|
||||
<Combobox
|
||||
by="id"
|
||||
disabled={disabled}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
multiple={false}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
nullable={nullable}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
if (newValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
onSelect(newValue as TypeaheadOption);
|
||||
}}>
|
||||
<Combobox.Label
|
||||
className={clsx(
|
||||
isLabelHidden
|
||||
? 'sr-only'
|
||||
: 'mb-1 block text-sm font-medium text-slate-700',
|
||||
)}>
|
||||
{label}
|
||||
</Combobox.Label>
|
||||
<div className="relative">
|
||||
<div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm">
|
||||
<Combobox.Input
|
||||
className={clsx(
|
||||
'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0',
|
||||
disabled && 'pointer-events-none select-none bg-slate-100',
|
||||
)}
|
||||
displayValue={(option) =>
|
||||
(option as unknown as TypeaheadOption)?.label
|
||||
}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
onQueryChange(event.target.value, event);
|
||||
}}
|
||||
/>
|
||||
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 text-slate-400"
|
||||
/>
|
||||
</Combobox.Button>
|
||||
</div>
|
||||
<Transition
|
||||
afterLeave={() => setQuery('')}
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<Combobox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{options.length === 0 && query !== '' ? (
|
||||
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
|
||||
{noResultsMessage}
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.id}
|
||||
className={({ active }) =>
|
||||
clsx(
|
||||
'relative cursor-default select-none py-2 px-4 text-slate-500',
|
||||
active && 'bg-slate-100',
|
||||
)
|
||||
}
|
||||
value={option}>
|
||||
{({ selected }) => (
|
||||
<span
|
||||
className={clsx(
|
||||
'block truncate',
|
||||
selected ? 'font-medium' : 'font-normal',
|
||||
)}>
|
||||
{option.label}
|
||||
</span>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
Loading…
Reference in new issue