chore: initial commit

This commit is contained in:
Divlo
2021-10-24 05:19:39 +02:00
commit 21123c4477
145 changed files with 48821 additions and 0 deletions

View File

@ -0,0 +1,22 @@
export const Main: React.FC = (props) => {
return (
<>
<main className='main'>{props.children}</main>
<style jsx>
{`
.main {
padding: 2rem;
margin-left: var(--sidebar-width);
background-color: var(--color-background-secondary);
min-height: 100vh;
overflow: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,19 @@
import { memo } from 'react'
export const SidebarItem: React.FC = memo((props) => {
return (
<>
<li className='sidebar-item'>{props.children}</li>
<style jsx>
{`
.sidebar-item {
position: relative;
margin: 10px;
cursor: pointer;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,28 @@
export interface SidebarListProps extends React.ComponentPropsWithRef<'ul'> {}
export const SidebarList: React.FC<SidebarListProps> = (props) => {
const { children, ...rest } = props
return (
<>
<ul {...rest} className='sidebar-list'>
{children}
</ul>
<style jsx>
{`
.sidebar-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-flow: column wrap;
overflow-y: auto;
overflow-x: hidden;
flex-direction: row !important;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { SidebarItem } from '../SidebarItem'
describe('<SidebarItem />', () => {
it('should render', async () => {
const { getByText } = render(<SidebarItem>Item</SidebarItem>)
expect(getByText('Item')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { SidebarList } from '../SidebarList'
describe('<SidebarList />', () => {
it('should render', async () => {
const { getByText } = render(<SidebarList>List Item</SidebarList>)
expect(getByText('List Item')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,98 @@
import useTranslation from 'next-translate/useTranslation'
import InfiniteScroll from 'react-infinite-scroll-component'
import { IconButton } from 'components/design/IconButton'
import { Avatar } from 'components/design/Avatar'
import { SidebarItem } from './SidebarItem'
import { SidebarList } from './SidebarList'
import { API_URL } from 'utils/api'
import { useGuilds } from 'contexts/Guilds'
import { Tooltip } from 'components/design/Tooltip'
import { useAuthentication } from 'utils/authentication'
import { Loader } from 'components/design/Loader'
import Link from 'next/link'
export const Sidebar: React.FC = () => {
const { guilds, nextPage } = useGuilds()
const { t } = useTranslation()
const { user } = useAuthentication()
return (
<>
<nav className='sidebar'>
<SidebarList id='sidebar-list'>
<SidebarItem>
<Link href='/application'>
<Tooltip content={t('application:settings')} direction='right'>
<Avatar
src='/images/icons/Thream.png'
alt='Thream'
width={60}
height={60}
/>
</Tooltip>
</Link>
</SidebarItem>
<SidebarItem>
<Tooltip content={t('application:settings')} direction='right'>
<Avatar
src={`${API_URL}${user.logo}`}
alt={user.name}
width={60}
height={60}
/>
</Tooltip>
</SidebarItem>
<SidebarItem>
<Tooltip content={t('application:add-guild')} direction='right'>
<IconButton icon='add' hasBackground />
</Tooltip>
</SidebarItem>
<InfiniteScroll
dataLength={guilds.rows.length}
next={nextPage}
style={{ overflow: 'none' }}
hasMore={guilds.hasMore}
loader={<Loader />}
scrollableTarget='sidebar-list'
>
{guilds.rows.map((row) => {
return (
<SidebarItem key={row.id}>
<Link
href={`/application/${row.guildId}/${row.lastVisitedChannelId}`}
>
<Tooltip content={row.guild.name} direction='right'>
<Avatar
src={`${API_URL}${row.guild.icon}`}
alt={row.guild.name}
width={60}
height={60}
/>
</Tooltip>
</Link>
</SidebarItem>
)
})}
</InfiniteScroll>
</SidebarList>
</nav>
<style jsx>
{`
.sidebar {
display: flex;
flex-flow: column wrap;
justify-content: space-between;
align-items: center;
position: fixed;
background-color: var(--color-background-primary);
width: var(--sidebar-width);
height: 100vh;
padding: 0 15px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Main } from '../Main'
describe('<Main />', () => {
it('should render', async () => {
const { getByText } = render(<Main>Content</Main>)
expect(getByText('Content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,32 @@
import {
AuthenticationProvider,
PagePropsWithAuthentication
} from 'utils/authentication'
import { Main } from './Main'
import { Sidebar } from './Sidebar'
import { Guilds, GuildsProvider } from 'contexts/Guilds'
export interface ApplicationProps extends PagePropsWithAuthentication {
guilds: Guilds
}
export const Application: React.FC<ApplicationProps> = (props) => {
return (
<AuthenticationProvider authentication={props.authentication}>
<GuildsProvider guilds={props.guilds}>
<div className='application'>
<Sidebar />
<Main>{props.children}</Main>
</div>
<style jsx global>
{`
body {
--sidebar-width: 11rem;
}
`}
</style>
</GuildsProvider>
</AuthenticationProvider>
)
}

View File

@ -0,0 +1,99 @@
import Link from 'next/link'
import useTranslation from 'next-translate/useTranslation'
import { Input } from 'components/design/Input'
import { FormState } from 'components/Authentication/FormState'
import { ValidatorSchema } from 'hooks/useFastestValidator'
import { AuthenticationProps } from '.'
import { AuthenticationFormLayout } from './AuthenticationFormLayout'
import { useForm } from 'hooks/useForm'
export const emailSchema: ValidatorSchema = {
email: {
type: 'email',
empty: false,
trim: true
}
}
export const nameSchema: ValidatorSchema = {
name: {
type: 'string',
min: 3,
max: 30,
trim: true
}
}
export const passwordSchema: ValidatorSchema = {
password: {
type: 'string',
empty: false,
trim: true
}
}
export const AuthenticationForm: React.FC<AuthenticationProps> = (props) => {
const { mode, onSubmit } = props
const { t } = useTranslation()
const {
getErrorMessages,
formState,
message,
handleChange,
handleSubmit
} = useForm({
validatorSchema: {
...(mode === 'signup' && { ...nameSchema }),
...emailSchema,
...passwordSchema
}
})
return (
<>
<AuthenticationFormLayout
onChange={handleChange}
onSubmit={handleSubmit(onSubmit)}
link={
<p>
<Link href={mode === 'signup' ? '/authentication/signin' : '/authentication/signup'}>
<a>
{mode === 'signup'
? t('authentication:already-have-an-account')
: t('authentication:dont-have-an-account')}
</a>
</Link>
</p>
}
>
{mode === 'signup' && (
<Input
errors={getErrorMessages('name')}
type='text'
placeholder={t('authentication:name')}
name='name'
label={t('authentication:name')}
/>
)}
<Input
errors={getErrorMessages('email')}
type='email'
placeholder='Email'
name='email'
label='Email'
/>
<Input
errors={getErrorMessages('password')}
type='password'
placeholder={t('authentication:password')}
name='password'
label={t('authentication:password')}
showForgotPassword={mode === 'signin'}
/>
</AuthenticationFormLayout>
<FormState state={formState} message={message} />
</>
)
}

View File

@ -0,0 +1,53 @@
import Form, { HandleForm } from 'react-component-form'
import { Button } from 'components/design/Button'
import useTranslation from 'next-translate/useTranslation'
export interface AuthenticationFormLayoutProps {
onChange?: HandleForm
onSubmit?: HandleForm
link?: React.ReactNode
}
export const AuthenticationFormLayout: React.FC<AuthenticationFormLayoutProps> = (
props
) => {
const { children, onChange, onSubmit, link } = props
const { t } = useTranslation()
return (
<>
<Form onChange={onChange} onSubmit={onSubmit}>
<div className='form-container'>
<div className='form'>
{children}
<Button style={{ width: '100%' }} type='submit'>
{t('authentication:submit')}
</Button>
{link}
</div>
</div>
</Form>
<style jsx>
{`
@media (max-width: 330px) {
.form {
width: auto !important;
}
}
.form {
flex-shrink: 0;
width: 310px;
}
.form-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,109 @@
import { useRouter } from 'next/router'
import {
SocialMediaButton,
SocialMedia
} from 'components/design/SocialMediaButton'
import { api } from 'utils/api'
import { Authentication, Tokens } from 'utils/authentication'
import { useEffect } from 'react'
const isTokens = (data: { [key: string]: any }): data is Tokens => {
return (
'accessToken' in data &&
'refreshToken' in data &&
'type' in data &&
'expiresIn' in data
)
}
export const AuthenticationSocialMedia: React.FC = () => {
const router = useRouter()
const handleAuthentication = async (
socialMedia: SocialMedia
): Promise<void> => {
const redirect = window.location.href
const { data: url } = await api.get(
`/users/oauth2/${socialMedia.toLowerCase()}/signin?redirectURI=${redirect}`
)
window.location.href = url
}
useEffect(() => {
const data = router.query
if (isTokens(data)) {
const authentication = new Authentication(data)
authentication.signin()
router.push('/application').catch(() => {})
}
}, [router.query])
return (
<>
<div className='social-container'>
<div className='social-buttons'>
<SocialMediaButton
onClick={async () => await handleAuthentication('Google')}
className='social-button'
socialMedia='Google'
/>
<SocialMediaButton
onClick={async () => await handleAuthentication('GitHub')}
className='social-button'
socialMedia='GitHub'
/>
<SocialMediaButton
onClick={async () => await handleAuthentication('Discord')}
className='social-button'
socialMedia='Discord'
/>
</div>
</div>
<style jsx>
{`
@media (max-width: 600px) {
:global(.social-button) {
margin-top: 15px !important;
}
.social-container {
margin-top: 20px !important;
}
.social-buttons {
height: 100% !important;
}
}
.social-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.social-buttons {
display: flex;
justify-content: space-evenly;
width: 60%;
}
@media (max-width: 970px) {
.social-buttons {
width: 80%;
}
}
@media (max-width: 770px) {
.social-buttons {
width: 100%;
}
}
@media (max-width: 600px) {
.social-buttons {
flex-direction: column;
align-items: center;
height: 30%;
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,76 @@
import useTranslation from 'next-translate/useTranslation'
export interface ErrorMessageProps {
errors: string[]
fontSize?: number
}
export const ErrorMessage: React.FC<ErrorMessageProps> = (props) => {
const { errors, fontSize = 14 } = props
const { t } = useTranslation()
if (errors.length === 0) {
return null
}
return (
<>
<div className='error-message'>
{errors.length === 1 && (
<>
<div className='error-thumbnail' />
<span className='error-text'>{errors[0]}</span>
</>
)}
{errors.length > 1 && (
<>
<div className='error-container'>
<div className='error-thumbnail' />
<span className='error-text'>{t('authentication:errors')} :</span>
</div>
<ul className='errors-list'>
{errors.map((error, index) => {
return <li key={index}>{error}</li>
})}
</ul>
</>
)}
</div>
<style jsx>
{`
.error-message {
position: relative;
display: ${errors.length > 1 ? 'block' : 'flex'};
flex-flow: row;
align-items: center;
margin-top: 12px;
left: -3px;
color: var(--color-error);
font-family: 'Poppins', 'Arial', 'sans-serif';
font-size: ${fontSize}px;
line-height: 21px;
}
.error-container {
display: flex;
align-items: center;
}
.errors-list {
margin: 10px 0 0 0;
}
.error-thumbnail {
display: inline-block;
min-width: 20px;
width: 20px;
height: 20px;
background-image: url(/images/svg/icons/input/error.svg);
background-size: cover;
}
.error-text {
padding-left: 5px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,104 @@
import useTranslation from 'next-translate/useTranslation'
import { FormState as FormStateType } from 'hooks/useFormState'
import { ErrorMessage } from './ErrorMessage'
import { Loader } from 'components/design/Loader'
export interface FormStateProps {
state: FormStateType
message?: string
}
export const FormState: React.FC<FormStateProps> = (props) => {
const { state, message } = props
const { t } = useTranslation()
if (state === 'loading') {
return (
<>
<div data-testid='loader' className='loader'>
<Loader />
</div>
<style jsx>
{`
.loader {
margin-top: 30px;
display: flex;
justify-content: center;
}
`}
</style>
</>
)
}
if (state === 'idle' || message == null) {
return null
}
if (state === 'success') {
return (
<>
<div className='success'>
<div className='success-message'>
<div className='success-thumbnail' />
<span className='success-text'>
<b>{t('authentication:success')} :</b> {message}
</span>
</div>
</div>
<style jsx>
{`
.success {
margin-top: 20px;
display: flex;
justify-content: center;
}
.success-message {
position: relative;
display: flex;
flex-flow: row;
align-items: center;
justify-content: center;
margin-top: 12px;
left: -3px;
color: var(--color-success);
font-family: 'Arial', 'sans-serif';
font-size: 16px;
line-height: 21px;
}
.success-thumbnail {
display: inline-block;
width: 20px;
height: 22px;
background-image: url(/images/svg/icons/input/success.svg);
background-size: cover;
}
.success-text {
padding-left: 5px;
}
`}
</style>
</>
)
}
return (
<>
<div data-testid='error' className='error'>
<ErrorMessage fontSize={16} errors={[message]} />
</div>
<style jsx>
{`
.error {
margin-top: 20px;
display: flex;
justify-content: center;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,14 @@
import { useTheme } from 'contexts/Theme'
export const Success: React.FC = () => {
const { theme } = useTheme()
return (
<svg data-testid='success' width='25' height='25' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path
d='M12.5 0C5.607 0 0 5.607 0 12.5 0 19.392 5.607 25 12.5 25 19.392 25 25 19.392 25 12.5 25 5.607 19.392 0 12.5 0zm-2.499 18.016L5.36 13.385l1.765-1.77 2.874 2.869 6.617-6.618 1.768 1.768L10 18.016z'
fill={theme === 'light' ? '#1e4620' : '#90ee90'}
/>
</svg>
)
}

View File

@ -0,0 +1,16 @@
import { render } from '@testing-library/react'
import { ErrorMessage } from '../ErrorMessage'
describe('<ErrorMessage />', () => {
it('should return nothing if there are no errors', async () => {
const { container } = render(<ErrorMessage errors={[]} />)
expect(container.innerHTML.length).toEqual(0)
})
it('should render the single error', async () => {
const errorMessage = 'Error Message'
const { getByText } = render(<ErrorMessage errors={[errorMessage]} />)
expect(getByText(errorMessage)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,33 @@
import { render } from '@testing-library/react'
import { FormState } from '../FormState'
describe('<FormState />', () => {
it('should return nothing if the state is idle', async () => {
const { container } = render(<FormState state='idle' />)
expect(container.innerHTML.length).toEqual(0)
})
it('should return nothing if the message is null', async () => {
const { container } = render(<FormState state='error' />)
expect(container.innerHTML.length).toEqual(0)
})
it('should render the <Loader /> if state is loading', async () => {
const { getByTestId } = render(<FormState state='loading' />)
expect(getByTestId('loader')).toBeInTheDocument()
})
it('should render the success message if state is success', async () => {
const message = 'Success Message'
const { getByText } = render(
<FormState state='success' message={message} />
)
expect(getByText(message)).toBeInTheDocument()
})
it('should render the error message if state is error', async () => {
const { getByTestId } = render(<FormState state='error' message='Error Message' />)
expect(getByTestId('error')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Success } from '../Success'
describe('<Success />', () => {
it('should render', async () => {
const { getByTestId } = render(<Success />)
expect(getByTestId('success')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,49 @@
import useTranslation from 'next-translate/useTranslation'
import { Divider } from 'components/design/Divider'
import { Header } from 'components/Header'
import { AuthenticationForm } from 'components/Authentication/AuthenticationForm'
import { AuthenticationSocialMedia } from 'components/Authentication/AuthenticationSocialMedia'
import { Container } from 'components/design/Container'
import { HandleSubmitCallback } from 'hooks/useForm'
export interface AuthenticationProps {
mode: 'signup' | 'signin'
onSubmit: HandleSubmitCallback
}
export const Authentication: React.FC<AuthenticationProps> = (props) => {
const { mode, onSubmit } = props
const { t } = useTranslation()
return (
<>
<Header />
<Container className='container-authentication'>
<AuthenticationSocialMedia />
<div className='divider'>
<Divider content={t('authentication:or')} />
</div>
<AuthenticationForm onSubmit={onSubmit} mode={mode} />
</Container>
<style jsx>
{`
@media (max-height: 700px) {
:global(.container-authentication) {
height: auto !important;
}
}
@media (max-width: 600px) {
.divider {
margin: 20px 0 !important;
}
}
.divider {
margin: 40px 0;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,22 @@
import { Emoji as EmojiMart } from 'emoji-mart'
import { emojiSet } from './emojiPlugin'
export interface EmojiProps {
value: string
size: number
}
export const Emoji: React.FC<EmojiProps> = (props) => {
const { value, size } = props
return (
<EmojiMart
set={emojiSet}
emoji={value}
size={size}
tooltip
fallback={() => <>{value}</>}
/>
)
}

View File

@ -0,0 +1,28 @@
import 'emoji-mart/css/emoji-mart.css'
import { EmojiData, Picker } from 'emoji-mart'
import { useTheme } from 'contexts/Theme'
import { emojiSet } from './emojiPlugin'
export type EmojiPickerOnClick = (
emoji: EmojiData,
event: React.MouseEvent<HTMLElement, MouseEvent>
) => void
export interface EmojiPickerProps {
onClick: EmojiPickerOnClick
}
export const EmojiPicker: React.FC<EmojiPickerProps> = (props) => {
const { theme } = useTheme()
return (
<Picker
set={emojiSet}
theme={theme}
onClick={props.onClick}
showPreview={false}
showSkinTones={false}
/>
)
}

View File

@ -0,0 +1,63 @@
import visit from 'unist-util-visit'
import { Plugin, Transformer } from 'unified'
import { Node } from 'unist'
import { EmojiSet } from 'emoji-mart'
export const emojiSet: EmojiSet = 'twitter'
export const emojiRegex = /:\+1:|:-1:|:[\w-]+:/
export const isStringWithOnlyOneEmoji = (value: string): boolean => {
const result = emojiRegex.exec(value)
return result != null && result.input === result[0]
}
const extractText = (string: string, start: number, end: number): Node => {
const startLine = string.slice(0, start).split('\n')
const endLine = string.slice(0, end).split('\n')
return {
type: 'text',
value: string.slice(start, end),
position: {
start: {
line: startLine.length,
column: startLine[startLine.length - 1].length + 1
},
end: {
line: endLine.length,
column: endLine[endLine.length - 1].length + 1
}
}
}
}
export const emojiPlugin: Plugin = () => {
const transformer: Transformer = (tree) => {
visit(tree, 'text', (node, position, parent) => {
if (typeof node.value !== 'string') {
return
}
const definition: Node[] = []
let lastIndex = 0
const match = emojiRegex.exec(node.value)
if (match != null) {
const value = match[0]
if (match.index !== lastIndex) {
definition.push(extractText(node.value, lastIndex, match.index))
}
definition.push({ type: 'emoji', value })
lastIndex = match.index + value.length
if (lastIndex !== node.value.length) {
definition.push(extractText(node.value, lastIndex, node.value.length))
}
if (parent != null) {
const last = parent.children.slice(position + 1)
parent.children = parent.children.slice(0, position)
parent.children = parent.children.concat(definition)
parent.children = parent.children.concat(last)
}
}
})
}
return transformer
}

View File

@ -0,0 +1,3 @@
export * from './Emoji'
export * from './EmojiPicker'
export * from './emojiPlugin'

62
components/ErrorPage.tsx Normal file
View File

@ -0,0 +1,62 @@
import { Header } from 'components/Header'
interface ErrorPageProps {
message: string
statusCode: number
}
export const ErrorPage: React.FC<ErrorPageProps> = (props) => {
const { message, statusCode } = props
return (
<>
<Header />
<div className='container'>
<h1>{statusCode}</h1>
<div className='container-message'>
<h2>{message}</h2>
</div>
</div>
<style jsx global>
{`
#__next {
min-height: 100vh;
}
`}
</style>
<style jsx>
{`
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: calc(100vh - 110px);
}
h1 {
display: inline-block;
margin: 0;
font-size: 24px;
font-weight: 500;
vertical-align: top;
}
.container-message {
display: inline-block;
text-align: left;
line-height: 49px;
height: 49px;
vertical-align: middle;
}
.container-message > h2 {
font-size: 14px;
font-weight: normal;
line-height: inherit;
margin: 0;
padding: 0;
}
`}
</style>
</>
)
}

54
components/Head.tsx Normal file
View File

@ -0,0 +1,54 @@
import HeadTag from 'next/head'
import useTranslation from 'next-translate/useTranslation'
interface HeadProps {
title?: string
image?: string
description?: string
url?: string
}
export const Head: React.FC<HeadProps> = (props) => {
const { t } = useTranslation()
const {
title = 'Thream',
image = '/images/icons/96x96.png',
description = t('common:description'),
url = 'https://thream.divlo.fr/'
} = props
return (
<HeadTag>
<title>{title}</title>
<link rel='icon' type='image/png' href={image} />
{/* Meta Tag */}
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta name='description' content={description} />
<meta name='Language' content='en' />
<meta name='theme-color' content='#27B05E' />
{/* Open Graph Metadata */}
<meta property='og:title' content={title} />
<meta property='og:type' content='website' />
<meta property='og:url' content={url} />
<meta property='og:image' content={image} />
<meta property='og:description' content={description} />
<meta property='og:locale' content='en_EN' />
<meta property='og:site_name' content={title} />
{/* Twitter card Metadata */}
<meta name='twitter:card' content='summary' />
<meta name='twitter:description' content={description} />
<meta name='twitter:title' content={title} />
<meta name='twitter:image:src' content={image} />
{/* PWA Data */}
<link rel='manifest' href='/manifest.json' />
<meta name='apple-mobile-web-app-capable' content='yes' />
<meta name='mobile-web-app-capable' content='yes' />
<link rel='apple-touch-icon' href={image} />
</HeadTag>
)
}

View File

@ -0,0 +1,20 @@
import { useTheme } from 'contexts/Theme'
export const Arrow: React.FC = () => {
const { theme } = useTheme()
return (
<svg
width='12'
height='8'
viewBox='0 0 12 8'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<path
d='M9.8024 0.292969L5.61855 4.58597L1.43469 0.292969L0.0566406 1.70697L5.61855 7.41397L11.1805 1.70697L9.8024 0.292969Z'
fill={theme === 'dark' ? '#fff' : '#181818'}
/>
</svg>
)
}

View File

@ -0,0 +1,31 @@
import Image from 'next/image'
import { Language } from 'utils/authentication'
export interface LanguageFlagProps {
language: Language
}
export const LanguageFlag: React.FC<LanguageFlagProps> = (props) => {
const { language } = props
return (
<>
<Image
width={35}
height={35}
src={`/images/svg/languages/${language}.svg`}
alt={language}
/>
<p className='language-title'>{language.toUpperCase()}</p>
<style jsx>
{`
.language-title {
margin: 0 8px 0 10px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,105 @@
import { useEffect, useState } from 'react'
import useTranslation from 'next-translate/useTranslation'
import setLanguage from 'next-translate/setLanguage'
import { Arrow } from './Arrow'
import { languages, Language as LanguageType } from 'utils/authentication'
import { LanguageFlag } from './LanguageFlag'
export const Language: React.FC = () => {
const { lang: currentLanguage } = useTranslation()
const [hiddenMenu, setHiddenMenu] = useState(true)
useEffect(() => {
if (!hiddenMenu) {
window.document.addEventListener('click', handleHiddenMenu)
} else {
window.document.removeEventListener('click', handleHiddenMenu)
}
return () => {
window.document.removeEventListener('click', handleHiddenMenu)
}
}, [hiddenMenu])
const handleLanguage = async (language: LanguageType): Promise<void> => {
await setLanguage(language)
handleHiddenMenu()
}
const handleHiddenMenu = (): void => {
setHiddenMenu(!hiddenMenu)
}
return (
<>
<div className='language-menu'>
<div className='selected-language' onClick={handleHiddenMenu}>
<LanguageFlag language={currentLanguage as LanguageType} />
<Arrow />
</div>
{!hiddenMenu && (
<ul>
{languages.map((language, index) => {
if (language === currentLanguage) {
return null
}
return (
<li
key={index}
onClick={async () => await handleLanguage(language)}
>
<LanguageFlag language={language} />
</li>
)
})}
</ul>
)}
</div>
<style jsx>
{`
.language-menu {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
}
.selected-language {
display: flex;
align-items: center;
margin-right: 15px;
}
ul {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: absolute;
top: 60px;
width: 100px;
padding: 10px;
margin: 10px 15px 0 0px;
border-radius: 15%;
padding: 0;
box-shadow: 0px 1px 10px var(--color-shadow);
background-color: var(--color-background-primary);
z-index: 10;
}
ul > li {
list-style: none;
display: flex;
align-items: center;
justify-content: center;
height: 50px;
width: 100%;
}
ul > li:hover {
background-color: rgba(79, 84, 92, 0.16);
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,114 @@
import { useTheme } from 'contexts/Theme'
export const SwitchTheme: React.FC = () => {
const { handleToggleTheme, theme } = useTheme()
return (
<>
<div className='toggle-button' onClick={handleToggleTheme}>
<div className='toggle-theme-button'>
<div className='toggle-track'>
<div className='toggle-track-check'>
<span className='toggle_Dark'>🌜</span>
</div>
<div className='toggle-track-x'>
<span className='toggle_Light'>🌞</span>
</div>
</div>
<div className='toggle-thumb' />
<input
type='checkbox'
aria-label='Dark mode toggle'
className='toggle-screenreader-only'
defaultChecked
/>
</div>
</div>
<style jsx>
{`
.toggle-button {
display: flex;
align-items: center;
}
.toggle-theme-button {
touch-action: pan-x;
display: inline-block;
position: relative;
cursor: pointer;
background-color: transparent;
border: 0;
padding: 0;
user-select: none;
}
.toggle-track {
width: 50px;
height: 24px;
padding: 0;
border-radius: 30px;
background-color: #4d4d4d;
transition: all 0.2s ease;
color: #fff;
}
.toggle-track-check {
position: absolute;
width: 14px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
left: 8px;
opacity: ${theme === 'dark' ? 1 : 0};
transition: opacity 0.25s ease;
}
.toggle-track-x {
position: absolute;
width: 10px;
height: 10px;
top: 0;
bottom: 0;
margin-top: auto;
margin-bottom: auto;
line-height: 0;
right: 10px;
opacity: ${theme === 'dark' ? 0 : 1};
}
.toggle_Dark,
.toggle_Light {
align-items: center;
display: flex;
height: 10px;
justify-content: center;
position: relative;
width: 10px;
}
.toggle-thumb {
position: absolute;
left: ${theme === 'dark' ? '27px' : '0px'};
width: 22px;
height: 22px;
border: 1px solid #4d4d4d;
border-radius: 50%;
background-color: #fafafa;
box-sizing: border-box;
transition: all 0.25s ease;
top: 1px;
color: #fff;
}
.toggle-screenreader-only {
border: 0;
clip: rect(0 0 0 0);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,94 @@
import Link from 'next/link'
import Image from 'next/image'
import { Language } from './Language'
import { SwitchTheme } from './SwitchTheme'
export const Header: React.FC = () => {
return (
<>
<header className='header'>
<div className='container'>
<nav className='navbar navbar-fixed-top'>
<Link href='/'>
<a className='navbar__brand-link'>
<div className='navbar__brand'>
<Image
width={60}
height={60}
src='/images/icons/Thream.png'
alt='Thream'
/>
<strong className='navbar__brand-title'>Thream</strong>
</div>
</a>
</Link>
<div className='navbar__buttons'>
<Language />
<SwitchTheme />
</div>
</nav>
</div>
</header>
<style jsx global>
{`
body {
padding: 0 32px;
}
@media (max-width: 404px) {
body {
padding: 0;
}
}
`}
</style>
<style jsx>
{`
.header {
margin-top: 20px;
}
.container {
max-width: 1280px;
width: 100%;
margin: auto;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
}
.navbar-fixed-top {
position: sticky;
top: 0;
z-index: 200;
}
.navbar__brand-link {
color: var(--color-secondary);
text-decoration: none;
font-size: 16px;
}
.navbar__brand {
display: flex;
align-items: center;
justify-content: space-between;
}
.navbar__brand-title {
font-weight: 400;
margin-left: 10px;
}
.navbar__buttons {
display: flex;
justify-content: space-between;
}
@media (max-width: 320px) {
.navbar__brand-title {
display: none;
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,114 @@
import { useEffect, useState } from 'react'
import prettyBytes from 'pretty-bytes'
import { useAuthentication } from 'utils/authentication'
import { MessageContentProps } from '.'
import { Loader } from 'components/design/Loader'
import { IconButton } from 'components/design/IconButton'
export interface FileData {
blob: Blob
url: string
}
export const MessageFile: React.FC<MessageContentProps> = (props) => {
const { authentication } = useAuthentication()
const [file, setFile] = useState<FileData | null>(null)
useEffect(() => {
const fetchData = async (): Promise<void> => {
const { data } = await authentication.api.get(props.value, {
responseType: 'blob'
})
const fileURL = URL.createObjectURL(data)
setFile({ blob: data, url: fileURL })
}
fetchData().catch(() => {})
}, [])
if (file == null) {
return <Loader />
}
if (props.mimetype.startsWith('image/')) {
return (
<>
<a href={file.url} target='_blank' rel='noreferrer'>
<img src={file.url} />
</a>
<style jsx>
{`
img {
max-width: 30vw;
max-height: 30vw;
}
`}
</style>
</>
)
}
if (props.mimetype.startsWith('audio/')) {
return (
<audio controls>
<source src={file.url} type={props.mimetype} />
</audio>
)
}
if (props.mimetype.startsWith('video/')) {
return (
<>
<video controls>
<source src={file.url} type={props.mimetype} />
</video>
<style jsx>
{`
video {
max-width: 250px;
max-height: 250px;
}
`}
</style>
</>
)
}
return (
<>
<div className='message-file'>
<div className='file-informations'>
<div className='file-icon'>
<img src='/images/svg/icons/file.svg' alt='file' />
</div>
<div className='file-title'>
<div className='file-name'>{file.blob.type}</div>
<div className='file-size'>{prettyBytes(file.blob.size)}</div>
</div>
</div>
<div className='download-button'>
<a href={file.url} download>
<IconButton icon='download' />
</a>
</div>
</div>
<style jsx>
{`
.message-file {
display: flex;
justify-content: space-between;
}
.file-informations {
display: flex;
}
.file-title {
margin-left: 10px;
}
.file-size {
color: var(--color-tertiary);
margin-top: 5px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,62 @@
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import gfm from 'remark-gfm'
import Tex from '@matejmazur/react-katex'
import math from 'remark-math'
import 'katex/dist/katex.min.css'
import { Emoji, emojiPlugin, isStringWithOnlyOneEmoji } from 'components/Emoji'
export interface MessageTextProps {
value: string
}
export const MessageText: React.FC<MessageTextProps> = (props) => {
const isMessageWithOnlyOneEmoji = useMemo(() => {
return isStringWithOnlyOneEmoji(props.value)
}, [props.value])
if (isMessageWithOnlyOneEmoji) {
return (
<div className='message-content'>
<p>
<Emoji value={props.value} size={40} />
</p>
</div>
)
}
return (
<>
<ReactMarkdown
disallowedTypes={['heading', 'table']}
unwrapDisallowed
plugins={[[gfm], [emojiPlugin], [math]]}
linkTarget='_blank'
renderers={{
inlineMath: ({ value }) => <Tex math={value} />,
math: ({ value }) => <Tex block math={value} />,
emoji: ({ value }) => {
return <Emoji value={value} size={20} />
}
}}
>
{props.value}
</ReactMarkdown>
<style jsx global>
{`
.message-content p {
margin: 0;
line-height: 30px;
white-space: pre-wrap;
}
.message-content .katex,
.message-content .katex-display {
text-align: initial;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,40 @@
import { Loader } from 'components/design/Loader'
import { MessageType } from 'contexts/Messages'
import { MessageFile } from './MessageFile'
import { MessageText } from './MessageText'
export interface MessageContentProps {
value: string
type: MessageType
mimetype: string
}
export const MessageContent: React.FC<MessageContentProps> = (props) => {
return (
<>
<div className='message-content'>
{props.type === 'text' ? (
<MessageText value={props.value} />
) : props.type === 'file' ? (
<MessageFile {...props} />
) : (
<Loader />
)}
</div>
<style jsx>
{`
.message-content {
font-family: 'Roboto', 'Arial', 'sans-serif';
font-size: 16px;
font-weight: 400;
position: relative;
margin-left: -75px;
padding-left: 75px;
overflow: hidden;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,48 @@
import date from 'date-and-time'
import { User } from 'utils/authentication'
export interface MessageHeaderProps {
user: User
createdAt: string
}
export const MessageHeader: React.FC<MessageHeaderProps> = (props) => {
return (
<>
<h2 className='message-header'>
<span className='username'>{props.user.name}</span>
<span className='date'>
{date.format(new Date(props.createdAt), 'DD/MM/YYYY - HH:mm:ss')}
</span>
</h2>
<style jsx>
{`
.message-header {
position: relative;
overflow: hidden;
display: block;
position: relative;
line-height: 1.375rem;
min-height: 1.375rem;
margin-bottom: 12px;
}
.username {
font-family: 'Poppins', 'Arial', 'sans-serif';
font-size: 16px;
font-weight: 600;
color: var(--color-secondary);
margin-right: 0.25rem;
}
.date {
font-family: 'Poppins', 'Arial', 'sans-serif';
font-size: 14px;
font-weight: 400;
margin-left: 1em;
color: var(--color-tertiary);
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,34 @@
import { Avatar } from 'components/design/Avatar'
import { API_URL } from 'utils/api'
import { User } from 'utils/authentication'
export interface UserAvatarProps {
user: User
}
export const UserAvatar: React.FC<UserAvatarProps> = (props) => {
return (
<>
<span className='user-avatar'>
<Avatar
src={`${API_URL}${props.user.logo}`}
alt={props.user.name}
width={50}
height={50}
/>
</span>
<style jsx>
{`
.user-avatar {
cursor: pointer;
position: absolute;
flex: 0 0 auto;
left: 12px;
overflow: hidden;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,40 @@
import { memo } from 'react'
import { MessageContent } from './MessageContent'
import { MessageHeader } from './MessageHeader'
import { UserAvatar } from './UserAvatar'
import { Message as MessageProps } from 'contexts/Messages'
export const Message: React.FunctionComponent<MessageProps> = memo((props) => {
return (
<>
<div className='message'>
<UserAvatar user={props.user} />
<MessageHeader createdAt={props.createdAt} user={props.user} />
<MessageContent
value={props.value}
type={props.type}
mimetype={props.mimetype}
/>
</div>
<style jsx>
{`
.message:hover {
background-color: var(--color-background-tertiary);
}
.message {
transition: background-color 0.15s ease-in-out;
margin-top: 2.3rem;
min-height: 2.75rem;
padding-left: 72px;
position: relative;
word-wrap: break-word;
flex: 0 0 auto;
position: relative;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,58 @@
import { useEffect } from 'react'
import InfiniteScroll from 'react-infinite-scroll-component'
import { Message } from './Message'
import { Loader } from 'components/design/Loader'
import { useMessages } from 'contexts/Messages'
import { Emoji } from 'emoji-mart'
import { emojiSet } from 'components/Emoji'
export const Messages: React.FC = () => {
const { messages, nextPage } = useMessages()
useEffect(() => {
window.scrollTo(0, document.body.scrollHeight)
}, [])
if (messages.rows.length === 0) {
return (
<div id='messages'>
<p>
Nothing to show here!{' '}
<Emoji set={emojiSet} emoji=':ghost:' size={20} />
</p>
<p>Start chatting to kill this Ghost!</p>
</div>
)
}
return (
<>
<div id='messages'>
<InfiniteScroll
dataLength={messages.rows.length}
next={nextPage}
inverse
scrollableTarget='messages'
hasMore={messages.hasMore}
loader={<Loader />}
>
{messages.rows.map((message) => {
return <Message key={message.id} {...message} />
})}
</InfiniteScroll>
</div>
<style jsx>
{`
#messages {
overflow-y: scroll;
display: flex;
flex-direction: column-reverse;
height: 800px;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react'
import TextareaAutosize from 'react-textarea-autosize'
import { useAuthentication } from 'utils/authentication'
import { IconButton } from 'components/design/IconButton'
import { MessageData } from 'contexts/Messages'
import { EmojiPicker, EmojiPickerOnClick } from 'components/Emoji'
const defaultMessageData: MessageData = { type: 'text', value: '' }
export interface SendMessageProps {
channelId: string
}
export const SendMessage: React.FC<SendMessageProps> = (props) => {
const { authentication } = useAuthentication()
const [messageData, setMessageData] = useState<MessageData>(
defaultMessageData
)
const [isVisibleEmojiPicker, setIsVisibleEmojiPicker] = useState(false)
const inputFile = useRef<HTMLInputElement>(null)
useEffect(() => {
window.scrollTo(0, document.body.scrollHeight)
}, [isVisibleEmojiPicker])
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = async (
event
) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
await sendMessage(messageData)
}
}
const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault()
await sendMessage(messageData)
}
const handleChange: React.ChangeEventHandler<HTMLTextAreaElement> = (
event
) => {
setMessageData({
value: event.target.value,
type: 'text'
})
}
const handleVisibleEmojiPicker = (): void => {
setIsVisibleEmojiPicker((isVisible) => !isVisible)
}
const handleEmojiPicker: EmojiPickerOnClick = (emoji) => {
const emojiColons = emoji.colons ?? ''
setMessageData((message) => {
return {
value: message.value + emojiColons,
type: 'text'
}
})
handleVisibleEmojiPicker()
}
const handleUploadFile = (): void => {
inputFile.current?.click()
}
const handleSubmitFile = async (): Promise<void> => {
if (
inputFile.current?.files != null &&
inputFile.current?.files?.length > 0
) {
const file = inputFile.current.files[0]
const formData = new FormData()
formData.append('type', 'file')
formData.append('file', file)
await authentication.api.post(
`/channels/${props.channelId}/messages`,
formData
)
setMessageData(defaultMessageData)
}
}
const sendMessage = async (messageData: MessageData): Promise<void> => {
const isEmptyMessage = messageData.value.length <= 0
if (!isEmptyMessage) {
await authentication.api.post(`/channels/${props.channelId}/messages`, {
value: messageData.value,
type: messageData.type
})
setMessageData(defaultMessageData)
}
}
return (
<>
{isVisibleEmojiPicker && <EmojiPicker onClick={handleEmojiPicker} />}
<form onSubmit={handleSubmit}>
<div className='send-message'>
<div className='icons'>
<IconButton
type='button'
icon='emoji'
hasBackground
size={50}
id='emoji-picker-button'
onClick={handleVisibleEmojiPicker}
/>
<IconButton
type='button'
icon='add'
hasBackground
size={50}
style={{ marginLeft: 5 }}
onClick={handleUploadFile}
/>
<input
ref={inputFile}
type='file'
name='input-file'
id='input-file'
onChange={handleSubmitFile}
/>
</div>
<div className='message-content'>
<TextareaAutosize
name='message-value'
id='message-value'
wrap='soft'
placeholder='Write a message'
required
maxLength={50_000}
value={messageData.value}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
</div>
<IconButton type='submit' icon='send' hasBackground size={50} />
</div>
</form>
<style jsx global>
{`
.message-content textarea {
font-family: 'Roboto', 'Arial', 'sans-serif';
color: var(--color-secondary);
width: 100%;
border: none;
background-color: transparent;
resize: none;
outline: none;
line-height: 30px;
height: 30px;
padding: 0 10px;
margin: 0;
white-space: pre-wrap;
}
`}
</style>
<style jsx>
{`
.send-message {
display: flex;
justify-content: space-between;
background-color: var(--color-background-tertiary);
padding: 10px;
margin-top: 20px;
border-radius: 2%;
}
#input-file {
display: none;
}
.icons {
display: flex;
}
.message-content {
width: 100%;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,15 @@
import { render } from '@testing-library/react'
import { ErrorPage } from '../ErrorPage'
describe('<ErrorPage />', () => {
it('should render with message and statusCode', async () => {
const message = 'Error'
const statusCode = 404
const { getByText } = render(
<ErrorPage statusCode={statusCode} message={message} />
)
expect(getByText(message)).toBeInTheDocument()
expect(getByText(statusCode)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,17 @@
import Image, { ImageProps } from 'next/image'
export const Avatar: React.FC<ImageProps> = (props) => {
return (
<>
<Image {...props} className='avatar-image' />
<style jsx>
{`
:global(.avatar-image) {
border-radius: 50%;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,40 @@
import { forwardRef } from 'react'
interface ButtonProps extends React.ComponentPropsWithRef<'button'> {}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(props, ref) => {
const { children, ...rest } = props
return (
<>
<button ref={ref} {...rest} className='button'>
{children}
</button>
<style jsx>{`
.button {
cursor: pointer;
font-size: var(--default-font-size);
font-weight: 400;
letter-spacing: 0.8px;
padding: 1rem 2rem;
transform: translateY(-3px);
background-color: transparent;
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.3s ease-in;
color: var(--color-primary);
outline: 0;
margin: 0;
}
.button:hover {
background-color: var(--color-primary);
color: #fff;
}
`}
</style>
</>
)
}
)

View File

@ -0,0 +1,24 @@
interface ContainerProps extends React.ComponentPropsWithRef<'div'> {}
export const Container: React.FC<ContainerProps> = (props) => {
const { children, className } = props
return (
<>
<div className={`container ${className ?? ''}`}>
{children}
</div>
<style jsx>
{`
.container {
height: calc(100vh - 110px);
display: flex;
flex-direction: column;
justify-content: center;
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,38 @@
interface DividerProps {
content: string
}
export const Divider: React.FC<DividerProps> = (props) => {
const { content } = props
return (
<>
<div className='text-divider'>{content}</div>
<style jsx>
{`
.text-divider {
--text-divider-gap: 1rem;
--color-divider: #414141;
display: flex;
align-items: center;
letter-spacing: 0.1em;
&::before,
&::after {
content: '';
height: 1px;
background-color: var(--color-divider);
flex-grow: 1;
}
&::before {
margin-right: var(--text-divider-gap);
}
&::after {
margin-left: var(--text-divider-gap);
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,65 @@
import { forwardRef, useMemo } from 'react'
export const icons = [
'add',
'delete',
'edit',
'emoji',
'send',
'settings',
'more',
'download'
] as const
export type Icon = typeof icons[number]
interface IconButtonProps extends React.ComponentPropsWithRef<'button'> {
icon: Icon
hasBackground?: boolean
size?: number
}
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
const { icon, hasBackground = false, size = 60, ...rest } = props
const imageSize = useMemo(() => {
return size / 2.6
}, [size])
return (
<>
<button ref={ref} className='button' {...rest}>
<img src={`/images/svg/icons/${icon}.svg`} alt={icon} />
</button>
<style jsx>
{`
.button {
background: ${hasBackground
? 'var(--color-background-secondary)'
: 'none'};
border-radius: ${hasBackground ? '50%' : '0'};
width: ${hasBackground ? `${size}px` : '100%'};
height: ${hasBackground ? `${size}px` : '100%'};
border: none;
outline: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.button:hover {
opacity: 0.9;
}
.button > img {
width: ${imageSize}px;
height: ${imageSize}px;
display: block;
}
`}
</style>
</>
)
}
)

128
components/design/Input.tsx Normal file
View File

@ -0,0 +1,128 @@
import { forwardRef, useState } from 'react'
import Link from 'next/link'
import { ErrorMessage } from '../Authentication/ErrorMessage'
import useTranslation from 'next-translate/useTranslation'
interface InputProps extends React.ComponentPropsWithRef<'input'> {
label: string
errors?: string[]
showForgotPassword?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const {
label,
name,
type = 'text',
errors = [],
showForgotPassword = false,
...rest
} = props
const { t } = useTranslation()
const [inputType, setInputType] = useState(type)
const handlePassword = (): void => {
const oppositeType = inputType === 'password' ? 'text' : 'password'
setInputType(oppositeType)
}
return (
<>
<div className='container'>
<div className='input-with-label'>
<div className='label-container'>
<label className='label' htmlFor={name}>
{label}
</label>
{type === 'password' && showForgotPassword ? (
<Link href='/authentication/forgot-password'>
<a className='label-forgot-password'>
{t('authentication:forgot-password')}
</a>
</Link>
) : null}
</div>
<div className='input-container'>
<input
data-testid='input'
className='input'
{...rest}
ref={ref}
id={name}
name={name}
type={inputType}
/>
{type === 'password' && (
<div
data-testid='password-eye'
onClick={handlePassword}
className='password-eye'
/>
)}
<ErrorMessage errors={errors} />
</div>
</div>
</div>
<style jsx>
{`
.container {
margin-bottom: 20px;
}
.input-container {
margin-top: 0;
position: relative;
}
.input-with-label {
display: flex;
flex-direction: column;
}
.label-container {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.label-forgot-password {
font-size: 12px;
}
.label {
color: var(--color-secondary);
font-size: 16px;
font-family: 'Poppins', 'Arial', 'sans-serif';
padding-left: 3px;
}
.input {
background-color: #f1f1f1;
font-family: 'Roboto', 'Arial', 'sans-serif';
width: 100%;
height: 44px;
line-height: 44px;
padding: 0 20px;
color: #2a2a2a;
border: 0;
box-shadow: ${errors.length >= 1
? '0 0 0 2px var(--color-error)'
: 'none'};
border-radius: 10px;
}
.input:focus {
outline: 0;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px var(--color-primary);
}
.password-eye {
position: absolute;
top: 12px;
right: 16px;
z-index: 1;
width: 20px;
height: 20px;
background-image: url(/images/svg/icons/input/${inputType}.svg);
background-size: cover;
cursor: pointer;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,80 @@
export interface LoaderProps {
width?: number
height?: number
}
export const Loader: React.FC<LoaderProps> = (props) => {
const { width = 50, height = 50 } = props
return (
<>
<div data-testid='progress-spinner' className='progress-spinner'>
<svg className='progress-spinner-svg' viewBox='25 25 50 50'>
<circle
className='progress-spinner-circle'
cx='50'
cy='50'
r='20'
fill='none'
strokeWidth='2'
strokeMiterlimit='10'
/>
</svg>
</div>
<style jsx>{`
.progress-spinner {
position: relative;
margin: 0 auto;
width: ${width}px;
height: ${height}px;
display: inline-block;
}
.progress-spinner::before {
content: '';
display: block;
padding-top: 100%;
}
.progress-spinner-svg {
animation: progress-spinner-rotate 2s linear infinite;
height: 100%;
transform-origin: center center;
width: 100%;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
}
.progress-spinner-circle {
stroke-dasharray: 89, 200;
stroke-dashoffset: 0;
stroke: var(--color-primary);
animation: progress-spinner-dash 1.5s ease-in-out infinite;
stroke-linecap: round;
}
@keyframes progress-spinner-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes progress-spinner-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -35px;
}
100% {
stroke-dasharray: 89, 200;
stroke-dashoffset: -124px;
}
}
`}
</style>
</>
)
}

View File

@ -0,0 +1,80 @@
import { forwardRef, useMemo } from 'react'
import Image from 'next/image'
export type SocialMedia = 'Discord' | 'GitHub' | 'Google'
type SocialMediaColors = {
[key in SocialMedia]: string
}
interface SocialMediaButtonProps extends React.ComponentPropsWithRef<'button'> {
socialMedia: SocialMedia
}
const socialMediaColors: SocialMediaColors = {
Discord: '#7289DA',
GitHub: '#24292E',
Google: '#FCFCFC'
}
export const SocialMediaButton = forwardRef<
HTMLButtonElement,
SocialMediaButtonProps
>((props, ref) => {
const { socialMedia, className, ...rest } = props
const socialMediaColor = useMemo(() => {
return socialMediaColors[socialMedia]
}, [socialMedia])
return (
<>
<button
data-testid='button'
ref={ref}
{...rest}
className={`button ${className ?? ''}`}
>
<Image
width={20}
height={20}
src={`/images/svg/web/${socialMedia}.svg`}
alt={socialMedia}
/>
<span className='social-media'>{socialMedia}</span>
</button>
<style jsx>
{`
.button {
display: inline-flex;
align-items: center;
outline: none;
font-size: var(--default-font-size);
font-family: 'Roboto', 'Arial', 'sans-serif';
margin: 0;
cursor: pointer;
letter-spacing: 0.8px;
padding: 0.9rem 2.4rem;
border: 1px solid transparent;
border-radius: 10px;
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.2);
background: ${socialMediaColor};
color: ${socialMedia === 'Google' ? '#000' : '#fff'};
transition: all 0.3s ease-out;
}
.button:hover {
opacity: 0.85;
transition: all 0.3s ease-in;
}
.button:before {
display: none;
}
.social-media {
margin-left: 0.7rem;
}
`}
</style>
</>
)
})

View File

@ -0,0 +1,106 @@
import { forwardRef } from 'react'
interface TooltipProps extends React.ComponentPropsWithRef<'div'> {
content: string
direction?: 'top' | 'bottom' | 'right' | 'left'
}
export const Tooltip = forwardRef<HTMLDivElement, TooltipProps>(
(props, ref) => {
const { direction = 'bottom', children, content, ...rest } = props
return (
<>
<div ref={ref} {...rest} className='tooltip-wrapper'>
{children}
<div className={`tooltip ${direction}`}>{content}</div>
</div>
<style jsx>
{`
.tooltip-wrapper {
--tooltip-text-color: white;
--tooltip-background-color: black;
--tooltip-margin: 50px;
--tooltip-arrow-size: 6px;
}
.tooltip-wrapper {
display: inline-block;
}
.tooltip {
position: absolute;
border-radius: 6px;
left: 100%;
top: 50%;
transform: translateY(-50%);
padding: 10px;
color: var(--tooltip-text-color);
background: var(--tooltip-background-color);
font-size: 15px;
font-family: sans-serif;
line-height: 1;
z-index: 100;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease-in;
}
.tooltip-wrapper ~ .tooltip-wrapper:hover .tooltip,
.tooltip-wrapper:first-child:hover .tooltip {
opacity: 1;
visibility: visible;
transition: all 0.35s ease-out;
margin: 0;
}
.tooltip::before {
content: ' ';
left: 50%;
border: solid transparent;
height: 0;
width: 0;
position: absolute;
pointer-events: none;
border-width: var(--tooltip-arrow-size);
margin-left: calc(var(--tooltip-arrow-size) * -1);
}
.tooltip.top {
top: calc(var(--tooltip-margin) * -1);
}
.tooltip.top::before {
top: 100%;
border-top-color: var(--tooltip-background-color);
}
.tooltip.right {
left: calc(100% + var(--tooltip-margin));
}
.tooltip.right::before {
left: calc(var(--tooltip-arrow-size) * -1);
border-right-color: var(--tooltip-background-color);
}
.tooltip.bottom {
bottom: calc(var(--tooltip-margin) * -1);
}
.tooltip.bottom::before {
bottom: 100%;
border-bottom-color: var(--tooltip-background-color);
}
.tooltip.left {
left: auto;
right: calc(100% + var(--tooltip-margin));
top: 50%;
transform: translateX(0) translateY(-50%);
}
.tooltip.left::before {
left: auto;
right: calc(var(--tooltip-arrow-size) * -2);
top: 50%;
transform: translateX(0) translateY(-50%);
border-left-color: var(--tooltip-background-color);
}
`}
</style>
</>
)
}
)

View File

@ -0,0 +1,13 @@
import { render } from '@testing-library/react'
import { Avatar } from '../Avatar'
describe('<Avatar />', () => {
it('should render', async () => {
const altAttribute = 'avatar'
const { getByAltText } = render(
<Avatar width={50} height={50} src='/avatar.png' alt={altAttribute} />
)
expect(getByAltText(altAttribute)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Button } from '../Button'
describe('<Button />', () => {
it('should render', async () => {
const { getByText } = render(<Button>Submit</Button>)
expect(getByText('Submit')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react'
import { Container } from '../Container'
describe('<Container />', () => {
it('should render', async () => {
const { getByText } = render(<Container>Content</Container>)
expect(getByText('Content')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Divider } from '../Divider'
describe('<Divider />', () => {
it('should render with the content', async () => {
const content = 'divider'
const { getByText } = render(<Divider content={content} />)
expect(getByText(content)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,13 @@
import { render } from '@testing-library/react'
import { Icon, IconButton } from '../IconButton'
describe('<IconButton />', () => {
it('should render with the icon', async () => {
const icon: Icon = 'add'
const { getByAltText } = render(<IconButton icon={icon} />)
const iconImage = getByAltText(icon)
expect(iconImage).toBeInTheDocument()
expect(iconImage).toHaveAttribute('src', `/images/svg/icons/${icon}.svg`)
})
})

View File

@ -0,0 +1,26 @@
import { render, fireEvent } from '@testing-library/react'
import { Input } from '../Input'
describe('<Input />', () => {
it('should render the label', async () => {
const labelContent = 'label content'
const { getByText } = render(<Input label={labelContent} />)
expect(getByText(labelContent)).toBeInTheDocument()
})
it('should not render the eye icon if the input is not of type "password"', async () => {
const { queryByTestId } = render(<Input type='text' label='content' />)
const passwordEye = queryByTestId('password-eye')
expect(passwordEye).not.toBeInTheDocument()
})
it('should handlePassword with eye icon', async () => {
const { findByTestId } = render(<Input type='password' label='content' />)
const passwordEye = await findByTestId('password-eye')
const input = await findByTestId('input')
expect(input).toHaveAttribute('type', 'password')
fireEvent.click(passwordEye)
expect(input).toHaveAttribute('type', 'text')
})
})

View File

@ -0,0 +1,20 @@
import { render } from '@testing-library/react'
import { Loader } from '../Loader'
describe('<Loader />', () => {
it('should render with correct width and height', async () => {
const size = 20
const { findByTestId } = render(<Loader width={size} height={size} />)
const progressSpinner = await findByTestId('progress-spinner')
expect(progressSpinner).toHaveStyle(`width: ${size}px`)
expect(progressSpinner).toHaveStyle(`height: ${size}px`)
})
it('should render with default width and height', async () => {
const { findByTestId } = render(<Loader />)
const progressSpinner = await findByTestId('progress-spinner')
expect(progressSpinner).toHaveStyle('width: 50px')
expect(progressSpinner).toHaveStyle('height: 50px')
})
})

View File

@ -0,0 +1,23 @@
import { render } from '@testing-library/react'
import { SocialMedia, SocialMediaButton } from '../SocialMediaButton'
describe('<SocialMediaButton />', () => {
it('should render the social media', async () => {
const socialMedia: SocialMedia = 'Discord'
const { findByAltText } = render(
<SocialMediaButton socialMedia={socialMedia} />
)
const socialMediaButton = await findByAltText(socialMedia)
expect(socialMediaButton).toBeInTheDocument()
})
it('should render with a black text color with Google social media', async () => {
const socialMedia: SocialMedia = 'Google'
const { findByTestId } = render(
<SocialMediaButton socialMedia={socialMedia} />
)
const button = await findByTestId('button')
expect(button).toHaveStyle('color: #000')
})
})

View File

@ -0,0 +1,11 @@
import { render } from '@testing-library/react'
import { Tooltip } from '../Tooltip'
describe('<Tooltip />', () => {
it('should render with content', async () => {
const content = 'tooltip content'
const { getByText } = render(<Tooltip content={content} />)
expect(getByText(content)).toBeInTheDocument()
})
})