feat: optimized UI

main
Justin Xiao 2023-07-17 03:37:40 +08:00
parent 3adba5d69e
commit 206f895feb
8 changed files with 210 additions and 154 deletions

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { import {
Button, Button,
createStyles,
Card, Card,
Container, Container,
Group, Group,
@ -10,28 +11,83 @@ import {
ScrollArea, ScrollArea,
TextInput, TextInput,
ActionIcon, ActionIcon,
Popover,
CopyButton,
Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { IconArrowRight } from "@tabler/icons-react"; import {
IconArrowRight,
IconCheck,
IconCopy,
IconSettings,
IconEdit,
IconPlugOff,
} from "@tabler/icons-react";
import NameModal from "@/components/NameModal"; import NameModal from "@/components/NameModal";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import useSocketStore from "@/store/socket"; import useSocketStore from "@/store/socket";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState, useLayoutEffect } from "react";
import useBasicStore from "@/store/basic"; import useBasicStore from "@/store/basic";
import { SocketMessage } from "@/types/next";
const useStyles = createStyles((theme) => ({
rightMessageField: {
display: "flex",
flexDirection: "row-reverse",
flexWrap: "nowrap",
alignItems: "center",
width: "100%",
marginTop: theme.spacing.xs,
marginBottom: theme.spacing.xs,
},
rightMessage: {
width: "fit-content",
padding: theme.spacing.xs,
borderRadius: theme.radius.md,
backgroundColor: theme.colors.green[2],
},
leftMessageField: {
display: "flex",
flexDirection: "row",
flexWrap: "nowrap",
alignItems: "center",
width: "100%",
marginTop: theme.spacing.xs,
marginBottom: theme.spacing.xs,
},
leftMessage: {
width: "fit-content",
padding: theme.spacing.xs,
borderRadius: theme.radius.md,
backgroundColor: theme.colors.gray[2],
},
}));
export default function Home() { export default function Home() {
const storeName = useBasicStore((state) => state.name); const storeName = useBasicStore((state) => state.name);
const [socket, emit] = useSocketStore((state) => [state.socket, state.emit]); const { socket, emit, disconnect } = useSocketStore((state) => state);
const chatViewportRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error const [name, setName] = useState<string | null>(null); // avoiding Next.js hydration error
const [socketId, setSocketId] = useState<string | undefined>(); // avoiding Next.js hydration error
const [targetSocketId, setTargetSocketId] = useState<string>("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [messages, setMessages] = useState<string[]>([]); const [messages, setMessages] = useState<SocketMessage[]>([]);
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const { classes } = useStyles();
const scrollToBottom = () => {
console.log("scrollToBottom", chatViewportRef.current?.scrollHeight);
chatViewportRef?.current?.scrollTo({
top: chatViewportRef.current.scrollHeight,
behavior: "smooth",
});
};
const sendMessage = () => { const sendMessage = () => {
emit("message", message); if (!socketId || !message) return;
setMessages([...messages, message]); emit("message", { from: socketId, to: targetSocketId, timestamp: Date.now(), message });
setMessage(""); setMessage("");
inputRef.current?.focus(); inputRef.current?.focus();
}; };
@ -43,35 +99,116 @@ export default function Home() {
}, [storeName]); }, [storeName]);
useEffect(() => { useEffect(() => {
socket?.on("message", (data) => { setSocketId(socket?.id);
console.log("message", data); socket?.on("message", (message: SocketMessage) => {
console.log("message", message);
setMessages((state) => [...state, message]);
}); });
return () => { return () => {
socket?.off("message"); socket?.off("message");
}; };
}, [socket]); }, [socket]);
useLayoutEffect(() => {
scrollToBottom(); // DOM更新完畢後才執行scrollToBottom
}, [messages]);
return ( return (
<> <>
<Container pt="sm" size="md"> <Container pt="sm" size="md">
<Card shadow="sm" padding="sm" radius="md" withBorder> <Card shadow="sm" padding="sm" radius="md" withBorder>
<Group position="apart" mt="xs" mb="xs"> <Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
<Group> <Group className="flex flex-row flex-nowrap">
<Text size="xl" weight={500} suppressHydrationWarning={true}> <Text size="xl" weight={500}>
{name} {name}
</Text> </Text>
<Button size="xs" radius="md" onClick={open} variant="light"> <Popover width={170} position="bottom" withArrow shadow="md">
Change name <Popover.Target>
</Button> <ActionIcon variant="outline">
<IconSettings size="1rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Group className="justify-between">
<Text size="sm">SocketID</Text>
{socketId && (
<CopyButton value={socketId} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={
copied ? `Copied: ${socketId}` : "Copy"
}
withArrow
position="right"
>
<ActionIcon
color={copied ? "teal" : "gray"}
onClick={copy}
>
{copied ? (
<IconCheck size="1rem" />
) : (
<IconCopy size="1rem" />
)}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
)}
</Group>
<Group className="justify-between">
<Text size="sm">Actions</Text>
<Group className="gap-1">
<Tooltip label="Change name" withArrow position="right">
<ActionIcon color="yellow" onClick={open}>
<IconEdit size="1rem" />
</ActionIcon>
</Tooltip>
<Tooltip label="Disconnect" withArrow position="right">
<ActionIcon color="red" onClick={disconnect}>
<IconPlugOff size="1rem" />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Popover.Dropdown>
</Popover>
</Group>
<Group w={220} className="flex flex-row flex-nowrap">
<Text w={20}>To:</Text>
<Input
w={170}
placeholder="Your target Socket ID"
value={targetSocketId}
onChange={(e) => setTargetSocketId(e.target.value)}
/>
</Group> </Group>
<Input placeholder="Your target Socket ID" />
</Group> </Group>
<Divider /> <Divider />
<ScrollArea h={"80vh"} offsetScrollbars> <ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}>
{messages.map((message, index) => ( {messages.map((message, index) => {
<Text key={index}>{message}</Text> return (
))} <div
className={
message.from === socketId
? classes.rightMessageField
: classes.leftMessageField
}
key={message.timestamp + index}
>
<Text
className={
message.from === socketId
? classes.rightMessage
: classes.leftMessage
}
>
{message.message}
</Text>
</div>
);
})}
</ScrollArea> </ScrollArea>
<Divider /> <Divider />
@ -90,9 +227,7 @@ export default function Home() {
placeholder="Type something..." placeholder="Type something..."
rightSectionWidth={42} rightSectionWidth={42}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") sendMessage();
sendMessage();
}
}} }}
/> />
</Card> </Card>

View File

@ -15,6 +15,7 @@ export function AppProvider({ children }: Prop) {
console.log("disconnect"); console.log("disconnect");
disconnect(); disconnect();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
return ( return (

View File

@ -1,116 +0,0 @@
"use client";
import {
createStyles,
Badge,
Group,
Title,
Text,
Card,
SimpleGrid,
Container,
rem,
} from "@mantine/core";
import { IconGauge, IconUser, IconCookie } from "@tabler/icons-react";
const mockdata = [
{
title: "Extreme performance",
description:
"This dust is actually a powerful poison that will even make a pro wrestler sick, Regice cloaks itself with frigid air of -328 degrees Fahrenheit",
icon: IconGauge,
},
{
title: "Privacy focused",
description:
"People say it can run at the same speed as lightning striking, Its icy body is so cold, it will not melt even if it is immersed in magma",
icon: IconUser,
},
{
title: "No third parties",
description:
"Theyre popular, but theyre rare. Trainers who show them off recklessly may be targeted by thieves",
icon: IconCookie,
},
];
const useStyles = createStyles((theme) => ({
title: {
fontSize: rem(34),
fontWeight: 900,
[theme.fn.smallerThan("sm")]: {
fontSize: rem(24),
},
},
description: {
maxWidth: 600,
margin: "auto",
"&::after": {
content: '""',
display: "block",
backgroundColor: theme.fn.primaryColor(),
width: rem(45),
height: rem(2),
marginTop: theme.spacing.sm,
marginLeft: "auto",
marginRight: "auto",
},
},
card: {
border: `${rem(1)} solid ${
theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1]
}`,
},
cardTitle: {
"&::after": {
content: '""',
display: "block",
backgroundColor: theme.fn.primaryColor(),
width: rem(45),
height: rem(2),
marginTop: theme.spacing.sm,
},
},
}));
export default function FeaturesCards() {
const { classes, theme } = useStyles();
const features = mockdata.map((feature) => (
<Card key={feature.title} shadow="md" radius="md" className={classes.card} padding="xl">
<feature.icon size={rem(50)} stroke={2} color={theme.fn.primaryColor()} />
<Text fz="lg" fw={500} className={classes.cardTitle} mt="md">
{feature.title}
</Text>
<Text fz="sm" c="dimmed" mt="sm">
{feature.description}
</Text>
</Card>
));
return (
<Container size="lg" py="xl">
<Group position="center">
<Badge variant="filled" size="lg">
Best company ever
</Badge>
</Group>
<Title order={2} className={classes.title} ta="center" mt="sm">
Integrate effortlessly with any technology stack
</Title>
<Text c="dimmed" className={classes.description} ta="center" mt="md">
Every once in a while, youll see a Golbat thats missing some fangs. This happens
when hunger drives it to try biting a Steel-type Pokémon.
</Text>
<SimpleGrid cols={3} spacing="xl" mt={50} breakpoints={[{ maxWidth: "md", cols: 1 }]}>
{features}
</SimpleGrid>
</Container>
);
}

View File

@ -1,13 +1,29 @@
import { NextApiResponseServerIO } from "@/types/next"; import { NextApiResponseServerIO, SocketMessage } from "@/types/next";
import { NextApiRequest } from "next"; import { NextApiRequest } from "next";
const message = (req: NextApiRequest, res: NextApiResponseServerIO) => { const message = (req: NextApiRequest, res: NextApiResponseServerIO) => {
if (req.method === "POST") { if (req.method === "POST") {
// get message // get message
const message = req.body; const {
from: sourceSocketId,
to: targetSocketId,
timestamp,
message,
} = req.body as SocketMessage;
// dispatch to channel "message" // dispatch to channel "message"
res?.socket?.server?.io?.emit("message", message); res?.socket?.server?.io?.to(targetSocketId).emit("message", {
from: sourceSocketId,
to: targetSocketId,
message,
timestamp,
} as SocketMessage);
res?.socket?.server?.io?.to(sourceSocketId).emit("message", {
from: sourceSocketId,
to: targetSocketId,
message,
timestamp,
} as SocketMessage);
// return message // return message
res.status(201).json(message); res.status(201).json(message);

View File

@ -18,9 +18,11 @@ const socketio = async (req: NextApiRequest, res: NextApiResponseServerIO) => {
path: "/api/socket/socketio", path: "/api/socket/socketio",
addTrailingSlash: false, addTrailingSlash: false,
}); });
io.on("connect", () => { io.on("connect", (socket) => {
console.log("SOCKET CONNECTED!"); console.log("SOCKET CONNECTED!", socket.id);
}) }).on("disconnect", () => {
console.log("SOCKET DISCONNECTED!");
});
// append SocketIO server to Next.js socket server response // append SocketIO server to Next.js socket server response
res.socket.server.io = io; res.socket.server.io = io;
} else { } else {

View File

@ -1,4 +1,5 @@
import { SOCKET_URL } from "@/config"; import { SOCKET_URL } from "@/config";
import { SocketMessage } from "@/types/next";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
import { create } from "zustand"; import { create } from "zustand";
@ -6,7 +7,7 @@ import { create } from "zustand";
type Store = { type Store = {
socketReady: boolean; socketReady: boolean;
socket: null | Socket; socket: null | Socket;
emit: (event: string, data: any) => void; emit: (event: string, data: SocketMessage) => void;
disconnect: () => void; disconnect: () => void;
}; };
@ -32,7 +33,8 @@ const useSocketStore = create<Store>((set, get) => {
return { return {
socketReady: false, socketReady: false,
socket: socket, socket: socket,
emit: async (event: string, data: any) => { emit: async (event: string, data: SocketMessage) => {
console.log("emit", event, data);
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
try { try {
const response = await fetch("/api/socket/message", { const response = await fetch("/api/socket/message", {
@ -42,14 +44,13 @@ const useSocketStore = create<Store>((set, get) => {
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
console.log("response", response);
} catch (error) { } catch (error) {
if (error instanceof Error) toast.error(error?.message); if (error instanceof Error) toast.error(error?.message);
} }
} else { } else {
// This response needs to define on server at first. // This response needs to define on server at first.
socket.emit(event, data, (response: any) => { socket.emit(event, data, (response: { ok: boolean }) => {
console.log(response.status); // ok if (!response.ok) toast.error("Something went wrong");
}); });
} }
}, },
@ -58,6 +59,8 @@ const useSocketStore = create<Store>((set, get) => {
if (socket) { if (socket) {
socket.disconnect(); socket.disconnect();
set({ socket: null }); set({ socket: null });
} else {
toast.error("Socket not connected");
} }
}, },
}; };

View File

@ -9,3 +9,10 @@ export type NextApiResponseServerIO = NextApiResponse & {
}; };
}; };
}; };
export type SocketMessage = {
from: string;
to: string;
message: string;
timestamp: number;
};

View File

@ -15,12 +15,20 @@ const userSockets = new Map();
io.on("connection", (socket) => { io.on("connection", (socket) => {
console.log(socket.id); console.log(socket.id);
socket.on("join", (userId) => {
userSockets.set(userId, socket.id);
console.log(userSockets);
});
socket.on("message", (message, callback) => { socket.on("message", (message, callback) => {
console.log(message); const { from: sourceSocketId, to: targetSocketId } = message;
io.emit("message", message); io.to(targetSocketId).emit("message", message);
callback({ io.to(sourceSocketId).emit("message", message);
status: "ok", if (callback) {
}); callback({
ok: true,
});
}
}); });
socket.on("disconnect", () => { socket.on("disconnect", () => {