Skip to content

Commit

Permalink
feat: メッセージページを実装
Browse files Browse the repository at this point in the history
  • Loading branch information
rito528 committed Nov 8, 2024
1 parent ea1bdeb commit 6aa216d
Show file tree
Hide file tree
Showing 6 changed files with 516 additions and 0 deletions.
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;
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 src/app/(authed)/admin/answer/[answerId]/messages/page.tsx
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;
16 changes: 16 additions & 0 deletions src/app/api/_schemas/ResponseSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,22 @@ export const getAnswerResponseSchema = z.object({

export type GetAnswerResponse = z.infer<typeof getAnswerResponseSchema>;

// GET /forms/answers/:answerId/messages
export const getMessagesResponseSchema = z
.object({
id: z.string(),
body: z.string().uuid(),
sender: z.object({
uuid: z.string(),
name: z.string(),
role: z.enum(['ADMINISTRATOR', 'STANDARD_USER']),
}),
timestamp: z.string().datetime(),
})
.array();

export type GetMessagesResponse = z.infer<typeof getMessagesResponseSchema>;

// GET /users
export const getUsersResponseSchema = z.object({
uuid: z.string(),
Expand Down
Loading

0 comments on commit 6aa216d

Please sign in to comment.