Skip to content

Commit dee6094

Browse files
committed
feat: add application shell
1 parent 523d91f commit dee6094

File tree

17 files changed

+465
-98
lines changed

17 files changed

+465
-98
lines changed

apps/portal/.eslintrc.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module.exports = {
22
root: true,
3-
extends: ['tih', 'next/core-web-vitals'],
3+
extends: ['tih'],
44
parserOptions: {
55
tsconfigRootDir: __dirname,
66
project: ['./tsconfig.json'],

apps/portal/next.config.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ function defineNextConfig(config) {
1313
}
1414

1515
export default defineNextConfig({
16+
experimental: {
17+
newNextLinkBehavior: true,
18+
},
1619
reactStrictMode: true,
1720
swcMinify: true,
1821
});

apps/portal/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,16 @@
1212
"prisma": "prisma"
1313
},
1414
"dependencies": {
15+
"@headlessui/react": "^1.7.2",
16+
"@heroicons/react": "^2.0.11",
1517
"@next-auth/prisma-adapter": "^1.0.4",
1618
"@prisma/client": "^4.4.0",
1719
"@tih/ui": "*",
1820
"@trpc/client": "^9.27.2",
1921
"@trpc/next": "^9.27.2",
2022
"@trpc/react": "^9.27.2",
2123
"@trpc/server": "^9.27.2",
24+
"clsx": "^1.2.1",
2225
"next": "12.3.1",
2326
"next-auth": "~4.10.3",
2427
"react": "18.2.0",
@@ -28,6 +31,10 @@
2831
"zod": "^3.18.0"
2932
},
3033
"devDependencies": {
34+
"@tailwindcss/aspect-ratio": "^0.4.2",
35+
"@tailwindcss/forms": "^0.5.3",
36+
"@tailwindcss/line-clamp": "^0.4.2",
37+
"@tailwindcss/typography": "^0.5.7",
3138
"@tih/tsconfig": "*",
3239
"@types/node": "18.0.0",
3340
"@types/react": "18.0.21",
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
import clsx from 'clsx';
2+
import Link from 'next/link';
3+
import { signIn, signOut, useSession } from 'next-auth/react';
4+
import type { ReactNode } from 'react';
5+
import { Fragment, useState } from 'react';
6+
import { Dialog, Menu, Transition } from '@headlessui/react';
7+
import {
8+
Bars3BottomLeftIcon,
9+
BriefcaseIcon,
10+
CurrencyDollarIcon,
11+
DocumentTextIcon,
12+
HomeIcon,
13+
XMarkIcon,
14+
} from '@heroicons/react/24/outline';
15+
16+
const sidebarNavigation = [
17+
{ current: false, href: '/', icon: HomeIcon, name: 'Home' },
18+
{ current: false, href: '/resumes', icon: DocumentTextIcon, name: 'Resumes' },
19+
{
20+
current: false,
21+
href: '/questions',
22+
icon: BriefcaseIcon,
23+
name: 'Questions',
24+
},
25+
{ current: false, href: '/offers', icon: CurrencyDollarIcon, name: 'Offers' },
26+
];
27+
28+
type Props = Readonly<{
29+
children: ReactNode;
30+
}>;
31+
32+
function ProfileJewel() {
33+
const { data: session, status } = useSession();
34+
const isSessionLoading = status === 'loading';
35+
36+
if (isSessionLoading) {
37+
return null;
38+
}
39+
40+
if (session == null) {
41+
return (
42+
<a
43+
href="/api/auth/signin"
44+
onClick={(event) => {
45+
event.preventDefault();
46+
signIn();
47+
}}>
48+
Sign in
49+
</a>
50+
);
51+
}
52+
53+
const userNavigation = [
54+
{ href: '/profile', name: 'Profile' },
55+
{
56+
href: '/api/auth/signout',
57+
name: 'Sign out',
58+
onClick: (event: MouseEvent) => {
59+
event.preventDefault();
60+
signOut();
61+
},
62+
},
63+
];
64+
65+
return (
66+
<Menu as="div" className="relative flex-shrink-0">
67+
<div>
68+
<Menu.Button className="flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
69+
<span className="sr-only">Open user menu</span>
70+
{session?.user?.image == null ? (
71+
<span>Render some icon</span>
72+
) : (
73+
<img
74+
alt={session?.user?.email ?? session?.user?.name ?? ''}
75+
className="h-8 w-8 rounded-full"
76+
src={session?.user.image}
77+
/>
78+
)}
79+
</Menu.Button>
80+
</div>
81+
<Transition
82+
as={Fragment}
83+
enter="transition ease-out duration-100"
84+
enterFrom="transform opacity-0 scale-95"
85+
enterTo="transform opacity-100 scale-100"
86+
leave="transition ease-in duration-75"
87+
leaveFrom="transform opacity-100 scale-100"
88+
leaveTo="transform opacity-0 scale-95">
89+
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
90+
{userNavigation.map((item) => (
91+
<Menu.Item key={item.name}>
92+
{({ active }) => (
93+
<Link
94+
className={clsx(
95+
active ? 'bg-gray-100' : '',
96+
'block px-4 py-2 text-sm text-gray-700',
97+
)}
98+
href={item.href}
99+
onClick={item.onClick}>
100+
{item.name}
101+
</Link>
102+
)}
103+
</Menu.Item>
104+
))}
105+
</Menu.Items>
106+
</Transition>
107+
</Menu>
108+
);
109+
}
110+
111+
export default function AppShell({ children }: Props) {
112+
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
113+
114+
return (
115+
<div className="flex min-h-screen h-full">
116+
{/* Narrow sidebar */}
117+
<div className="hidden w-28 overflow-y-auto bg-indigo-700 md:block">
118+
<div className="flex w-full flex-col items-center py-6">
119+
<div className="flex flex-shrink-0 items-center">
120+
<img
121+
alt="Your Company"
122+
className="h-8 w-auto"
123+
src="https://tailwindui.com/img/logos/mark.svg?color=white"
124+
/>
125+
</div>
126+
<div className="mt-6 w-full flex-1 space-y-1 px-2">
127+
{sidebarNavigation.map((item) => (
128+
<Link
129+
key={item.name}
130+
aria-current={item.current ? 'page' : undefined}
131+
className={clsx(
132+
item.current
133+
? 'bg-indigo-800 text-white'
134+
: 'text-indigo-100 hover:bg-indigo-800 hover:text-white',
135+
'group w-full p-3 rounded-md flex flex-col items-center text-xs font-medium',
136+
)}
137+
href={item.href}>
138+
<item.icon
139+
aria-hidden="true"
140+
className={clsx(
141+
item.current
142+
? 'text-white'
143+
: 'text-indigo-300 group-hover:text-white',
144+
'h-6 w-6',
145+
)}
146+
/>
147+
<span className="mt-2">{item.name}</span>
148+
</Link>
149+
))}
150+
</div>
151+
</div>
152+
</div>
153+
154+
{/* Mobile menu */}
155+
<Transition.Root as={Fragment} show={mobileMenuOpen}>
156+
<Dialog
157+
as="div"
158+
className="relative z-20 md:hidden"
159+
onClose={setMobileMenuOpen}>
160+
<Transition.Child
161+
as={Fragment}
162+
enter="transition-opacity ease-linear duration-300"
163+
enterFrom="opacity-0"
164+
enterTo="opacity-100"
165+
leave="transition-opacity ease-linear duration-300"
166+
leaveFrom="opacity-100"
167+
leaveTo="opacity-0">
168+
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
169+
</Transition.Child>
170+
171+
<div className="fixed inset-0 z-40 flex">
172+
<Transition.Child
173+
as={Fragment}
174+
enter="transition ease-in-out duration-300 transform"
175+
enterFrom="-translate-x-full"
176+
enterTo="translate-x-0"
177+
leave="transition ease-in-out duration-300 transform"
178+
leaveFrom="translate-x-0"
179+
leaveTo="-translate-x-full">
180+
<Dialog.Panel className="relative flex w-full max-w-xs flex-1 flex-col bg-indigo-700 pt-5 pb-4">
181+
<Transition.Child
182+
as={Fragment}
183+
enter="ease-in-out duration-300"
184+
enterFrom="opacity-0"
185+
enterTo="opacity-100"
186+
leave="ease-in-out duration-300"
187+
leaveFrom="opacity-100"
188+
leaveTo="opacity-0">
189+
<div className="absolute top-1 right-0 -mr-14 p-1">
190+
<button
191+
className="flex h-12 w-12 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-white"
192+
type="button"
193+
onClick={() => setMobileMenuOpen(false)}>
194+
<XMarkIcon
195+
aria-hidden="true"
196+
className="h-6 w-6 text-white"
197+
/>
198+
<span className="sr-only">Close sidebar</span>
199+
</button>
200+
</div>
201+
</Transition.Child>
202+
<div className="flex flex-shrink-0 items-center px-4">
203+
<img
204+
alt="Your Company"
205+
className="h-8 w-auto"
206+
src="https://tailwindui.com/img/logos/mark.svg?color=white"
207+
/>
208+
</div>
209+
<div className="mt-5 h-0 flex-1 overflow-y-auto px-2">
210+
<nav className="flex h-full flex-col">
211+
<div className="space-y-1">
212+
{sidebarNavigation.map((item) => (
213+
<a
214+
key={item.name}
215+
aria-current={item.current ? 'page' : undefined}
216+
className={clsx(
217+
item.current
218+
? 'bg-indigo-800 text-white'
219+
: 'text-indigo-100 hover:bg-indigo-800 hover:text-white',
220+
'group py-2 px-3 rounded-md flex items-center text-sm font-medium',
221+
)}
222+
href={item.href}>
223+
<item.icon
224+
aria-hidden="true"
225+
className={clsx(
226+
item.current
227+
? 'text-white'
228+
: 'text-indigo-300 group-hover:text-white',
229+
'mr-3 h-6 w-6',
230+
)}
231+
/>
232+
<span>{item.name}</span>
233+
</a>
234+
))}
235+
</div>
236+
</nav>
237+
</div>
238+
</Dialog.Panel>
239+
</Transition.Child>
240+
<div aria-hidden="true" className="w-14 flex-shrink-0">
241+
{/* Dummy element to force sidebar to shrink to fit close icon */}
242+
</div>
243+
</div>
244+
</Dialog>
245+
</Transition.Root>
246+
247+
{/* Content area */}
248+
<div className="flex flex-1 flex-col overflow-hidden">
249+
<header className="w-full">
250+
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm">
251+
<button
252+
className="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500 md:hidden"
253+
type="button"
254+
onClick={() => setMobileMenuOpen(true)}>
255+
<span className="sr-only">Open sidebar</span>
256+
<Bars3BottomLeftIcon aria-hidden="true" className="h-6 w-6" />
257+
</button>
258+
<div className="flex flex-1 justify-between px-4 sm:px-6">
259+
<div className="flex flex-1 items-center">Some menu items</div>
260+
<div className="ml-2 flex items-center space-x-4 sm:ml-6 sm:space-x-6">
261+
<ProfileJewel />
262+
</div>
263+
</div>
264+
</div>
265+
</header>
266+
267+
{/* Main content */}
268+
<div className="flex flex-1 items-stretch overflow-hidden">
269+
{children}
270+
</div>
271+
</div>
272+
</div>
273+
);
274+
}

apps/portal/src/env/client.mjs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ export const formatErrors = (
99
) =>
1010
Object.entries(errors)
1111
.map(([name, value]) => {
12-
if (value && '_errors' in value)
12+
if (value && '_errors' in value) {
1313
return `${name}: ${value._errors.join(', ')}\n`;
14+
}
1415
})
1516
.filter(Boolean);
1617

@@ -25,7 +26,7 @@ if (_clientEnv.success === false) {
2526
/**
2627
* Validate that client-side environment variables are exposed to the client.
2728
*/
28-
for (let key of Object.keys(_clientEnv.data)) {
29+
for (const key of Object.keys(_clientEnv.data)) {
2930
if (!key.startsWith('NEXT_PUBLIC_')) {
3031
console.warn(
3132
`❌ Invalid public environment variable name: ${key}. It must begin with 'NEXT_PUBLIC_'`,

apps/portal/src/pages/_app.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import type { AppType } from 'next/app';
22
import type { Session } from 'next-auth';
33
import { SessionProvider } from 'next-auth/react';
4+
import React from 'react';
45
import superjson from 'superjson';
56
import { httpBatchLink } from '@trpc/client/links/httpBatchLink';
67
import { loggerLink } from '@trpc/client/links/loggerLink';
78
import { withTRPC } from '@trpc/next';
89

10+
import AppShell from '~/components/global/AppShell';
11+
912
import type { AppRouter } from '~/server/router';
1013

1114
import '~/styles/globals.css';
@@ -16,7 +19,9 @@ const MyApp: AppType<{ session: Session | null }> = ({
1619
}) => {
1720
return (
1821
<SessionProvider session={session}>
19-
<Component {...pageProps} />
22+
<AppShell>
23+
<Component {...pageProps} />
24+
</AppShell>
2025
</SessionProvider>
2126
);
2227
};

apps/portal/src/pages/_document.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Head, Html, Main, NextScript } from 'next/document';
2+
3+
export default function Document() {
4+
return (
5+
<Html className="h-full bg-gray-50">
6+
<Head />
7+
<body className="h-full overflow-hidden">
8+
<Main />
9+
<NextScript />
10+
</body>
11+
</Html>
12+
);
13+
}

apps/portal/src/pages/api/auth/[...nextauth].ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export const authOptions: NextAuthOptions = {
1313
// Include user.id on session
1414
callbacks: {
1515
session({ session, user }) {
16-
if (session.user) {
16+
if (session.user != null) {
17+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
18+
// @ts-ignore
1719
session.user.id = user.id;
1820
}
1921
return session;

0 commit comments

Comments
 (0)