feat: optimized UI
parent
3adba5d69e
commit
206f895feb
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import {
|
||||
Button,
|
||||
createStyles,
|
||||
Card,
|
||||
Container,
|
||||
Group,
|
||||
|
@ -10,28 +11,83 @@ import {
|
|||
ScrollArea,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Popover,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
} 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 { useDisclosure } from "@mantine/hooks";
|
||||
import useSocketStore from "@/store/socket";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, useLayoutEffect } from "react";
|
||||
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() {
|
||||
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 [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 [messages, setMessages] = useState<string[]>([]);
|
||||
const [messages, setMessages] = useState<SocketMessage[]>([]);
|
||||
|
||||
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 = () => {
|
||||
emit("message", message);
|
||||
setMessages([...messages, message]);
|
||||
if (!socketId || !message) return;
|
||||
emit("message", { from: socketId, to: targetSocketId, timestamp: Date.now(), message });
|
||||
setMessage("");
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
@ -43,35 +99,116 @@ export default function Home() {
|
|||
}, [storeName]);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("message", (data) => {
|
||||
console.log("message", data);
|
||||
setSocketId(socket?.id);
|
||||
socket?.on("message", (message: SocketMessage) => {
|
||||
console.log("message", message);
|
||||
setMessages((state) => [...state, message]);
|
||||
});
|
||||
return () => {
|
||||
socket?.off("message");
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollToBottom(); // DOM更新完畢後,才執行scrollToBottom
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container pt="sm" size="md">
|
||||
<Card shadow="sm" padding="sm" radius="md" withBorder>
|
||||
<Group position="apart" mt="xs" mb="xs">
|
||||
<Group>
|
||||
<Text size="xl" weight={500} suppressHydrationWarning={true}>
|
||||
<Group position="apart" mt="xs" mb="xs" className="flex flex-row flex-nowrap">
|
||||
<Group className="flex flex-row flex-nowrap">
|
||||
<Text size="xl" weight={500}>
|
||||
{name}
|
||||
</Text>
|
||||
<Button size="xs" radius="md" onClick={open} variant="light">
|
||||
Change name
|
||||
</Button>
|
||||
<Popover width={170} position="bottom" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<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>
|
||||
<Input placeholder="Your target Socket ID" />
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
<ScrollArea h={"80vh"} offsetScrollbars>
|
||||
{messages.map((message, index) => (
|
||||
<Text key={index}>{message}</Text>
|
||||
))}
|
||||
<ScrollArea h={"80vh"} offsetScrollbars viewportRef={chatViewportRef}>
|
||||
{messages.map((message, index) => {
|
||||
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>
|
||||
<Divider />
|
||||
|
||||
|
@ -90,9 +227,7 @@ export default function Home() {
|
|||
placeholder="Type something..."
|
||||
rightSectionWidth={42}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
sendMessage();
|
||||
}
|
||||
if (e.key === "Enter") sendMessage();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
|
|
@ -15,6 +15,7 @@ export function AppProvider({ children }: Prop) {
|
|||
console.log("disconnect");
|
||||
disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
|
@ -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:
|
||||
"They’re popular, but they’re 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, you’ll see a Golbat that’s 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>
|
||||
);
|
||||
}
|
|
@ -1,13 +1,29 @@
|
|||
import { NextApiResponseServerIO } from "@/types/next";
|
||||
import { NextApiResponseServerIO, SocketMessage } from "@/types/next";
|
||||
import { NextApiRequest } from "next";
|
||||
|
||||
const message = (req: NextApiRequest, res: NextApiResponseServerIO) => {
|
||||
if (req.method === "POST") {
|
||||
// get message
|
||||
const message = req.body;
|
||||
const {
|
||||
from: sourceSocketId,
|
||||
to: targetSocketId,
|
||||
timestamp,
|
||||
message,
|
||||
} = req.body as SocketMessage;
|
||||
|
||||
// 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
|
||||
res.status(201).json(message);
|
||||
|
|
|
@ -18,9 +18,11 @@ const socketio = async (req: NextApiRequest, res: NextApiResponseServerIO) => {
|
|||
path: "/api/socket/socketio",
|
||||
addTrailingSlash: false,
|
||||
});
|
||||
io.on("connect", () => {
|
||||
console.log("SOCKET CONNECTED!");
|
||||
})
|
||||
io.on("connect", (socket) => {
|
||||
console.log("SOCKET CONNECTED!", socket.id);
|
||||
}).on("disconnect", () => {
|
||||
console.log("SOCKET DISCONNECTED!");
|
||||
});
|
||||
// append SocketIO server to Next.js socket server response
|
||||
res.socket.server.io = io;
|
||||
} else {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { SOCKET_URL } from "@/config";
|
||||
import { SocketMessage } from "@/types/next";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { create } from "zustand";
|
||||
|
@ -6,7 +7,7 @@ import { create } from "zustand";
|
|||
type Store = {
|
||||
socketReady: boolean;
|
||||
socket: null | Socket;
|
||||
emit: (event: string, data: any) => void;
|
||||
emit: (event: string, data: SocketMessage) => void;
|
||||
disconnect: () => void;
|
||||
};
|
||||
|
||||
|
@ -32,7 +33,8 @@ const useSocketStore = create<Store>((set, get) => {
|
|||
return {
|
||||
socketReady: false,
|
||||
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") {
|
||||
try {
|
||||
const response = await fetch("/api/socket/message", {
|
||||
|
@ -42,14 +44,13 @@ const useSocketStore = create<Store>((set, get) => {
|
|||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
console.log("response", response);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) toast.error(error?.message);
|
||||
}
|
||||
} else {
|
||||
// This response needs to define on server at first.
|
||||
socket.emit(event, data, (response: any) => {
|
||||
console.log(response.status); // ok
|
||||
socket.emit(event, data, (response: { ok: boolean }) => {
|
||||
if (!response.ok) toast.error("Something went wrong");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -58,6 +59,8 @@ const useSocketStore = create<Store>((set, get) => {
|
|||
if (socket) {
|
||||
socket.disconnect();
|
||||
set({ socket: null });
|
||||
} else {
|
||||
toast.error("Socket not connected");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -9,3 +9,10 @@ export type NextApiResponseServerIO = NextApiResponse & {
|
|||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SocketMessage = {
|
||||
from: string;
|
||||
to: string;
|
||||
message: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
|
|
@ -15,12 +15,20 @@ const userSockets = new Map();
|
|||
io.on("connection", (socket) => {
|
||||
console.log(socket.id);
|
||||
|
||||
socket.on("join", (userId) => {
|
||||
userSockets.set(userId, socket.id);
|
||||
console.log(userSockets);
|
||||
});
|
||||
|
||||
socket.on("message", (message, callback) => {
|
||||
console.log(message);
|
||||
io.emit("message", message);
|
||||
callback({
|
||||
status: "ok",
|
||||
});
|
||||
const { from: sourceSocketId, to: targetSocketId } = message;
|
||||
io.to(targetSocketId).emit("message", message);
|
||||
io.to(sourceSocketId).emit("message", message);
|
||||
if (callback) {
|
||||
callback({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
|
|
Loading…
Reference in New Issue