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 # 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, Card,
Container, Container,
Text, Text,
Divider,
ScrollArea, ScrollArea,
TextInput,
ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react";
import useSocketStore from "@/store/socket"; import useSocketStore from "@/store/socket";
import { useEffect, useRef, useState, useLayoutEffect } from "react"; import { useEffect, useRef, useState, useLayoutEffect } from "react";
import { MessageWithMe, SocketMessage } from "@/types/next"; import { MessageWithMe, SocketMessage } from "@/types/next";
import { toast } from "react-hot-toast";
import ChatroomTitle from "@/components/ChatroomTitle"; import ChatroomTitle from "@/components/ChatroomTitle";
import ChatroomInput from "@/components/ChatroomInput";
const useStyles = createStyles((theme) => ({ const useStyles = createStyles((theme) => ({
rightMessageField: { rightMessageField: {
display: "flex", display: "flex",
flexDirection: "row-reverse", flexDirection: "row-reverse",
flexWrap: "nowrap",
// alignItems: "center",
width: "100%", width: "100%",
marginTop: theme.spacing.xs, marginTop: theme.spacing.xs,
marginBottom: theme.spacing.xs, marginBottom: theme.spacing.xs,
@ -29,14 +23,23 @@ const useStyles = createStyles((theme) => ({
rightMessage: { rightMessage: {
width: "fit-content", width: "fit-content",
padding: theme.spacing.xs, padding: theme.spacing.xs,
borderRadius: theme.radius.md, overflowWrap: "break-word",
borderRadius: theme.radius.lg,
backgroundColor: theme.colors.green[2], 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: { leftMessageField: {
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
flexWrap: "nowrap",
// alignItems: "center",
width: "100%", width: "100%",
marginTop: theme.spacing.xs, marginTop: theme.spacing.xs,
marginBottom: theme.spacing.xs, marginBottom: theme.spacing.xs,
@ -44,11 +47,25 @@ const useStyles = createStyles((theme) => ({
leftMessage: { leftMessage: {
width: "fit-content", width: "fit-content",
padding: theme.spacing.xs, padding: theme.spacing.xs,
borderRadius: theme.radius.md, overflowWrap: "break-word",
borderRadius: theme.radius.lg,
backgroundColor: theme.colors.gray[2], 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: { timestamp: {
width: "fit-content",
display: "flex", display: "flex",
flexWrap: "nowrap",
fontSize: theme.fontSizes.xs, fontSize: theme.fontSizes.xs,
color: theme.colors.gray[5], color: theme.colors.gray[5],
marginLeft: theme.spacing.xs, marginLeft: theme.spacing.xs,
@ -58,31 +75,17 @@ const useStyles = createStyles((theme) => ({
})); }));
export default function Home() { 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 { 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 = () => { const scrollToBottom = () => {
chatViewportRef?.current?.scrollTo({ chatViewportRef?.current?.scrollTo({
top: chatViewportRef.current.scrollHeight, top: chatViewportRef.current.scrollHeight,
behavior: "smooth", 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(() => { useEffect(() => {
connect(); connect();
@ -106,21 +109,22 @@ export default function Home() {
return ( return (
<> <>
<Container pt="sm" size="md"> <Container size="md" h={"100vh"}>
<Card shadow="sm" padding="sm" radius="md" withBorder> <Card shadow="sm" padding="sm" radius="md" withBorder h={"100%"}>
<ChatroomTitle <Card.Section withBorder inheritPadding py="xs" h={"10%"}>
targetSocketId={targetSocketId} <ChatroomTitle
setTargetSocketId={setTargetSocketId} targetSocketId={targetSocketId}
/> setTargetSocketId={setTargetSocketId}
<Divider /> />
<ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}> </Card.Section>
<ScrollArea offsetScrollbars viewportRef={chatViewportRef} h={"85%"}>
{messages.map((message, index) => { {messages.map((message, index) => {
return ( return (
<div <div
className={ className={
message.me (message.me
? classes.rightMessageField ? classes.rightMessageField
: classes.leftMessageField : classes.leftMessageField) + " whitespace-pre"
} }
key={message.timestamp + index} key={message.timestamp + index}
> >
@ -138,26 +142,9 @@ export default function Home() {
); );
})} })}
</ScrollArea> </ScrollArea>
<Divider /> <Card.Section withBorder inheritPadding h={"10%"}>
<ChatroomInput targetSocketId={targetSocketId} />
<TextInput </Card.Section>
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> </Card>
</Container> </Container>
</> </>

View File

@ -10,8 +10,8 @@ type Prop = {
}; };
export const metadata = { export const metadata = {
title: "Tiny Socket.io demo", title: "A Simple Full-Stack Socket.io Demo",
description: "This repo implements a simple chat app with Socket.io and Next.js 13.", description: "This repo implements a simple chat app with Socket.io, Next.js 13, Mantine and Zustand.",
}; };
export default function RootLayout({ children }: Prop) { 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, Popover,
CopyButton, CopyButton,
Tooltip, Tooltip,
Avatar, Avatar as MantineAvatar,
Indicator, Indicator,
Menu, Menu,
} from "@mantine/core"; } from "@mantine/core";
@ -15,18 +15,15 @@ import {
IconPlug, IconPlug,
IconCheck, IconCheck,
IconCopy, IconCopy,
IconEdit,
IconPlugOff, IconPlugOff,
IconChevronDown, IconChevronDown,
IconUserCog, IconUserCog,
IconUser, IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { SetStateAction, Dispatch, FC, useEffect, useState } from "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 useSocketStore from "@/store/socket";
import { environment } from "@/config"; import { environment } from "@/config";
import Avatar from "./Avatar";
type Props = { type Props = {
targetSocketId: string; targetSocketId: string;
@ -35,18 +32,10 @@ type Props = {
const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => { const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
const { socket, connect, disconnect } = useSocketStore(); // deconstructing socket and its method from socket store 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 [popoverOpened, setPopoverOpened] = useState(false); // control popover open/close
const [onlineUsers, setOnlineUsers] = useState<Record<string, string>>({}); // online users 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(() => { useEffect(() => {
socket?.on("online_user", (onlineUsers: Record<string, string>) => { socket?.on("online_user", (onlineUsers: Record<string, string>) => {
setOnlineUsers(onlineUsers); setOnlineUsers(onlineUsers);
@ -59,20 +48,9 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
return ( return (
<> <>
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap"> <Group position="apart" mt="xs" mb="xs" noWrap h={"5vh"}>
<Group className="flex flex-row flex-nowrap"> <Group noWrap>
<Indicator <Avatar />
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>
<Popover <Popover
width="fit-content" width="fit-content"
@ -119,17 +97,6 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
<Group position="apart"> <Group position="apart">
<Text size="sm">Actions</Text> <Text size="sm">Actions</Text>
<Group className="gap-1"> <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 // if socket is not connected, show connect button
socket?.connected ? ( socket?.connected ? (
@ -151,11 +118,11 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
</Group> </Group>
<Group w={220} className="flex flex-row flex-nowrap"> <Group noWrap w={220}>
<Text w={20}>To:</Text> <Text w={20}>To:</Text>
<Input <Input
w={170} w={170}
placeholder="Your target Socket ID" placeholder="Target Socket ID"
value={targetSocketId} value={targetSocketId}
onChange={(e) => setTargetSocketId(e.currentTarget.value)} onChange={(e) => setTargetSocketId(e.currentTarget.value)}
/> />
@ -168,7 +135,9 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Label> <Menu.Label>
{environment === "development" ? "Not available in development" : "Online user"} {environment === "development"
? "Not available in development"
: "Online user"}
</Menu.Label> </Menu.Label>
{socket?.connected && {socket?.connected &&
Object.keys(onlineUsers) Object.keys(onlineUsers)
@ -190,7 +159,7 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
color="teal" color="teal"
withBorder withBorder
> >
<Avatar <MantineAvatar
src={null} src={null}
alt="User" alt="User"
color="blue" color="blue"
@ -198,7 +167,7 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
w="fit-content" w="fit-content"
> >
<IconUser size="1.5rem" /> <IconUser size="1.5rem" />
</Avatar> </MantineAvatar>
</Indicator> </Indicator>
<Text>{onlineUsers[socketId]}</Text> <Text>{onlineUsers[socketId]}</Text>
</Group> </Group>
@ -208,7 +177,6 @@ const ChatroomTitle: FC<Props> = ({ targetSocketId, setTargetSocketId }) => {
</Menu> </Menu>
</Group> </Group>
</Group> </Group>
<NameModal opened={modalOpened} onClose={modalClose} />
</> </>
); );
}; };