feat: extract input and optimize ui

main
Justin Xiao 2023-07-20 21:13:28 +08:00
parent 7d80c2b8f6
commit 92c013ad08
6 changed files with 200 additions and 106 deletions

View File

@ -1 +1,9 @@
# nextjs13-socketio-boilerplate
[![License MIT](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](./LICENSE)
<img src="https://forthebadge.com/images/badges/made-with-typescript.svg" alt="Made with TypeScript" height="28" />
<a href="https://vercel.com/new/clone?repository-url=https://github.com/ttpss930141011/nextjs-13-socketio-boilerplate&env=NEXT_PUBLIC_SOCKET_URL"><img src="./public/powered-by-vercel.svg" alt="Powered by Vercel" height="29" /></a>
## Project Description
This boilerplate

View File

@ -4,24 +4,18 @@ import {
Card,
Container,
Text,
Divider,
ScrollArea,
TextInput,
ActionIcon,
} from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import useSocketStore from "@/store/socket";
import { useEffect, useRef, useState, useLayoutEffect } from "react";
import { MessageWithMe, SocketMessage } from "@/types/next";
import { toast } from "react-hot-toast";
import ChatroomTitle from "@/components/ChatroomTitle";
import ChatroomInput from "@/components/ChatroomInput";
const useStyles = createStyles((theme) => ({
rightMessageField: {
display: "flex",
flexDirection: "row-reverse",
flexWrap: "nowrap",
// alignItems: "center",
width: "100%",
marginTop: theme.spacing.xs,
marginBottom: theme.spacing.xs,
@ -29,14 +23,23 @@ const useStyles = createStyles((theme) => ({
rightMessage: {
width: "fit-content",
padding: theme.spacing.xs,
borderRadius: theme.radius.md,
overflowWrap: "break-word",
borderRadius: theme.radius.lg,
backgroundColor: theme.colors.green[2],
maxWidth: "50em",
[theme.fn.smallerThan("md")]: {
maxWidth: "35em",
},
[theme.fn.smallerThan("sm")]: {
maxWidth: "25em",
},
[theme.fn.smallerThan("xs")]: {
maxWidth: "15em",
},
},
leftMessageField: {
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
// alignItems: "center",
width: "100%",
marginTop: theme.spacing.xs,
marginBottom: theme.spacing.xs,
@ -44,11 +47,25 @@ const useStyles = createStyles((theme) => ({
leftMessage: {
width: "fit-content",
padding: theme.spacing.xs,
borderRadius: theme.radius.md,
overflowWrap: "break-word",
borderRadius: theme.radius.lg,
backgroundColor: theme.colors.gray[2],
maxWidth: "50em",
[theme.fn.smallerThan("md")]: {
maxWidth: "35em",
},
[theme.fn.smallerThan("sm")]: {
maxWidth: "25em",
},
[theme.fn.smallerThan("xs")]: {
maxWidth: "15em",
},
},
timestamp: {
width: "fit-content",
display: "flex",
flexWrap: "nowrap",
fontSize: theme.fontSizes.xs,
color: theme.colors.gray[5],
marginLeft: theme.spacing.xs,
@ -58,31 +75,17 @@ const useStyles = createStyles((theme) => ({
}));
export default function Home() {
const { socket, connect, emit } = useSocketStore((state) => state); // deconstructing socket and its method from socket store
const chatViewportRef = useRef<HTMLDivElement>(null); // binding chat viewport ref to scroll to bottom
const messageInputRef = useRef<HTMLInputElement>(null); // binding message input ref to focus
const [targetSocketId, setTargetSocketId] = useState<string>(""); // target socket id input value
const [message, setMessage] = useState(""); // message input value
const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea
const { classes } = useStyles();
const { socket, connect } = useSocketStore((state) => state); // deconstructing socket and its method from socket store
const chatViewportRef = useRef<HTMLDivElement>(null); // binding chat viewport ref to scroll to bottom
const [targetSocketId, setTargetSocketId] = useState<string>(""); // target socket id input value
const [messages, setMessages] = useState<MessageWithMe[]>([]); // show messages on ScrollArea
const scrollToBottom = () => {
chatViewportRef?.current?.scrollTo({
top: chatViewportRef.current.scrollHeight,
behavior: "smooth",
});
};
const sendMessage = () => {
if (!message) return toast.error("Please enter a message");
if (!socket?.connected) return toast.error("Please reconnect server first");
if (!targetSocketId) return toast.error("Please enter a target socket id");
emit("message", { from: socket?.id, to: targetSocketId, timestamp: Date.now(), message });
setMessage("");
messageInputRef.current?.focus();
};
useEffect(() => {
connect();
@ -106,21 +109,22 @@ export default function Home() {
return (
<>
<Container pt="sm" size="md">
<Card shadow="sm" padding="sm" radius="md" withBorder>
<Container size="md" h={"100vh"}>
<Card shadow="sm" padding="sm" radius="md" withBorder h={"100%"}>
<Card.Section withBorder inheritPadding py="xs" h={"10%"}>
<ChatroomTitle
targetSocketId={targetSocketId}
setTargetSocketId={setTargetSocketId}
/>
<Divider />
<ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}>
</Card.Section>
<ScrollArea offsetScrollbars viewportRef={chatViewportRef} h={"85%"}>
{messages.map((message, index) => {
return (
<div
className={
message.me
(message.me
? classes.rightMessageField
: classes.leftMessageField
: classes.leftMessageField) + " whitespace-pre"
}
key={message.timestamp + index}
>
@ -138,26 +142,9 @@ export default function Home() {
);
})}
</ScrollArea>
<Divider />
<TextInput
ref={messageInputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
mt={10}
radius="xl"
size="md"
rightSection={
<ActionIcon size={32} radius="xl" variant="filled">
<IconArrowRight size="1.1rem" stroke={1.5} onClick={sendMessage} />
</ActionIcon>
}
placeholder="Type something..."
rightSectionWidth={42}
onKeyDown={(e) => {
if (e.key === "Enter") sendMessage();
}}
/>
<Card.Section withBorder inheritPadding h={"10%"}>
<ChatroomInput targetSocketId={targetSocketId} />
</Card.Section>
</Card>
</Container>
</>

View File

@ -10,8 +10,8 @@ type Prop = {
};
export const metadata = {
title: "Tiny Socket.io demo",
description: "This repo implements a simple chat app with Socket.io and Next.js 13.",
title: "A Simple Full-Stack Socket.io Demo",
description: "This repo implements a simple chat app with Socket.io, Next.js 13, Mantine and Zustand.",
};
export default function RootLayout({ children }: Prop) {

View File

@ -0,0 +1,69 @@
import { createStyles, Indicator, Overlay, Avatar as MantineAvatar } from "@mantine/core";
import { IconPencil } from "@tabler/icons-react";
import React, { useEffect, useState } from "react";
import NameModal from "./NameModal";
import useBasicStore from "@/store/basic";
import { useDisclosure } from "@mantine/hooks";
import useSocketStore from "@/store/socket";
const useStyles = createStyles((theme, { isShown }: { isShown: boolean }) => ({
overlay: {
cursor: "pointer",
opacity: !isShown ? "0" : "1",
transition: "all .2s",
visibility: !isShown ? "hidden" : "visible",
},
}));
const Avatar = () => {
const socket = useSocketStore((state) => state.socket); // deconstructing socket and its method from socket store
const storeName = useBasicStore((state) => state.name); // get name from basic store
const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error
const [modalOpened, { open: modalOpen, close: modalClose }] = useDisclosure(false); // control change name modal open/close
const [isShown, setIsShown] = useState(false);
const { classes } = useStyles({ isShown });
useEffect(() => {
setName(storeName);
if (!storeName) modalOpen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeName]);
return (
<>
<Indicator
inline
size={16}
offset={5}
position="bottom-end"
color={socket?.connected ? "teal" : "red"}
withBorder
zIndex={2}
>
<MantineAvatar
src={null}
alt="User"
color="blue"
radius="xl"
onMouseEnter={() => setIsShown(true)}
onMouseLeave={() => setIsShown(false)}
>
{name && name.length > 3 ? `${name.slice(0, 1)}` : name}
<Overlay
blur={15}
center
radius="xl"
zIndex={1}
className={classes.overlay}
onClick={modalOpen}
>
<IconPencil size="1.5rem" />
</Overlay>
</MantineAvatar>
</Indicator>
<NameModal opened={modalOpened} onClose={modalClose} />
</>
);
};
export default Avatar;

View File

@ -0,0 +1,62 @@
import useSocketStore from "@/store/socket";
import { ActionIcon, Group, Textarea, createStyles } from "@mantine/core";
import { IconSend } from "@tabler/icons-react";
import { KeyboardEvent, FC, useRef, useState } from "react";
import toast from "react-hot-toast";
type Props = {
targetSocketId: string;
};
const useStyles = createStyles((theme) => ({
inputWithoutBorder: {
border: "none",
},
}));
const ChatroomInput: FC<Props> = ({ targetSocketId }) => {
const { socket, emit } = useSocketStore((state) => state); // deconstructing socket and its method from socket store
const [message, setMessage] = useState(""); // message input value
const messageInputRef = useRef<HTMLTextAreaElement>(null); // binding message input ref to focus
const { classes } = useStyles();
const sendMessage = () => {
console.log("sendMessage", message === "");
if (!message) return toast.error("Please enter a message");
if (!socket?.connected) return toast.error("Please reconnect server first");
if (!targetSocketId) return toast.error("Please enter a target socket id");
emit("message", { from: socket?.id, to: targetSocketId, timestamp: Date.now(), message });
setMessage("");
messageInputRef.current?.focus();
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && e.altKey !== true && e.shiftKey !== true && e.ctrlKey !== true) {
e.preventDefault();
sendMessage();
}
};
return (
<Group w={"100%"} align="center">
<Textarea
classNames={{ input: classes.inputWithoutBorder }}
h={"100%"}
w={"100%"}
ref={messageInputRef}
value={message}
onChange={(e) => setMessage(e.currentTarget.value)}
// radius="xl"
// size="md"
rightSection={
<ActionIcon size={32} radius="xl">
<IconSend size="1.5rem" stroke={1.5} onClick={sendMessage} />
</ActionIcon>
}
placeholder="Type something..."
rightSectionWidth={42}
onKeyDown={handleKeyDown}
/>
</Group>
);
};
export default ChatroomInput;

View File

@ -7,7 +7,7 @@ import {
Popover,
CopyButton,
Tooltip,
Avatar,
Avatar as MantineAvatar,
Indicator,
Menu,
} from "@mantine/core";
@ -15,18 +15,15 @@ import {
IconPlug,
IconCheck,
IconCopy,
IconEdit,
IconPlugOff,
IconChevronDown,
IconUserCog,
IconUser,
} from "@tabler/icons-react";
import { SetStateAction, Dispatch, FC, useEffect, useState } from "react";
import NameModal from "./NameModal";
import useBasicStore from "@/store/basic";
import { useDisclosure } from "@mantine/hooks";
import useSocketStore from "@/store/socket";
import { environment } from "@/config";
import Avatar from "./Avatar";
type Props = {
targetSocketId: string;
@ -35,18 +32,10 @@ type Props = {
const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
const { socket, connect, disconnect } = useSocketStore(); // deconstructing socket and its method from socket store
const storeName = useBasicStore((state) => state.name); // get name from basic store
const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error
const [modalOpened, { open: modalOpen, close: modalClose }] = useDisclosure(false); // control change name modal open/close
const [popoverOpened, setPopoverOpened] = useState(false); // control popover open/close
const [onlineUsers, setOnlineUsers] = useState<Record<string, string>>({}); // online users
useEffect(() => {
setName(storeName);
if (!storeName) modalOpen();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storeName]);
useEffect(() => {
socket?.on("online_user", (onlineUsers: Record<string, string>) => {
setOnlineUsers(onlineUsers);
@ -59,20 +48,9 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
return (
<>
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
<Group className="flex flex-row flex-nowrap">
<Indicator
inline
size={16}
offset={5}
position="bottom-end"
color={socket?.connected ? "teal" : "red"}
withBorder
>
<Avatar src={null} alt="User" color="blue" radius="xl" w="fit-content">
{name}
</Avatar>
</Indicator>
<Group position="apart" mt="xs" mb="xs" noWrap h={"5vh"}>
<Group noWrap>
<Avatar />
<Popover
width="fit-content"
@ -119,17 +97,6 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
<Group position="apart">
<Text size="sm">Actions</Text>
<Group className="gap-1">
<Tooltip label="Change name" withArrow position="right">
<ActionIcon
color="yellow"
onClick={() => {
modalOpen();
setPopoverOpened(false);
}}
>
<IconEdit size="1rem" />
</ActionIcon>
</Tooltip>
{
// if socket is not connected, show connect button
socket?.connected ? (
@ -151,11 +118,11 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
</Popover.Dropdown>
</Popover>
</Group>
<Group w={220} className="flex flex-row flex-nowrap">
<Group noWrap w={220}>
<Text w={20}>To:</Text>
<Input
w={170}
placeholder="Your target Socket ID"
placeholder="Target Socket ID"
value={targetSocketId}
onChange={(e) => setTargetSocketId(e.currentTarget.value)}
/>
@ -168,7 +135,9 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
<Menu.Dropdown>
<Menu.Label>
{environment === "development" ? "Not available in development" : "Online user"}
{environment === "development"
? "Not available in development"
: "Online user"}
</Menu.Label>
{socket?.connected &&
Object.keys(onlineUsers)
@ -190,7 +159,7 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
color="teal"
withBorder
>
<Avatar
<MantineAvatar
src={null}
alt="User"
color="blue"
@ -198,7 +167,7 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
w="fit-content"
>
<IconUser size="1.5rem" />
</Avatar>
</MantineAvatar>
</Indicator>
<Text>{onlineUsers[socketId]}</Text>
</Group>
@ -208,7 +177,6 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
</Menu>
</Group>
</Group>
<NameModal opened={modalOpened} onClose={modalClose} />
</>
);
};