feat: extract input and optimize ui
parent
7d80c2b8f6
commit
92c013ad08
|
@ -1 +1,9 @@
|
|||
# nextjs13-socketio-boilerplate
|
||||
|
||||
[](./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
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue