Skip to content

Commit 1d8182b

Browse files
committed
feat: implement LoginModal and associated components for user authentication
1 parent 05b31ce commit 1d8182b

File tree

14 files changed

+535
-2
lines changed

14 files changed

+535
-2
lines changed

src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import WindowWrapper from '../components/Window/WindowWrapper';
1111
import Providers from '../providers/Providers';
1212
import '../styles/index.scss';
1313
import { toUITheme } from '../utils/theme';
14+
import { Modals } from '../components/Modals/Modals';
1415

1516
export const metadata = {
1617
title: 'Podverse',
@@ -40,6 +41,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
4041
<NavBar />
4142
{children}
4243
</PageWrapper>
44+
<Modals />
4345
</WindowWrapper>
4446
</Providers>
4547
</body>

src/components/Auth/LoginModal.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"use client";
2+
3+
import React, { useState } from 'react'
4+
import { Button } from '../Button/Button'
5+
import { Modal } from '../Modal/Modal'
6+
import { TextInput } from '../TextInput/TextInput'
7+
import { useModals } from '../../contexts/Modals'
8+
import styles from '../../styles/components/Auth/LoginModal.module.scss'
9+
10+
export const LoginModal: React.FC = () => {
11+
const { modals, closeModal } = useModals()
12+
const [email, setEmail] = useState('')
13+
const [password, setPassword] = useState('')
14+
15+
const handleSubmit = (e: React.FormEvent) => {
16+
e.preventDefault()
17+
// handle login logic here
18+
closeModal('LoginModal')
19+
}
20+
21+
return (
22+
<Modal
23+
header="Log in"
24+
isOpen={modals.LoginModal.isOpen}
25+
onClose={() => closeModal('LoginModal')}
26+
ariaLabel="Log in"
27+
>
28+
<form onSubmit={handleSubmit}>
29+
<TextInput
30+
type="email"
31+
value={email}
32+
onChange={e => setEmail(e.target.value)}
33+
autoFocus
34+
placeholder="Email"
35+
eyebrow="Email"
36+
/>
37+
<TextInput
38+
type="password"
39+
value={password}
40+
onChange={e => setPassword(e.target.value)}
41+
placeholder="Password"
42+
eyebrow="Password"
43+
/>
44+
<div className={styles.buttons}>
45+
<Button type="button" onClick={() => closeModal('LoginModal')} variant="secondary">
46+
Cancel
47+
</Button>
48+
<Button type="submit" variant="primary">
49+
Submit
50+
</Button>
51+
</div>
52+
</form>
53+
</Modal>
54+
)
55+
}

src/components/Button/Button.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from 'react'
2+
import classNames from 'classnames'
3+
import styles from '../../styles/components/Button/Button.module.scss'
4+
5+
type ButtonVariant = 'primary' | 'secondary' | 'warning' | 'success' | 'danger' | 'outline'
6+
7+
type ButtonProps = {
8+
children: React.ReactNode
9+
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
10+
type?: 'button' | 'submit' | 'reset'
11+
disabled?: boolean
12+
className?: string
13+
style?: React.CSSProperties
14+
variant?: ButtonVariant
15+
'aria-label'?: string
16+
'aria-describedby'?: string
17+
'aria-pressed'?: boolean
18+
tabIndex?: number
19+
autoFocus?: boolean
20+
id?: string
21+
name?: string
22+
title?: string
23+
role?: string
24+
}
25+
26+
export const Button: React.FC<ButtonProps> = ({
27+
children,
28+
onClick,
29+
type = 'button',
30+
disabled = false,
31+
className,
32+
style,
33+
variant = 'primary',
34+
'aria-label': ariaLabel,
35+
'aria-describedby': ariaDescribedBy,
36+
'aria-pressed': ariaPressed,
37+
tabIndex,
38+
autoFocus,
39+
id,
40+
name,
41+
title,
42+
role = 'button',
43+
...rest
44+
}) => (
45+
<button
46+
type={type}
47+
onClick={onClick}
48+
disabled={disabled}
49+
className={classNames(
50+
styles.button,
51+
styles[variant],
52+
{ [styles.disabled]: disabled },
53+
className
54+
)}
55+
style={style}
56+
aria-label={ariaLabel}
57+
aria-describedby={ariaDescribedBy}
58+
aria-pressed={ariaPressed}
59+
tabIndex={tabIndex}
60+
autoFocus={autoFocus}
61+
id={id}
62+
name={name}
63+
title={title}
64+
role={role}
65+
{...rest}
66+
>
67+
{children}
68+
</button>
69+
)

src/components/Modal/Modal.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { ReactNode } from 'react'
2+
import { FaTimes } from 'react-icons/fa'
3+
import styles from '../../styles/components/Modal/Modal.module.scss'
4+
5+
type ModalProps = {
6+
isOpen: boolean
7+
onClose: () => void
8+
ariaLabel: string
9+
children: ReactNode
10+
header?: string
11+
}
12+
13+
export const Modal = ({ isOpen, onClose, ariaLabel, children, header }: ModalProps) => {
14+
if (!isOpen) return null
15+
16+
return (
17+
<div
18+
role="dialog"
19+
aria-modal="true"
20+
aria-label={ariaLabel}
21+
tabIndex={-1}
22+
className={styles.modalRoot}
23+
>
24+
<div
25+
className={styles.modalBackdrop}
26+
aria-hidden="true"
27+
onClick={onClose}
28+
/>
29+
<div className={styles.modalContent}>
30+
{(header || header === '') && (
31+
<div className={styles.modalHeader}>
32+
<span
33+
className={styles.modalHeaderText}
34+
title={header}
35+
style={{
36+
overflow: 'hidden',
37+
textOverflow: 'ellipsis',
38+
whiteSpace: 'nowrap',
39+
flex: 1,
40+
minWidth: 0,
41+
}}
42+
>
43+
{header}
44+
</span>
45+
<button
46+
onClick={onClose}
47+
aria-label="Close modal"
48+
className={styles.modalCloseButton}
49+
>
50+
<FaTimes />
51+
</button>
52+
</div>
53+
)}
54+
{!header && (
55+
<button
56+
onClick={onClose}
57+
aria-label="Close modal"
58+
className={styles.modalCloseButton}
59+
style={{ position: 'absolute', top: 8, right: 8 }}
60+
>
61+
<FaTimes />
62+
</button>
63+
)}
64+
<div className={styles.modalChildren}>
65+
{children}
66+
</div>
67+
</div>
68+
</div>
69+
)
70+
}

src/components/Modals/Modals.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"use client";
2+
3+
import React from 'react'
4+
import { LoginModal } from '../Auth/LoginModal'
5+
6+
export const Modals: React.FC = () => {
7+
return (
8+
<>
9+
<LoginModal />
10+
</>
11+
)
12+
}

src/components/NavBar/NavBarDropdownButton.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { AccountContext } from "../../contexts/Account";
88
import DropdownMenu from "../DropdownMenu/DropdownMenu";
99
import { useDropdownKeyboardNavigation } from "../../hooks/useDropdownKeyboardNavigation";
1010
import { ROUTES } from "../../constants/routes";
11+
import { useModals } from '../../contexts/Modals'
1112

1213
const NavBarDropdownButton: React.FC = () => {
1314
const { isLoggedIn } = useContext(AccountContext);
15+
const { openModal } = useModals();
1416
const router = useRouter();
1517
const buttonRef = useRef<HTMLButtonElement>(null);
1618
const menuRef = useRef<HTMLUListElement>(null);
@@ -19,7 +21,7 @@ const NavBarDropdownButton: React.FC = () => {
1921
{ label: "My Profile", onClick: () => router.push(ROUTES.MY_PROFILE) },
2022
{ label: "Membership", onClick: () => router.push(ROUTES.MEMBERSHIP) },
2123
{ label: "Settings", onClick: () => router.push(ROUTES.SETTINGS) },
22-
{ label: "Logout", onClick: () => {/* ... */} }
24+
{ label: "Login", onClick: () => openModal('LoginModal') }
2325
];
2426

2527
const {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import React from 'react'
2+
import styles from '../../styles/components/TextInput/TextInput.module.scss'
3+
4+
type TextInputProps = {
5+
value: string
6+
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void
7+
eyebrow?: string
8+
info?: string
9+
placeholder?: string
10+
type?: string
11+
disabled?: boolean
12+
className?: string
13+
style?: React.CSSProperties
14+
id?: string
15+
name?: string
16+
autoFocus?: boolean
17+
tabIndex?: number
18+
'aria-label'?: string
19+
'aria-describedby'?: string
20+
'aria-required'?: boolean
21+
'aria-invalid'?: boolean
22+
}
23+
24+
export const TextInput: React.FC<TextInputProps> = ({
25+
value,
26+
onChange,
27+
eyebrow,
28+
info,
29+
placeholder,
30+
type = 'text',
31+
disabled = false,
32+
className,
33+
style,
34+
id,
35+
name,
36+
autoFocus,
37+
tabIndex,
38+
'aria-label': ariaLabel,
39+
'aria-describedby': ariaDescribedBy,
40+
'aria-required': ariaRequired,
41+
'aria-invalid': ariaInvalid,
42+
...rest
43+
}) => {
44+
const inputId = id || name || undefined
45+
const infoId = info ? `${inputId || 'textinput'}-info` : undefined
46+
47+
return (
48+
<div className={`${styles.textInput} ${className || ''}`} style={style}>
49+
<div className={styles.textInputWrapper}>
50+
<div className={styles.textInnerInputWrapper}>
51+
{eyebrow && value && (
52+
<label htmlFor={inputId} className={styles.eyebrow}>
53+
{eyebrow}
54+
</label>
55+
)}
56+
<input
57+
id={inputId}
58+
name={name}
59+
type={type}
60+
value={value}
61+
onChange={onChange}
62+
placeholder={placeholder}
63+
disabled={disabled}
64+
autoFocus={autoFocus}
65+
tabIndex={tabIndex}
66+
aria-label={ariaLabel}
67+
aria-describedby={info ? infoId : ariaDescribedBy}
68+
aria-required={ariaRequired}
69+
aria-invalid={ariaInvalid}
70+
className={styles.input}
71+
{...rest}
72+
/>
73+
{info && (
74+
<div id={infoId} className={styles.textInputInfo}>
75+
{info}
76+
</div>
77+
)}
78+
</div>
79+
</div>
80+
</div>
81+
)
82+
}

src/contexts/Modals.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { createContext, useContext, useState, ReactNode } from 'react'
2+
3+
type ModalsState = {
4+
LoginModal: { isOpen: boolean }
5+
SignUpModal: { isOpen: boolean }
6+
}
7+
8+
type ModalsContextType = {
9+
modals: ModalsState
10+
openModal: (modal: keyof ModalsState) => void
11+
closeModal: (modal: keyof ModalsState) => void
12+
}
13+
14+
const defaultState: ModalsState = {
15+
LoginModal: { isOpen: false },
16+
SignUpModal: { isOpen: false },
17+
}
18+
19+
const ModalsContext = createContext<ModalsContextType | undefined>(undefined)
20+
21+
export const ModalsProvider = ({ children }: { children: ReactNode }) => {
22+
const [modals, setModals] = useState<ModalsState>(defaultState)
23+
24+
const openModal = (modal: keyof ModalsState) => {
25+
setModals(prev => ({
26+
...prev,
27+
[modal]: { isOpen: true }
28+
}))
29+
}
30+
31+
const closeModal = (modal: keyof ModalsState) => {
32+
setModals(prev => ({
33+
...prev,
34+
[modal]: { isOpen: false }
35+
}))
36+
}
37+
38+
return (
39+
<ModalsContext.Provider value={{ modals, openModal, closeModal }}>
40+
{children}
41+
</ModalsContext.Provider>
42+
)
43+
}
44+
45+
export const useModals = () => {
46+
const context = useContext(ModalsContext)
47+
if (!context) throw new Error('useModals must be used within a ModalsProvider')
48+
return context
49+
}

0 commit comments

Comments
 (0)