feat: optimized UI
parent
3adba5d69e
commit
206f895feb
|
@ -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>
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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";
|
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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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) => {
|
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", () => {
|
||||||
|
|
Loading…
Reference in New Issue