-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
516 additions
and
0 deletions.
There are no files selected for viewing
74 changes: 74 additions & 0 deletions
74
src/app/(authed)/admin/answer/[answerId]/messages/_components/InputMessageField.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import SendIcon from '@mui/icons-material/Send'; | ||
import { Button, Container, Grid, TextField, Typography } from '@mui/material'; | ||
import { useState } from 'react'; | ||
import { useForm } from 'react-hook-form'; | ||
|
||
type SendMessageSchema = { | ||
body: string; | ||
}; | ||
|
||
const InputMessageField = (props: { answer_id: number }) => { | ||
const { handleSubmit, register, reset } = useForm<SendMessageSchema>(); | ||
const [sendFailedMessage, setSendFailedMessage] = useState< | ||
string | undefined | ||
>(undefined); | ||
|
||
const onSubmit = async (data: SendMessageSchema) => { | ||
if (data.body === '') { | ||
return; | ||
} | ||
|
||
const response = await fetch(`/api/answers/${props.answer_id}/messages`, { | ||
method: 'POST', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
body: JSON.stringify(data), | ||
}); | ||
|
||
if (response.ok) { | ||
reset({ body: '' }); | ||
setSendFailedMessage(undefined); | ||
} else { | ||
setSendFailedMessage('送信に失敗しました'); | ||
} | ||
}; | ||
|
||
return ( | ||
<Container component="form" onSubmit={handleSubmit(onSubmit)}> | ||
<Grid container> | ||
<Grid item xs={11}> | ||
<TextField | ||
{...register('body')} | ||
label="メッセージを入力してください" | ||
helperText="Shift + Enter で改行、Enter で送信することができます。" | ||
sx={{ width: '100%' }} | ||
onKeyDown={async (e) => { | ||
if (e.key === 'Enter' && !e.shiftKey) { | ||
e.preventDefault(); | ||
|
||
await handleSubmit(onSubmit)(); | ||
} | ||
}} | ||
multiline | ||
required | ||
/> | ||
</Grid> | ||
<Grid item xs={1} container alignItems="center" justifyContent="center"> | ||
<Button variant="contained" endIcon={<SendIcon />} type="submit"> | ||
送信 | ||
</Button> | ||
</Grid> | ||
{sendFailedMessage && ( | ||
<Grid item xs={12}> | ||
<Typography sx={{ fontSize: '12px', marginTop: '10px' }}> | ||
{sendFailedMessage} | ||
</Typography> | ||
</Grid> | ||
)} | ||
</Grid> | ||
</Container> | ||
); | ||
}; | ||
|
||
export default InputMessageField; |
211 changes: 211 additions & 0 deletions
211
src/app/(authed)/admin/answer/[answerId]/messages/_components/Messages.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,211 @@ | ||
import { MoreVert } from '@mui/icons-material'; | ||
import { | ||
Avatar, | ||
Box, | ||
Chip, | ||
Divider, | ||
Grid, | ||
IconButton, | ||
Menu, | ||
MenuItem, | ||
Stack, | ||
TextField, | ||
Typography, | ||
} from '@mui/material'; | ||
import { left, right } from 'fp-ts/lib/Either'; | ||
import { useState } from 'react'; | ||
import { errorResponseSchema } from '@/app/api/_schemas/ResponseSchemas'; | ||
import { formatString } from '@/generic/DateFormatter'; | ||
import type { ErrorResponse } from '@/app/api/_schemas/ResponseSchemas'; | ||
import type { Either } from 'fp-ts/lib/Either'; | ||
|
||
type Message = { | ||
id: string; | ||
body: string; | ||
sender: { | ||
uuid: string; | ||
name: string; | ||
role: 'ADMINISTRATOR' | 'STANDARD_USER'; | ||
}; | ||
timestamp: string; | ||
}; | ||
|
||
const Message = (props: { | ||
message: Message; | ||
answerId: number; | ||
edittingMessageId: string | undefined; | ||
handleEdit: () => void; | ||
handleCancelEditting: () => void; | ||
}) => { | ||
const [anchorEl, setAnchorEl] = useState<undefined | HTMLElement>(undefined); | ||
const [edittingMessage, setEdittingMessage] = useState<string>(); | ||
const [operationResultMessage, setOperationResultMessage] = useState< | ||
string | undefined | ||
>(undefined); | ||
|
||
const updateMessage = async ( | ||
body: string | ||
): Promise<Either<ErrorResponse, boolean>> => { | ||
const response = await fetch( | ||
`/api/answers/${props.answerId}/messages/${props.message.id}`, | ||
{ | ||
method: 'PATCH', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
body: JSON.stringify({ body }), | ||
} | ||
); | ||
|
||
if (response.ok) { | ||
return right(true); | ||
} else { | ||
const parseResult = errorResponseSchema.safeParse(await response.json()); | ||
|
||
if (parseResult.success) { | ||
return left(parseResult.data); | ||
} else { | ||
return right(false); | ||
} | ||
} | ||
}; | ||
|
||
const deleteMessage = async () => { | ||
await fetch(`/api/answers/${props.answerId}/messages/${props.message.id}`, { | ||
method: 'DELETE', | ||
headers: { | ||
'Content-Type': 'application/json', | ||
}, | ||
}); | ||
}; | ||
|
||
return ( | ||
<Grid container spacing={2}> | ||
<Grid item xs={1}> | ||
<Avatar | ||
alt="PlayerHead" | ||
src={`https://mc-heads.net/avatar/${props.message.sender.name}`} | ||
/> | ||
</Grid> | ||
<Grid item xs={9}> | ||
<Stack> | ||
<Stack | ||
direction="row" | ||
spacing={1} | ||
sx={{ | ||
display: 'flex', | ||
alignItems: 'center', | ||
}} | ||
> | ||
{props.message.sender.role === 'ADMINISTRATOR' ? ( | ||
<Chip | ||
avatar={<Avatar src="/server-icon.png" />} | ||
label="運営チーム" | ||
color="success" | ||
/> | ||
) : null} | ||
<Typography>{props.message.sender.name}</Typography> | ||
</Stack> | ||
<Typography>{formatString(props.message.timestamp)}</Typography> | ||
</Stack> | ||
</Grid> | ||
<Grid item xs={2}> | ||
<IconButton | ||
color="primary" | ||
onClick={(event: React.MouseEvent<HTMLElement>) => | ||
setAnchorEl(event.currentTarget) | ||
} | ||
> | ||
<MoreVert /> | ||
</IconButton> | ||
</Grid> | ||
<Menu | ||
anchorEl={anchorEl} | ||
open={anchorEl !== undefined} | ||
onClose={() => setAnchorEl(undefined)} | ||
> | ||
<MenuItem onClick={() => props.handleEdit()}>編集</MenuItem> | ||
<MenuItem onClick={deleteMessage}>削除</MenuItem> | ||
</Menu> | ||
<Grid item xs={1}></Grid> | ||
<Grid item xs={11}> | ||
{props.edittingMessageId === props.message.id ? ( | ||
<TextField | ||
defaultValue={props.message.body} | ||
helperText="編集を確定するには Enter キー、キャンセルするには Esc キーを入力してください。" | ||
onChange={(event) => setEdittingMessage(event.target.value)} | ||
onKeyDown={async (event) => { | ||
if ( | ||
event.key === 'Enter' && | ||
edittingMessage !== undefined && | ||
edittingMessage !== '' | ||
) { | ||
const updateResult = await updateMessage(edittingMessage); | ||
|
||
if ( | ||
updateResult._tag === 'Left' && | ||
updateResult.left.errorCode === 'FORBIDDEN' | ||
) { | ||
setOperationResultMessage( | ||
'このメッセージを編集する権限がありません。' | ||
); | ||
} else if ( | ||
updateResult._tag === 'Right' && | ||
updateResult.right | ||
) { | ||
props.handleCancelEditting(); | ||
} else { | ||
setOperationResultMessage( | ||
'不明なエラーが発生しました。もう一度お試しください。' | ||
); | ||
} | ||
} else if (event.key === 'Escape') { | ||
setOperationResultMessage(undefined); | ||
props.handleCancelEditting(); | ||
} | ||
}} | ||
/> | ||
) : ( | ||
<Typography sx={{ whiteSpace: 'pre-wrap' }}> | ||
{props.message.body} | ||
</Typography> | ||
)} | ||
</Grid> | ||
{operationResultMessage === undefined ? null : ( | ||
<Box sx={{ width: '100%' }}> | ||
<Grid item xs={1}></Grid> | ||
<Grid xs={11}> | ||
<Typography sx={{ fontSize: '12px', marginTop: '10px' }}> | ||
{operationResultMessage} | ||
</Typography> | ||
</Grid> | ||
</Box> | ||
)} | ||
</Grid> | ||
); | ||
}; | ||
|
||
const Messages = (props: { messages: Message[]; answerId: number }) => { | ||
const [edittingMessageId, setEdittingMessageId] = useState< | ||
string | undefined | ||
>(undefined); | ||
|
||
return ( | ||
<Stack spacing={2}> | ||
{props.messages.map((message) => ( | ||
<Stack key={message.id} spacing={2}> | ||
<Message | ||
message={message} | ||
answerId={props.answerId} | ||
edittingMessageId={edittingMessageId} | ||
handleEdit={() => setEdittingMessageId(message.id)} | ||
handleCancelEditting={() => setEdittingMessageId(undefined)} | ||
/> | ||
<Divider /> | ||
</Stack> | ||
))} | ||
</Stack> | ||
); | ||
}; | ||
|
||
export default Messages; |
68 changes: 68 additions & 0 deletions
68
src/app/(authed)/admin/answer/[answerId]/messages/page.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
'use client'; | ||
|
||
import { Container, CssBaseline, Stack, ThemeProvider } from '@mui/material'; | ||
import useSWR from 'swr'; | ||
import ErrorModal from '@/app/_components/ErrorModal'; | ||
import LoadingCircular from '@/app/_components/LoadingCircular'; | ||
import InputMessageField from './_components/InputMessageField'; | ||
import Messages from './_components/Messages'; | ||
import adminDashboardTheme from '../../../theme/adminDashboardTheme'; | ||
import type { | ||
ErrorResponse, | ||
GetMessagesResponse, | ||
} from '@/app/api/_schemas/ResponseSchemas'; | ||
import type { Either } from 'fp-ts/lib/Either'; | ||
|
||
const Home = ({ params }: { params: { answerId: number } }) => { | ||
const { data: messages, isLoading: isMessagesLoading } = useSWR< | ||
Either<ErrorResponse, GetMessagesResponse> | ||
>(`/api/answers/${params.answerId}/messages`, { refreshInterval: 1000 }); | ||
|
||
if (!messages) { | ||
return <LoadingCircular />; | ||
} else if ((!isMessagesLoading && !messages) || messages._tag === 'Left') { | ||
return <ErrorModal />; | ||
} | ||
|
||
return ( | ||
<ThemeProvider theme={adminDashboardTheme}> | ||
<CssBaseline /> | ||
<Stack | ||
sx={{ | ||
width: 'calc(100% - 240px)', // Subtract Drawer width | ||
height: 'calc(100vh - 64px)', // Subtract AppBar height | ||
overflow: 'hidden', | ||
position: 'fixed', | ||
top: '64px', // Add AppBar height | ||
left: '240px', // Add Drawer width | ||
margin: 0, | ||
}} | ||
> | ||
<Container | ||
style={{ | ||
width: '100%', | ||
height: 'calc(100vh - 100px)', | ||
overflow: 'auto', | ||
paddingBottom: '20px', | ||
marginLeft: 'auto', | ||
marginRight: 'auto', | ||
}} | ||
ref={(el) => { | ||
if (el) { | ||
el.scrollTop = el.scrollHeight; | ||
} | ||
}} | ||
sx={{ | ||
flexGrow: 1, | ||
px: { xs: 2, sm: 3 }, | ||
}} | ||
> | ||
<Messages messages={messages.right} answerId={params.answerId} /> | ||
</Container> | ||
<InputMessageField answer_id={params.answerId} /> | ||
</Stack> | ||
</ThemeProvider> | ||
); | ||
}; | ||
|
||
export default Home; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.