feat: add guilds and channels CRUD (#14)

This commit is contained in:
Divlo
2022-03-05 18:22:30 +01:00
committed by GitHub
parent 9f56a10305
commit 780788d682
50 changed files with 6459 additions and 9039 deletions

View File

@ -14,10 +14,17 @@ import { Members } from './Members'
import { useAuthentication } from '../../tools/authentication'
import { API_URL } from '../../tools/api'
export interface GuildsChannelsPath {
guildId: number
export interface ChannelsPath {
channelId: number
}
export interface GuildsPath {
guildId: number
}
export interface GuildsChannelsPath extends GuildsPath, ChannelsPath {}
const isGuildsChannelsPath = (path: any): path is GuildsChannelsPath => {
return path.guildId !== undefined && path.channelId !== undefined
}
export type ApplicationPath =
| '/application'
@ -26,6 +33,7 @@ export type ApplicationPath =
| `/application/users/${number}`
| `/application/users/settings`
| GuildsChannelsPath
| GuildsPath
export interface ApplicationProps {
path: ApplicationPath
@ -216,7 +224,7 @@ export const Application: React.FC<ApplicationProps> = (props) => {
{children}
</div>
{typeof path !== 'string' && (
{isGuildsChannelsPath(path) && (
<Sidebar
direction='right'
visible={visibleSidebars.right}

View File

@ -0,0 +1,128 @@
import { useRouter } from 'next/router'
import { useState } from 'react'
import { Form } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { FormState } from '../../design/FormState'
import { useGuildMember } from '../../../contexts/GuildMember'
import { Input } from '../../design/Input'
import { Button } from '../../design/Button'
import { useAuthentication } from '../../../tools/authentication'
import {
Channel,
channelSchema,
ChannelWithDefaultChannelId
} from '../../../models/Channel'
export interface ChannelSettingsProps {
channel: Channel
}
export const ChannelSettings: React.FC<ChannelSettingsProps> = (props) => {
const { t } = useTranslation()
const router = useRouter()
const { authentication } = useAuthentication()
const { guild } = useGuildMember()
const { channel } = props
const [inputValues, setInputValues] = useState({
name: channel.name
})
const {
fetchState,
message,
errors,
getErrorTranslation,
handleSubmit,
setFetchState,
setMessageTranslationKey
} = useForm({
validateSchema: {
name: channelSchema.name
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const onSubmit: HandleSubmitCallback = async (formData) => {
try {
await authentication.api.put(`/channels/${channel.id}`, formData)
setInputValues(formData as any)
await router.push(`/application/${guild.id}/${channel.id}`)
return null
} catch (error) {
return {
type: 'error',
value: 'errors:server-error'
}
}
}
const onChange: React.ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (event) => {
setInputValues((oldInputValues) => {
return {
...oldInputValues,
[event.target.name]: event.target.value
}
})
}
const handleDelete = async (): Promise<void> => {
try {
const { data } =
await authentication.api.delete<ChannelWithDefaultChannelId>(
`/channels/${channel.id}`
)
await router.push(`/application/${guild.id}/${data.defaultChannelId}`)
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
return (
<Form
onSubmit={handleSubmit(onSubmit)}
className='my-auto flex flex-col items-center justify-center py-12'
>
<div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className='mx-12 flex flex-col'>
<Input
name='name'
label={t('common:name')}
placeholder={t('common:name')}
className='!mt-0'
onChange={onChange}
value={inputValues.name}
error={getErrorTranslation(errors.name)}
data-cy='channel-name-input'
/>
</div>
</div>
</div>
<div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className='space-x-6'>
<Button type='submit' data-cy='button-save-channel-settings'>
Sauvegarder
</Button>
<Button
type='button'
color='red'
onClick={handleDelete}
data-cy='button-delete-channel-settings'
>
Supprimer
</Button>
</div>
<FormState state={fetchState} message={message} />
</div>
</Form>
)
}

View File

@ -0,0 +1 @@
export * from './ChannelSettings'

View File

@ -1,38 +1,55 @@
import Image from 'next/image'
import { useState } from 'react'
import { Loader } from '../../design/Loader'
export interface ConfirmGuildJoinProps {
className?: string
handleJoinGuild: () => void
handleYes: () => void | Promise<void>
handleNo: () => void | Promise<void>
}
export const ConfirmGuildJoin: React.FC<ConfirmGuildJoinProps> = (props) => {
const { className, handleJoinGuild } = props
const { className, handleYes, handleNo } = props
const [isLoading, setIsLoading] = useState(false)
const handleYesLoading = async (): Promise<void> => {
setIsLoading((isLoading) => !isLoading)
await handleYes()
}
return (
<div className={className}>
<Image
src='/images/svg/design/join-guild.svg'
alt='Joing Guild Illustration'
height={150}
width={150}
/>
<div className='mt-8 flex flex-col'>
<h1 className='mb-6 text-center text-xl'>Rejoindre la guild ?</h1>
<div className='flex gap-7'>
<button
className='rounded-3xl bg-success px-8 py-2 transition hover:opacity-50'
onClick={handleJoinGuild}
>
Oui
</button>
<button
className='rounded-3xl bg-error px-8 py-2 transition hover:opacity-50'
onClick={handleJoinGuild}
>
Non
</button>
</div>
</div>
{isLoading ? (
<Loader />
) : (
<>
<Image
src='/images/svg/design/join-guild.svg'
alt='Join Guild Illustration'
height={150}
width={150}
/>
<div className='mt-8 flex flex-col'>
<h1 className='mb-6 text-center text-xl'>Rejoindre la guild ?</h1>
<div className='flex gap-7'>
<button
className='rounded-3xl bg-success px-8 py-2 transition hover:opacity-50'
onClick={handleYesLoading}
>
Oui
</button>
<button
className='rounded-3xl bg-error px-8 py-2 transition hover:opacity-50'
onClick={handleNo}
>
Non
</button>
</div>
</div>
</>
)}
</div>
)
}

View File

@ -0,0 +1,67 @@
import { useRouter } from 'next/router'
import useTranslation from 'next-translate/useTranslation'
import { Form } from 'react-component-form'
import { useAuthentication } from '../../../tools/authentication'
import { HandleSubmitCallback, useForm } from '../../../hooks/useForm'
import { Input } from '../../design/Input'
import { Main } from '../../design/Main'
import { Button } from '../../design/Button'
import { FormState } from '../../design/FormState'
import { Channel, channelSchema } from '../../../models/Channel'
import { useGuildMember } from '../../../contexts/GuildMember'
export const CreateChannel: React.FC = () => {
const { t } = useTranslation()
const router = useRouter()
const { guild } = useGuildMember()
const { fetchState, message, errors, getErrorTranslation, handleSubmit } =
useForm({
validateSchema: {
name: channelSchema.name
},
resetOnSuccess: true
})
const { authentication } = useAuthentication()
const onSubmit: HandleSubmitCallback = async (formData) => {
try {
const { data: channel } = await authentication.api.post<Channel>(
`/guilds/${guild.id}/channels`,
formData
)
await router.push(`/application/${guild.id}/${channel.id}`)
return null
} catch (error) {
return {
type: 'error',
value: 'errors:server-error'
}
}
}
return (
<Main>
<Form className='w-4/6 max-w-xs' onSubmit={handleSubmit(onSubmit)}>
<Input
type='text'
placeholder={t('common:name')}
name='name'
label={t('common:name')}
error={getErrorTranslation(errors.name)}
data-cy='channel-name-input'
/>
<Button
className='mt-6 w-full'
type='submit'
data-cy='button-create-channel'
>
{t('application:create')}
</Button>
</Form>
<FormState id='message' state={fetchState} message={message} />
</Main>
)
}

View File

@ -0,0 +1 @@
export * from './CreateChannel'

View File

@ -1,3 +1,4 @@
import Link from 'next/link'
import { CogIcon, PlusIcon } from '@heroicons/react/solid'
import { useGuildMember } from '../../../contexts/GuildMember'
@ -13,7 +14,7 @@ export interface GuildLeftSidebarProps {
export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
const { path } = props
const { guild } = useGuildMember()
const { guild, member } = useGuildMember()
return (
<div className='mt-2 flex w-full flex-col justify-between'>
@ -26,12 +27,22 @@ export const GuildLeftSidebar: React.FC<GuildLeftSidebarProps> = (props) => {
<Channels path={path} />
<Divider />
<div className='mb-1 flex items-center justify-center space-x-6 p-2'>
<IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon />
</IconButton>
<IconButton className='h-7 w-7' title='Settings'>
<CogIcon />
</IconButton>
{member.isOwner && (
<Link href={`/application/${path.guildId}/channels/create`} passHref>
<a data-cy='link-add-channel'>
<IconButton className='h-10 w-10' title='Add a Channel'>
<PlusIcon />
</IconButton>
</a>
</Link>
)}
<Link href={`/application/${path.guildId}/settings`} passHref>
<a data-cy='link-settings-guild'>
<IconButton className='h-7 w-7' title='Settings'>
<CogIcon />
</IconButton>
</a>
</Link>
</div>
</div>
)

View File

@ -0,0 +1,201 @@
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { Type } from '@sinclair/typebox'
import { PhotographIcon } from '@heroicons/react/solid'
import { Form } from 'react-component-form'
import useTranslation from 'next-translate/useTranslation'
import { HandleSubmitCallback, useForm } from 'hooks/useForm'
import { guildSchema } from 'models/Guild'
import { FormState } from 'components/design/FormState'
import { API_URL } from '../../../tools/api'
import { useGuildMember } from '../../../contexts/GuildMember'
import { Textarea } from '../../design/Textarea'
import { Input } from '../../design/Input'
import { Button } from '../../design/Button'
import { useAuthentication } from '../../../tools/authentication'
export const GuildSettings: React.FC = () => {
const { t } = useTranslation()
const router = useRouter()
const { authentication } = useAuthentication()
const { guild, member } = useGuildMember()
const [inputValues, setInputValues] = useState({
name: guild.name,
description: guild.description
})
const {
fetchState,
message,
errors,
getErrorTranslation,
handleSubmit,
setFetchState,
setMessageTranslationKey
} = useForm({
validateSchema: {
name: guildSchema.name,
description: Type.Optional(guildSchema.description)
},
replaceEmptyStringToNull: true,
resetOnSuccess: false
})
const onSubmit: HandleSubmitCallback = async (formData) => {
try {
await authentication.api.put(`/guilds/${guild.id}`, formData)
setInputValues(formData as any)
return {
type: 'success',
value: 'common:name'
}
} catch (error) {
return {
type: 'error',
value: 'errors:server-error'
}
}
}
const onChange: React.ChangeEventHandler<
HTMLInputElement | HTMLTextAreaElement
> = (event) => {
setInputValues((oldInputValues) => {
return {
...oldInputValues,
[event.target.name]: event.target.value
}
})
}
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event
) => {
setFetchState('loading')
const files = event?.target?.files
if (files != null && files.length === 1) {
const file = files[0]
const formData = new FormData()
formData.append('icon', file)
try {
await authentication.api.put(`/guilds/${guild.id}/icon`, formData)
setFetchState('idle')
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
}
const handleDelete = async (): Promise<void> => {
try {
await authentication.api.delete(`/guilds/${guild.id}`)
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
const handleLeave = async (): Promise<void> => {
try {
await authentication.api.delete(`/guilds/${guild.id}/members/leave`)
await router.push('/application')
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')
}
}
return (
<Form
onSubmit={handleSubmit(onSubmit)}
className='my-auto flex flex-col items-center justify-center py-12'
>
{member.isOwner && (
<div className='flex w-full flex-col items-center justify-center sm:w-fit lg:flex-row'>
<div className=' flex w-full flex-wrap items-center justify-center px-6 sm:w-max'>
<div className='relative'>
<div className='absolute z-50 h-full w-full'>
<button className='relative flex h-full w-full items-center justify-center transition hover:scale-110'>
<input
type='file'
className='absolute h-full w-full cursor-pointer opacity-0'
onChange={handleFileChange}
/>
<PhotographIcon color='white' className='h-8 w-8' />
</button>
</div>
<div className='flex items-center justify-center rounded-full bg-black shadow-xl'>
<Image
className='rounded-full opacity-50'
src={
guild.icon == null
? '/images/data/guild-default.png'
: API_URL + guild.icon
}
alt='Profil Picture'
draggable='false'
height={125}
width={125}
/>
</div>
</div>
<div className='mx-12 flex flex-col'>
<Input
name='name'
label={t('common:name')}
placeholder={t('common:name')}
className='!mt-0'
onChange={onChange}
value={inputValues.name}
error={getErrorTranslation(errors.name)}
data-cy='guild-name-input'
/>
<Textarea
name='description'
label={'Description'}
placeholder={'Description'}
id='textarea-description'
onChange={onChange}
value={inputValues.description ?? ''}
data-cy='guild-description-input'
/>
</div>
</div>
</div>
)}
<div className='mt-12 flex flex-col items-center justify-center sm:w-fit'>
<div className='space-x-6'>
{member.isOwner ? (
<>
<Button type='submit' data-cy='button-save-guild-settings'>
Sauvegarder
</Button>
<Button
type='button'
color='red'
onClick={handleDelete}
data-cy='button-delete-guild-settings'
>
Supprimer
</Button>
</>
) : (
<Button
color='red'
onClick={handleLeave}
data-cy='button-leave-guild-settings'
>
Quitter {guild.name}
</Button>
)}
</div>
<FormState state={fetchState} message={message} />
</div>
</Form>
)
}

View File

@ -0,0 +1 @@
export * from './GuildSettings'

View File

@ -2,11 +2,11 @@ import InfiniteScroll from 'react-infinite-scroll-component'
import { Loader } from '../../design/Loader'
import { useGuilds } from '../../../contexts/Guilds'
import { GuildsChannelsPath } from '..'
import { GuildsPath } from '..'
import { Guild } from './Guild'
export interface GuildsProps {
path: GuildsChannelsPath | string
path: GuildsPath | string
}
export const Guilds: React.FC<GuildsProps> = (props) => {

View File

@ -1,12 +1,19 @@
import { useState } from 'react'
import Image from 'next/image'
import { useRouter } from 'next/router'
import { useState } from 'react'
import useTranslation from 'next-translate/useTranslation'
import classNames from 'classnames'
import axios from 'axios'
import { Emoji } from 'components/Emoji'
import { ConfirmGuildJoin } from 'components/Application/ConfirmGuildJoin'
import { API_URL } from 'tools/api'
import { GuildPublic as GuildPublicType } from '../../../../models/Guild'
import {
GuildPublic as GuildPublicType,
GuildWithDefaultChannelId
} from '../../../../models/Guild'
import { useAuthentication } from '../../../../tools/authentication'
export interface GuildPublicProps {
guild: GuildPublicType
@ -14,10 +21,37 @@ export interface GuildPublicProps {
export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
const { guild } = props
const router = useRouter()
const { authentication } = useAuthentication()
const [isConfirmed, setIsConfirmed] = useState(false)
const { t } = useTranslation()
const handleIsConfirmed = (): void => {
setIsConfirmed((isConfirmed) => !isConfirmed)
}
const handleYes = async (): Promise<void> => {
try {
const { data } = await authentication.api.post<{
guild: GuildWithDefaultChannelId
}>(`/guilds/${guild.id}/members/join`)
await router.push(
`/application/${guild.id}/${data.guild.defaultChannelId}`
)
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.status === 400 &&
typeof error.response?.data.defaultChannelId === 'number'
) {
const defaultChannelId = error.response.data.defaultChannelId as number
await router.push(`/application/${guild.id}/${defaultChannelId}`)
} else {
await router.push('/application')
}
}
}
return (
<div className='relative overflow-hidden rounded border border-gray-500 shadow-lg transition duration-200 ease-in-out hover:-translate-y-2 hover:shadow-none dark:border-gray-700'>
<div
@ -25,12 +59,14 @@ export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
'flex h-full cursor-pointer flex-col items-center justify-center p-4 pt-8 transition duration-200 ease-in-out',
{ '-translate-x-full': isConfirmed }
)}
onClick={() => setIsConfirmed(!isConfirmed)}
onClick={handleIsConfirmed}
>
<Image
className='rounded-full'
src={
guild.icon != null ? guild.icon : '/images/data/guild-default.png'
guild.icon != null
? API_URL + guild.icon
: '/images/data/guild-default.png'
}
alt='logo'
width={80}
@ -65,7 +101,8 @@ export const GuildPublic: React.FC<GuildPublicProps> = (props) => {
'!left-0': isConfirmed
}
)}
handleJoinGuild={() => setIsConfirmed(!isConfirmed)}
handleYes={handleYes}
handleNo={handleIsConfirmed}
/>
</div>
)

View File

@ -7,6 +7,7 @@ import { GuildPublic as GuildPublicType } from '../../../models/Guild'
import { Loader } from '../../design/Loader'
import { GuildPublic } from './GuildPublic'
import { usePagination } from '../../../hooks/usePagination'
import { SocketData, handleSocketData } from '../../../tools/handleSocketData'
export const JoinGuildsPublic: React.FC = () => {
const [search, setSearch] = useState('')
@ -14,12 +15,22 @@ export const JoinGuildsPublic: React.FC = () => {
const { authentication } = useAuthentication()
const { t } = useTranslation()
const { items, hasMore, nextPage, resetPagination } =
const { items, hasMore, nextPage, resetPagination, setItems } =
usePagination<GuildPublicType>({
api: authentication.api,
url: '/guilds/public'
})
useEffect(() => {
authentication.socket.on('guilds', (data: SocketData<GuildPublicType>) => {
handleSocketData({ data, setItems })
})
return () => {
authentication.socket.off('guilds')
}
}, [authentication.socket, setItems])
useEffect(() => {
resetPagination()
nextPage({ search })

View File

@ -8,6 +8,13 @@ import { MessageWithMember } from '../../../../../models/Message'
import { Loader } from '../../../../design/Loader'
import { FileIcon } from './FileIcon'
const supportedImageMimetype = [
'image/png',
'image/jpg',
'image/jpeg',
'image/gif'
]
export interface FileData {
blob: Blob
url: string
@ -44,7 +51,7 @@ export const MessageFile: React.FC<MessageContentProps> = (props) => {
if (file == null) {
return <Loader />
}
if (message.mimetype.startsWith('image/')) {
if (supportedImageMimetype.includes(message.mimetype)) {
return (
<a href={file.url} target='_blank' rel='noreferrer'>
<img

View File

@ -155,7 +155,8 @@ export const UserProfile: React.FC<UserProfileProps> = (props) => {
'absolute top-0 left-[150%] flex h-full w-full flex-col items-center justify-center transition-all',
{ 'left-[0%]': confirmation }
)}
handleJoinGuild={handleConfirmationState}
handleYes={handleConfirmationState}
handleNo={() => {}}
/>
</div>
<XIcon

View File

@ -133,6 +133,7 @@ export const UserSettings: React.FC = () => {
const handleFileChange: React.ChangeEventHandler<HTMLInputElement> = async (
event
) => {
setFetchState('loading')
const files = event?.target?.files
if (files != null && files.length === 1) {
const file = files[0]
@ -149,6 +150,7 @@ export const UserSettings: React.FC = () => {
logo: data.user.logo
}
})
setFetchState('idle')
} catch (error) {
setFetchState('error')
setMessageTranslationKey('errors:server-error')