mirror of
https://github.com/usatiuk/y.git
synced 2025-10-28 10:37:47 +01:00
messages!
This commit is contained in:
@@ -11,6 +11,7 @@ import { Login } from "./Login";
|
||||
import { Signup } from "./Signup";
|
||||
import { Home } from "./Home";
|
||||
import {
|
||||
chatAction,
|
||||
homeAction,
|
||||
loginAction,
|
||||
newChatAction,
|
||||
@@ -68,6 +69,7 @@ const router = createBrowserRouter([
|
||||
path: "messages/chat/:id",
|
||||
element: <Chat />,
|
||||
loader: chatLoader,
|
||||
action: chatAction,
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
|
||||
60
client/src/Chat.scss
Normal file
60
client/src/Chat.scss
Normal file
@@ -0,0 +1,60 @@
|
||||
@import "./common";
|
||||
|
||||
.chat {
|
||||
width: 20rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 0;
|
||||
|
||||
a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chatHeader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: auto;
|
||||
|
||||
border-bottom: solid 1px #A0A0A0;
|
||||
|
||||
span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.chatMessages {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
|
||||
a {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.3rem;
|
||||
flex-grow: 0;
|
||||
@include border-shadow;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.messageForm {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-top: 0.5rem;
|
||||
//margin: 2rem;
|
||||
min-height: 3rem;
|
||||
|
||||
@include border-shadow;
|
||||
border: solid gray 1px;
|
||||
|
||||
@include post-editor;
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,59 @@
|
||||
import { useLoaderData } from "react-router-dom";
|
||||
import { useFetcher, useLoaderData, useRevalidator } from "react-router-dom";
|
||||
import { chatLoader, LoaderToType } from "./loaders";
|
||||
import { isError } from "./api/dto";
|
||||
|
||||
import "./Chat.scss";
|
||||
import "./PostForm.scss";
|
||||
import { Message } from "./Message";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Chat() {
|
||||
const loaderData = useLoaderData() as LoaderToType<typeof chatLoader>;
|
||||
if (!loaderData || isError(loaderData)) {
|
||||
|
||||
if (!loaderData) {
|
||||
return <div>error</div>;
|
||||
}
|
||||
|
||||
return <div className={"chat"}>{loaderData.name}</div>;
|
||||
const chat = loaderData.chat;
|
||||
const messages = loaderData.messages;
|
||||
if (!chat || isError(chat) || !messages || isError(messages)) {
|
||||
return <div>error</div>;
|
||||
}
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => revalidator.revalidate(), 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetcher = useFetcher();
|
||||
const busy = fetcher.state === "submitting";
|
||||
|
||||
const sortedMessages = messages.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
return (
|
||||
<div className={"chat"}>
|
||||
<div className={"chatHeader"}>{chat.name}</div>
|
||||
<fetcher.Form className={"messageForm postForm"} method="post">
|
||||
<textarea placeholder={"Write something!"} name="text" />
|
||||
<input hidden={true} value={chat.id} name={"chatId"} />
|
||||
<button
|
||||
name="intent"
|
||||
value="addMessage"
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
<div className={"messages"}>
|
||||
{sortedMessages.map((m) => (
|
||||
<Message key={m.id} message={m} chat={chat} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
29
client/src/ChatCreate.scss
Normal file
29
client/src/ChatCreate.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.chatsNew {
|
||||
width: 20rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 0;
|
||||
|
||||
.errors {
|
||||
margin: 2rem 0;
|
||||
color: red;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
* {
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,41 @@
|
||||
import { Form, useLoaderData, useNavigation } from "react-router-dom";
|
||||
import {
|
||||
Form,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useNavigation,
|
||||
} from "react-router-dom";
|
||||
import { LoaderToType, newChatLoader } from "./loaders";
|
||||
import { isError } from "./api/dto";
|
||||
import { getTokenUserUuid } from "./api/utils";
|
||||
|
||||
import "./ChatCreate.scss";
|
||||
import { ActionToType, newChatAction } from "./actions";
|
||||
|
||||
export function ChatCreate() {
|
||||
const loaderData = useLoaderData() as LoaderToType<typeof newChatLoader>;
|
||||
if (!loaderData || isError(loaderData)) {
|
||||
return <div>error</div>;
|
||||
}
|
||||
let errors: JSX.Element[] = [];
|
||||
|
||||
const actionData = useActionData() as ActionToType<typeof newChatAction>;
|
||||
|
||||
if (isError(actionData)) {
|
||||
errors = actionData.errors.map((e) => {
|
||||
return <a>{e}</a>;
|
||||
});
|
||||
}
|
||||
|
||||
const navigation = useNavigation();
|
||||
const busy = navigation.state === "submitting";
|
||||
|
||||
return (
|
||||
<div className={"chatsNew"}>
|
||||
<div className={"errors"}>{errors}</div>
|
||||
<Form method="post">
|
||||
<label htmlFor="fname">name:</label>
|
||||
<label htmlFor="fname">Chat name:</label>
|
||||
<input type="text" name="name" />
|
||||
<label htmlFor="members">Members:</label>
|
||||
<select multiple name={"members"}>
|
||||
{loaderData
|
||||
.filter((p) => p.uuid != getTokenUserUuid())
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
@import "./common";
|
||||
|
||||
.chats {
|
||||
|
||||
width: 20rem;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 2rem 0;
|
||||
|
||||
a {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chatsHeader {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: auto;
|
||||
|
||||
border-bottom: solid 1px #A0A0A0;
|
||||
|
||||
span {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.chatsList {
|
||||
display: flex;
|
||||
flex: auto;
|
||||
|
||||
a {
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0.3rem;
|
||||
flex-grow: 0;
|
||||
@include border-shadow;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export function Chats() {
|
||||
return (
|
||||
<div className={"chats"}>
|
||||
<div className={"chatsHeader"}>
|
||||
<span>Your chats</span>
|
||||
<Link to={"../messages/chats/new"}>create</Link>
|
||||
</div>
|
||||
<div className={"chatsList"}>
|
||||
|
||||
79
client/src/Message.scss
Normal file
79
client/src/Message.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
@import "./common";
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #E0E0E0;
|
||||
border-radius: 7px;
|
||||
padding: 7px;
|
||||
resize: none;
|
||||
margin: 1rem 0;
|
||||
min-height: 3rem;
|
||||
|
||||
@include border-shadow;
|
||||
|
||||
&.messageEditing {
|
||||
padding: 0;
|
||||
min-height: 6rem;
|
||||
border: 1px solid #D0D0D0;
|
||||
|
||||
@include post-editor;
|
||||
}
|
||||
|
||||
.text {
|
||||
word-wrap: anywhere;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 0.3rem;
|
||||
margin-bottom: -0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: #A0A0A0;
|
||||
|
||||
display: flex;
|
||||
flex: auto;
|
||||
width: 100%;
|
||||
|
||||
.info {
|
||||
flex-grow: 1;
|
||||
align-self: start;
|
||||
|
||||
> * {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-self: end;
|
||||
|
||||
> * {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
client/src/Message.tsx
Normal file
50
client/src/Message.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import "./Message.scss";
|
||||
import { TChatTo, TMessageTo } from "./api/dto";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function Message({
|
||||
message,
|
||||
chat,
|
||||
}: {
|
||||
message: TMessageTo;
|
||||
chat: TChatTo;
|
||||
}) {
|
||||
return (
|
||||
<div className={"message"}>
|
||||
<span className={"text"}>{message.contents}</span>
|
||||
<div className={"footer"}>
|
||||
<div className={"info"}>
|
||||
<span className={"createdDate"}>
|
||||
{new Date(message.createdAt * 1000).toUTCString()}
|
||||
</span>
|
||||
<Link
|
||||
className={"authorLink"}
|
||||
to={"/home/profile/" + message.authorUsername}
|
||||
>
|
||||
by {message.authorUsername}
|
||||
</Link>
|
||||
</div>
|
||||
{/*{actions && (*/}
|
||||
{/* <div className={"actions"}>*/}
|
||||
{/* {<button onClick={() => setEditing(true)}>edit</button>}*/}
|
||||
{/* <Form method={"delete"}>*/}
|
||||
{/* <input*/}
|
||||
{/* hidden={true}*/}
|
||||
{/* name={"postToDeleteId"}*/}
|
||||
{/* value={id}*/}
|
||||
{/* />*/}
|
||||
{/* <button*/}
|
||||
{/* name="intent"*/}
|
||||
{/* value="deletePost"*/}
|
||||
{/* type={"submit"}*/}
|
||||
{/* disabled={busy}*/}
|
||||
{/* >*/}
|
||||
{/* delete*/}
|
||||
{/* </button>*/}
|
||||
{/* </Form>*/}
|
||||
{/* </div>*/}
|
||||
{/*)}*/}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function Messages() {
|
||||
return <a>Messages</a>;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { isError } from "./api/dto";
|
||||
import { deleteToken, getTokenUserUuid, setToken } from "./api/utils";
|
||||
import { createPost, deletePost, updatePost } from "./api/Post";
|
||||
import { createChat } from "./api/Chat";
|
||||
import { addMessagesToChat } from "./api/Message";
|
||||
|
||||
export type ActionToType<T extends (...args: any) => any> =
|
||||
| Exclude<Awaited<ReturnType<T>>, Response>
|
||||
@@ -89,8 +90,19 @@ export async function userListAction({ request }: ActionFunctionArgs) {
|
||||
|
||||
export async function newChatAction({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
return await createChat(formData.get("name")!.toString(), [
|
||||
const ret = await createChat(formData.get("name")!.toString(), [
|
||||
...formData.getAll("members")!.map((p) => p.toString()),
|
||||
getTokenUserUuid()!,
|
||||
]);
|
||||
if (ret && !isError(ret)) {
|
||||
return redirect("/home/messages/chat/" + ret.id);
|
||||
} else return ret;
|
||||
}
|
||||
|
||||
export async function chatAction({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData();
|
||||
return await addMessagesToChat(
|
||||
Number(formData.get("chatId")!.toString()),
|
||||
formData.get("text")!.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
22
client/src/api/Message.ts
Normal file
22
client/src/api/Message.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
MessagesToResp,
|
||||
MessageToResp,
|
||||
TMessagesToResp,
|
||||
TMessageToResp,
|
||||
} from "./dto";
|
||||
import { fetchJSONAuth } from "./utils";
|
||||
|
||||
export async function getMessagesByChat(
|
||||
chatId: number,
|
||||
): Promise<TMessagesToResp> {
|
||||
return fetchJSONAuth("/message/by-chat/" + chatId, "GET", MessagesToResp);
|
||||
}
|
||||
|
||||
export async function addMessagesToChat(
|
||||
chatId: number,
|
||||
messageContents: string,
|
||||
): Promise<TMessageToResp> {
|
||||
return fetchJSONAuth("/message/by-chat/" + chatId, "POST", MessageToResp, {
|
||||
contents: messageContents,
|
||||
});
|
||||
}
|
||||
@@ -79,19 +79,23 @@ export const MessageTo = z.object({
|
||||
id: number(),
|
||||
chatId: z.number(),
|
||||
authorUuid: z.string(),
|
||||
authorUsername: z.string(),
|
||||
contents: z.string(),
|
||||
createdAt: z.number(),
|
||||
});
|
||||
export type TMessageTo = z.infer<typeof MessageTo>;
|
||||
|
||||
export const MessageToResp = CreateAPIResponse(MessageTo);
|
||||
export type TMessageToResp = z.infer<typeof MessageToResp>;
|
||||
|
||||
export const MessagesToResp = CreateAPIResponse(z.array(MessageTo));
|
||||
export type TMessagesToResp = z.infer<typeof MessagesToResp>;
|
||||
|
||||
export const ChatTo = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
creatorUuid: z.string(),
|
||||
members: z.array(PersonTo),
|
||||
messages: z.array(MessageTo),
|
||||
memberCount: z.number(),
|
||||
});
|
||||
export type TChatTo = z.infer<typeof ChatTo>;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getPostsByFollowees,
|
||||
} from "./api/Post";
|
||||
import { getChat, getMyChats } from "./api/Chat";
|
||||
import { getMessagesByChat } from "./api/Message";
|
||||
|
||||
export type LoaderToType<T extends (...args: any) => any> =
|
||||
| Exclude<Awaited<ReturnType<T>>, Response>
|
||||
@@ -86,5 +87,8 @@ export async function newChatLoader() {
|
||||
}
|
||||
|
||||
export async function chatLoader({ params }: { params: { id?: number } }) {
|
||||
return getChat(params.id!);
|
||||
return {
|
||||
chat: await getChat(params.id!),
|
||||
messages: await getMessagesByChat(params.id!),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user