feat: lots of updates

This commit is contained in:
zerosoul
2022-01-27 22:06:44 +08:00
commit e18c7323a6
72 changed files with 12432 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
const BASE_URL = `http://rustchat.com:3000/api`;
export const tokenHeader = "X-API-Key";
export default BASE_URL;
+25
View File
@@ -0,0 +1,25 @@
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
import BASE_URL from "../config";
export const authApi = createApi({
reducerPath: "auth",
baseQuery: fetchBaseQuery({ baseUrl: BASE_URL }),
endpoints: (builder) => ({
login: builder.mutation({
query: (credentials) => ({
url: "token/login",
method: "POST",
body: credentials,
}),
// transformResponse: (resp) => {
// console.log("resp", resp);
// if (resp.status == 401) {
// resp.msg = "username or password incorrect";
// }
// return resp;
// },
}),
}),
});
export const { useLoginMutation } = authApi;
+40
View File
@@ -0,0 +1,40 @@
import { fetchBaseQuery } from "@reduxjs/toolkit/query";
import BASE_URL, { tokenHeader } from "../config";
// const whiteList = [
// "/resource/avatar",
// "/resource/company/logo",
// "/resource/thumbnail",
// "/resource/image",
// "/token/login",
// "/token/renew",
// "/user",
// "/admin/system/company",
// ];
const baseQuery = fetchBaseQuery({
baseUrl: BASE_URL,
prepareHeaders: (headers, { getState }) => {
const { token } = getState().authData;
if (token) {
headers.set(tokenHeader, token);
}
return headers;
},
});
const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await baseQuery(args, api, extraOptions);
if (result.error && result.error.status === 401) {
// try to get a new token with refreshToken
// const refreshResult = await baseQuery('/refreshToken', api, extraOptions);
// if (refreshResult.data) {
// // store the new token
// api.dispatch(tokenReceived(refreshResult.data));
// // retry the initial query
// result = await baseQuery(args, api, extraOptions);
// } else {
// api.dispatch(loggedOut());
// }
}
return result;
};
export default baseQueryWithReauth;
+38
View File
@@ -0,0 +1,38 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import baseQuery from "./base.query";
import { REHYDRATE } from "redux-persist";
export const channelApi = createApi({
reducerPath: "groups",
baseQuery,
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === REHYDRATE) {
return action.payload ? action.payload[reducerPath] : undefined;
}
},
refetchOnFocus: true,
endpoints: (builder) => ({
getChannels: builder.query({
query: () => ({ url: `group` }),
}),
createChannel: builder.mutation({
query: (data) => ({
url: "group",
method: "POST",
body: data,
}),
}),
sendChannelMsg: builder.mutation({
query: ({ gid, message }) => ({
url: `group/${gid}/send`,
method: "POST",
body: message,
}),
}),
}),
});
export const {
useGetChannelsQuery,
useCreateChannelMutation,
useSendChannelMsgMutation,
} = channelApi;
+34
View File
@@ -0,0 +1,34 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import baseQuery from "./base.query";
import BASE_URL from "../config";
import { REHYDRATE } from "redux-persist";
export const contactApi = createApi({
reducerPath: "contacts",
baseQuery,
extractRehydrationInfo(action, { reducerPath }) {
if (action.type === REHYDRATE) {
return action.payload ? action.payload[reducerPath] : undefined;
}
},
endpoints: (builder) => ({
getContacts: builder.query({
query: () => ({ url: `user` }),
transformResponse: (data) => {
return data.map((user) => {
const avatar = `${BASE_URL}/resource/avatar?uid=${user.uid}`;
user.avatar = avatar;
return user;
});
},
}),
sendMsg: builder.mutation({
query: ({ uid, message }) => ({
url: `user/${uid}/send`,
method: "POST",
body: message,
}),
}),
}),
});
export const { useGetContactsQuery, useSendMsgMutation } = contactApi;
+26
View File
@@ -0,0 +1,26 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import BASE_URL from "../config";
// import { REHYDRATE } from 'redux-persist';
import baseQuery from "./base.query";
export const serverApi = createApi({
reducerPath: "server",
baseQuery,
// extractRehydrationInfo(action, { reducerPath }) {
// if (action.type === REHYDRATE) {
// return action.payload ? action.payload[reducerPath] : undefined;
// }
// },
endpoints: (builder) => ({
getServer: builder.query({
query: () => ({ url: `admin/system/company` }),
transformResponse: (data) => {
data.logo = `${BASE_URL}/resource/company/logo`;
return data;
},
}),
}),
});
export const { useGetServerQuery } = serverApi;
+21
View File
@@ -0,0 +1,21 @@
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
user: null,
token: null,
refreshToken: null,
};
const authDataSlice = createSlice({
name: "authData",
initialState,
reducers: {
setAuthData(state, action) {
const { user, token, refresh_token } = action.payload;
state.user = user;
state.token = token;
state.refreshToken = refresh_token;
},
},
});
export const { setAuthData } = authDataSlice.actions;
export default authDataSlice.reducer;
+40
View File
@@ -0,0 +1,40 @@
import { createSlice } from "@reduxjs/toolkit";
import { isObjectEqual } from "../../common/utils";
const initialState = {};
const channelMsgSlice = createSlice({
name: "channelMessage",
initialState,
reducers: {
addChannelMsg(state, action) {
const { id, content, created_at, mid, from_uid } = action.payload;
const newMsg = { content, created_at, from_uid };
if (state[id]) {
let replaceMsg = state[id][mid];
// 如果存在,并且新消息和缓存消息不一样,则替换掉
if (replaceMsg) {
const copyMsg = { ...replaceMsg };
if (!isObjectEqual(copyMsg, newMsg)) {
state[id][mid] = newMsg;
}
} else {
state[id][mid] = newMsg;
}
// let replaceIdx = state[id].findIndex((m) => m.mid == mid);
// if (replaceIdx > -1) {
// const copyMsg = { ...state[id][replaceIdx] };
// console.log("current channel msg", copyMsg, newMsg);
// if (!isObjectEqual(copyMsg, newMsg)) {
// state[id][replaceIdx] = newMsg;
// }
// } else {
// state[id] = [...state[id], newMsg];
// }
} else {
state[id] = { [mid]: newMsg };
}
},
},
});
export const { addChannelMsg } = channelMsgSlice.actions;
export default channelMsgSlice.reducer;
+40
View File
@@ -0,0 +1,40 @@
import { createSlice } from "@reduxjs/toolkit";
import { isObjectEqual } from "../../common/utils";
const initialState = {};
const userMsgSlice = createSlice({
name: "userMessage",
initialState,
reducers: {
addUserMsg(state, action) {
const { id, content, created_at, mid, from_uid } = action.payload;
const newMsg = { content, created_at, from_uid };
if (state[id]) {
let replaceMsg = state[id][mid];
// 如果存在,并且新消息和缓存消息不一样,则替换掉
if (replaceMsg) {
const copyMsg = { ...replaceMsg };
if (!isObjectEqual(copyMsg, newMsg)) {
state[id][mid] = newMsg;
}
} else {
state[id][mid] = newMsg;
}
// let replaceIdx = state[id].findIndex((m) => m.mid == mid);
// if (replaceIdx > -1) {
// const copyMsg = { ...state[id][replaceIdx] };
// console.log("current user msg", copyMsg, newMsg);
// if (!isObjectEqual(copyMsg, newMsg)) {
// state[id][replaceIdx] = newMsg;
// }
// } else {
// state[id] = [...state[id], newMsg];
// }
} else {
state[id] = { [mid]: newMsg };
}
},
},
});
export const { addUserMsg } = userMsgSlice.actions;
export default userMsgSlice.reducer;
+52
View File
@@ -0,0 +1,52 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import { setupListeners } from "@reduxjs/toolkit/query";
import storage from "redux-persist/lib/storage";
import {
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist";
import authDataReducer from "./slices/auth.data";
import channelMsgReducer from "./slices/message.channel";
import userMsgReducer from "./slices/message.user";
import { authApi } from "./services/auth";
import { contactApi } from "./services/contact";
import { channelApi } from "./services/channel";
import { serverApi } from "./services/server";
const persistConfig = {
key: "root",
version: 1,
storage,
};
const persistedReducer = persistReducer(
persistConfig,
combineReducers({
userMsg: userMsgReducer,
channelMsg: channelMsgReducer,
authData: authDataReducer,
[authApi.reducerPath]: authApi.reducer,
[contactApi.reducerPath]: contactApi.reducer,
[channelApi.reducerPath]: channelApi.reducer,
[serverApi.reducerPath]: serverApi.reducer,
})
);
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}).concat(
authApi.middleware,
contactApi.middleware,
channelApi.middleware,
serverApi.middleware
),
});
setupListeners(store.dispatch);
export default store;
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.7207 25.7835C13.4371 27.17 15.6215 28.0002 17.9998 28.0002C19.5087 28.0002 20.9418 27.6654 22.2269 27.0653L26.8037 27.9809C27.1315 28.0464 27.4705 27.9438 27.7069 27.7074C27.9433 27.471 28.046 27.132 27.9804 26.8042L27.065 22.227C27.665 20.942 27.9998 19.509 27.9998 18.0002C27.9998 15.6224 27.1699 13.4385 25.7839 11.7222C25.9257 12.4597 25.9999 13.2213 25.9999 14.0002C25.9999 14.7535 25.9305 15.4906 25.7977 16.2055C25.93 16.7824 25.9998 17.3832 25.9998 18.0002C25.9998 19.3117 25.685 20.5466 25.1278 21.6365C25.0252 21.8373 24.9934 22.0668 25.0376 22.2879L25.7251 25.7255L22.2878 25.0379C22.0667 24.9936 21.8371 25.0254 21.6364 25.1281C20.5464 25.6854 19.3113 26.0002 17.9998 26.0002C17.3827 26.0002 16.7819 25.9304 16.2049 25.7981C15.4901 25.9308 14.7531 26.0002 13.9999 26.0002C13.2215 26.0002 12.4594 25.9258 11.7207 25.7835ZM14 4C8.47717 4 4.00002 8.47715 4.00002 14C4.00002 15.5088 4.3348 16.9418 4.93483 18.2267L4.01943 22.8039C3.95386 23.1318 4.05649 23.4707 4.29293 23.7072C4.52936 23.9436 4.86831 24.0462 5.19617 23.9806L9.77298 23.0651C11.058 23.6652 12.4911 24 14 24C19.5229 24 24 19.5228 24 14C24 8.47715 19.5229 4 14 4Z" fill="#4B5563"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.99995 9.10619C4.6356 9.10619 4.29404 9.20378 4 9.37426C3.40416 9.71971 3.00342 10.3644 3.00342 11.1027C3.00342 11.841 3.40416 12.4857 4 12.8312C4.29404 13.0017 4.6356 13.0993 4.99995 13.0993C6.10261 13.0993 6.99649 12.2054 6.99649 11.1027C6.99649 10.0001 6.10261 9.10619 4.99995 9.10619ZM4.99995 7.10619C4.77861 7.10619 4.56147 7.12418 4.34992 7.15878C5.08315 5.30851 6.88877 4 9 4H23C25.7614 4 28 6.23858 28 9V23C28 25.7614 25.7614 28 23 28H9C6.23858 28 4 25.7614 4 23V14.9731C4.31959 15.0555 4.65466 15.0993 4.99995 15.0993C7.20718 15.0993 8.99649 13.3099 8.99649 11.1027C8.99649 8.8955 7.20718 7.10619 4.99995 7.10619ZM11 13C11 13.5523 11.4477 14 12 14H20C20.5523 14 21 13.5523 21 13C21 12.4477 20.5523 12 20 12H12C11.4477 12 11 12.4477 11 13ZM11 19C11 19.5523 11.4477 20 12 20H17.2278C17.7801 20 18.2278 19.5523 18.2278 19C18.2278 18.4477 17.7801 18 17.2278 18H12C11.4477 18 11 18.4477 11 19Z" fill="#4B5563"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+35
View File
@@ -0,0 +1,35 @@
import { useState, useEffect } from "react";
export default function Avatar({ url, id = 0, ...rest }) {
const [src, setSrc] = useState(url);
const [loaded, setLoaded] = useState(false);
const handleLoaded = () => {
setLoaded(true);
};
const handleError = () => {
if (src.indexOf("avatars.dicebear.com") > -1) return;
setSrc(`https://avatars.dicebear.com/api/adventurer-neutral/${id}.svg`);
};
useEffect(() => {
setLoaded(false);
setSrc(url);
}, [url]);
useEffect(() => {
const inter = setTimeout(() => {
if (!loaded) {
handleError();
}
}, 500);
return () => {
clearTimeout(inter);
};
}, [loaded]);
return (
<img onLoad={handleLoaded} src={src} onError={handleError} {...rest} />
);
}
+49
View File
@@ -0,0 +1,49 @@
// import React from 'react';
const HashIcon = ({ size = 20, color = "#616161", ...rest }) => {
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
{...rest}
>
<path
d="M6.48667 11.6667L6.83667 8.33333H3.325V6.66667H7L7.43333 2.5H9.10833L8.66667 6.66667H11.9833L12.4167 2.5H14.0917L13.65 6.66667H16.625V8.33333H13.4667L13.1167 11.6667H16.6167V13.3333H12.9333L12.4917 17.5H10.8083L11.2417 13.3333H7.91667L7.475 17.5H5.8L6.23333 13.3333H3.25V11.6667H6.4H6.48667ZM8.1625 11.6667H11.4875L11.8375 8.33333H8.5125L8.1625 11.6667Z"
fill={color}
/>
</svg>
);
};
const PrivateHashIcon = ({ size = 20, color = "#616161", ...rest }) => {
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
fill="none"
{...rest}
>
<path
d="M6.83667 8.33333L6.48667 11.6667H6.4H3.25V13.3333H6.23333L5.8 17.5H7.475L7.91667 13.3333H11.2417L10.8083 17.5H12.4917L12.9333 13.3333H16.6167V11.6667H13.1167L13.2917 10H11.6625L11.4875 11.6667H8.1625L8.5125 8.33333H10.8334V6.66667H8.66667L9.10833 2.5H7.43333L7 6.66667H3.325V8.33333H6.83667Z"
fill={color}
/>
<path
d="M16.6875 4.16663V3.33329C16.6875 2.39996 15.875 1.66663 15 1.66663C14.125 1.66663 13.3333 2.39996 13.3333 3.33329V4.16663C12.8731 4.16663 12.5 4.53973 12.5 4.99996V7.49996C12.5 7.96019 12.8731 8.33329 13.3333 8.33329H15H16.6667C17.1269 8.33329 17.5 7.96019 17.5 7.49996V4.97913C17.5 4.53039 17.1362 4.16663 16.6875 4.16663ZM15.8333 4.16663H14.1667V3.33329C14.1667 2.8571 14.5556 2.49996 15 2.49996C15.4444 2.49996 15.8333 2.8571 15.8333 3.33329V4.16663Z"
fill={color}
/>
</svg>
);
};
export default function ChannelIcon({
personal = false,
size = 20,
color = "#616161",
...rest
}) {
return personal ? (
<PrivateHashIcon size={size} color={color} {...rest} />
) : (
<HashIcon size={size} color={color} {...rest} />
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useState, useEffect } from "react";
import toast from "react-hot-toast";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import Modal from "../Modal";
import ChannelIcon from "../ChannelIcon";
import Contact from "../Contact";
import StyledWrapper from "./styled";
import useFilteredUsers from "../../hook/useFilteredUsers";
import { useGetChannelsQuery } from "../../../app/services/channel";
import { useCreateChannelMutation } from "../../../app/services/channel";
export default function ChannelModal({ personal = false, closeModal }) {
const navigateTo = useNavigate();
const [data, setData] = useState({
name: "",
dsecription: "",
members: [],
is_public: !personal,
});
const { contacts, input, updateInput } = useFilteredUsers();
const { refetch: refetchChannels } = useGetChannelsQuery();
const [
createChannel,
{ isSuccess, isError, isLoading, data: newChannel },
] = useCreateChannelMutation();
const currentUser = useSelector((state) => {
return state.authData.user;
});
const handleToggle = () => {
const { is_public } = data;
setData((prev) => {
return { ...prev, is_public: !is_public };
});
};
const handleCreate = () => {
if (!data.name) {
toast("please input channel name");
return;
}
createChannel(data);
};
useEffect(() => {
if (isError) {
toast.error("create new channel failed");
}
}, [isError]);
useEffect(() => {
if (isSuccess) {
toast.success("create new channel success");
closeModal();
refetchChannels();
navigateTo(`/chat/channel/${newChannel.gid}`);
}
}, [isSuccess, newChannel]);
const handleNameInput = (evt) => {
setData((prev) => {
return { ...prev, name: evt.target.value };
});
};
const handleInputChange = (evt) => {
updateInput(evt.target.value);
};
const toggleCheckMember = ({ currentTarget }) => {
const { members } = data;
const { uid } = currentTarget.dataset;
let tmp = members.includes(+uid)
? members.filter((m) => m != uid)
: [...members, +uid];
console.log(uid, currentTarget);
setData((prev) => {
return { ...prev, members: tmp };
});
console.log({ data });
};
console.log("contacts", contacts);
if (!currentUser) return null;
const { name, members, is_public } = data;
return (
<Modal>
<StyledWrapper>
{!is_public && (
<div className="left">
<div className="search">
<input
value={input}
onChange={handleInputChange}
placeholder="Type username to search"
/>
</div>
{contacts && (
<ul className="users">
{contacts.map((u) => {
const { uid } = u;
const checked = members.includes(uid);
console.log({ checked });
return (
<li
key={uid}
data-uid={uid}
className="user"
onClick={toggleCheckMember}
>
<input
readOnly
checked={checked}
type="checkbox"
name="cb"
id="cb"
/>
<Contact uid={uid} interactive={false} />
</li>
);
})}
</ul>
)}
</div>
)}
<div className={`right ${!is_public ? "private" : ""}`}>
<h3 className="title">Create New Channel</h3>
<p className="desc normal">
{!is_public
? "This is a private channel, only select members will bee able to join."
: "This is a public channel, everyone in this team can see it."}
</p>
<div className="name">
<span className="label normal">Channel Name</span>
<div className="input">
<input
onChange={handleNameInput}
value={name}
placeholder="new channel"
/>
<ChannelIcon personal={!is_public} className="icon" />
</div>
</div>
{/* admin or */}
{
<div className="private">
<span className="txt normal">Private Channel</span>
<input
disabled={!currentUser?.is_admin}
onChange={handleToggle}
checked={!is_public}
type="checkbox"
name="private"
id="private"
/>
</div>
}
<div className="btns">
<button onClick={closeModal} className="btn normal cancel">
Cancel
</button>
<button
disabled={isLoading}
onClick={handleCreate}
className="btn normal create"
>
Create
</button>
</div>
</div>
</StyledWrapper>
</Modal>
);
}
+144
View File
@@ -0,0 +1,144 @@
import styled from "styled-components";
const StyledWrapper = styled.div`
display: flex;
/* max-width: 604px; */
max-height: 402px;
background: #fff;
box-shadow: 0px 25px 50px rgba(31, 41, 55, 0.25);
border-radius: 8px;
transition: all 0.5s ease;
.left {
width: 260px;
height: 100%;
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
overflow-y: scroll;
.search {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
padding: 8px;
width: -webkit-fill-available;
input {
outline: none;
width: -webkit-fill-available;
padding: 8px;
font-size: 14px;
line-height: 20px;
border: none;
}
}
.users {
display: flex;
flex-direction: column;
height: 260px;
padding-bottom: 20px;
overflow-y: scroll;
.user {
cursor: pointer;
display: flex;
align-items: center;
padding: 0 16px;
width: -webkit-fill-available;
> div {
width: 100%;
}
}
}
}
.right {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
box-sizing: border-box;
&.private {
width: 344px;
.desc {
text-align: center;
/* padding: 0 20px; */
}
}
> .title {
font-weight: 600;
font-size: 20px;
line-height: 30px;
color: #374151;
margin-bottom: 16px;
}
.desc {
font-weight: normal;
color: #6b7280;
margin-bottom: 48px;
}
.name {
width: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 8px;
margin-bottom: 34px;
.label {
font-weight: 600;
color: #6b7280;
}
.input {
position: relative;
input {
width: -webkit-fill-available;
border: 1px solid #e5e7eb;
box-shadow: 0px 1px 2px rgba(31, 41, 55, 0.08);
border-radius: 4px;
padding: 8px;
color: #78787c;
padding-left: 36px;
}
.icon {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
}
}
}
.private {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 50px;
.txt {
font-weight: 600;
color: #6b7280;
}
input {
cursor: pointer;
}
}
.btns {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
.btn {
cursor: pointer;
padding: 8px 16px;
background: none;
border: 1px solid #e5e7eb;
box-shadow: 0px 1px 2px rgba(31, 41, 55, 0.08);
border-radius: 4px;
font-weight: 500;
color: #374151;
&.create {
border: none;
background: #1fe1f9;
color: #fff;
}
}
}
.normal {
font-size: 14px;
line-height: 20px;
}
}
`;
export default StyledWrapper;
+69
View File
@@ -0,0 +1,69 @@
// import React from 'react';
import styled from "styled-components";
import Avatar from "./Avatar";
import { useGetContactsQuery } from "../../app/services/contact";
const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 8px;
border-radius: 4px;
user-select: none;
&.interactive {
&:hover,
&.active {
background: rgba(116, 127, 141, 0.1);
}
}
.avatar {
width: 32px;
height: 32px;
position: relative;
img {
border-radius: 50%;
width: 100%;
height: 100%;
}
.status {
position: absolute;
bottom: 0;
right: -2px;
width: 10px;
height: 10px;
border-radius: 50%;
outline: 2px solid #fff;
background-color: #22c55e;
&.offline {
background-color: #a1a1aa;
}
&.alert {
background-color: #dc2626;
}
}
}
.name {
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #52525b;
}
`;
export default function Contact({ interactive = true, status = "", uid = "" }) {
const { data: contacts } = useGetContactsQuery();
if (!contacts) return null;
const currUser = contacts.find((c) => c.uid == uid);
return (
<StyledWrapper
className={`${interactive ? "interactive" : ""}`}
title={currUser?.email}
>
<div className="avatar">
<Avatar url={currUser?.avatar} id={uid} alt="avatar" />
<div className={`status ${status}`}></div>
</div>
<span className="name">{currUser?.name}</span>
</StyledWrapper>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useRef } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import { NavLink } from "react-router-dom";
import { useOutsideClick } from "rooks";
import useFilteredUsers from "../hook/useFilteredUsers";
import Contact from "./Contact";
import Modal from "./Modal";
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 440px;
max-height: 402px;
background: #fff;
box-shadow: 0px 25px 50px rgba(31, 41, 55, 0.25);
border-radius: 8px;
transition: all 0.5s ease;
overflow-y: scroll;
.search {
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
padding: 8px;
width: -webkit-fill-available;
input {
outline: none;
width: -webkit-fill-available;
padding: 8px;
font-size: 14px;
line-height: 20px;
border: none;
}
}
.users {
display: flex;
flex-direction: column;
height: 260px;
padding: 16px 0;
overflow-y: scroll;
.user {
cursor: pointer;
display: flex;
align-items: center;
padding: 0 8px;
width: -webkit-fill-available;
&:hover {
background: rgba(116, 127, 141, 0.1);
}
> a {
width: 100%;
}
}
}
`;
export default function ContactsModal({ closeModal }) {
const wrapperRef = useRef();
const { contacts, updateInput, input } = useFilteredUsers();
const currentUser = useSelector((state) => {
return state.authData.user;
});
useOutsideClick(wrapperRef, closeModal);
const handleSearch = (evt) => {
updateInput(evt.target.value);
};
if (!currentUser) return null;
return (
<Modal>
<StyledWrapper ref={wrapperRef}>
<div className="search">
<input
value={input}
onChange={handleSearch}
placeholder="Type username to search"
/>
</div>
{contacts && (
<ul className="users">
{contacts.map((u) => {
const { uid } = u;
return (
<li key={uid} className="user">
<NavLink onClick={closeModal} to={`/chat/dm/${uid}`}>
<Contact uid={uid} interactive={false} />
</NavLink>
</li>
);
})}
</ul>
)}
</StyledWrapper>
</Modal>
);
}
+68
View File
@@ -0,0 +1,68 @@
// import React from 'react';
import styled from "styled-components";
import dayjs from "dayjs";
import Avatar from "./Avatar";
import { useGetContactsQuery } from "../../app/services/contact";
const StyledMsg = styled.div`
display: flex;
align-items: flex-start;
gap: 16px;
padding: 12px 0;
.avatar {
img {
width: 40px;
height: 40px;
border-radius: 50%;
}
}
.details {
display: flex;
flex-direction: column;
gap: 8px;
.up {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
.name {
color: #06b6d4;
font-style: normal;
font-size: 14px;
line-height: 20px;
}
.time {
color: #bfbfbf;
font-size: 12px;
line-height: 18px;
}
}
.down {
color: #374151;
font-weight: normal;
font-size: 14px;
line-height: 20px;
word-break: break-all;
}
}
`;
export default function Message({ uid, time, content }) {
const { data: contacts } = useGetContactsQuery();
if (!contacts) return null;
const currUser = contacts.find((c) => c.uid == uid);
return (
<StyledMsg>
<div className="avatar" data-uid={uid}>
<Avatar url={currUser.avatar} id={uid} name={currUser.name} />
</div>
<div className="details">
<div className="up">
<span className="name">{currUser.name}</span>
<i className="time">{dayjs(time).format("YYYY-MM-DD h:mm:ss A")}</i>
</div>
<div className="down">{content}</div>
</div>
</StyledMsg>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { createPortal } from 'react-dom';
const modalRoot = document.getElementById('root-modal');
const wrapper = document.createElement('div');
wrapper.classList.add('wrapper');
export default function Modal({ children }) {
// let eleRef = useRef(null);
useEffect(() => {
// const wrapper = document.createElement('div');
// eleRef.current = wrapper;
modalRoot.appendChild(wrapper);
return () => {
modalRoot.removeChild(wrapper);
};
}, []);
if (!wrapper) return null;
console.log("create portal");
return createPortal(children, wrapper);
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import BASE_URL from "../../app/config";
import { addChannelMsg } from "../../app/slices/message.channel";
import { addUserMsg } from "../../app/slices/message.user";
const NotificationHub = () => {
const dispatch = useDispatch();
const { token } = useSelector((store) => store.authData);
useEffect(() => {
let sse = null;
if (token) {
sse = new EventSource(`${BASE_URL}/user/events?api-key=${token}`);
sse.onopen = () => {
console.info("sse opened");
};
sse.onerror = (err) => {
console.error("sse error", err);
};
sse.onmessage = (evt) => {
handleSSEMessage(JSON.parse(evt.data));
};
}
return () => {
if (sse) {
sse.close();
}
};
}, [token]);
const handleSSEMessage = (data) => {
const { type } = data;
switch (type) {
case "heartbeat":
console.log("heartbeat");
break;
case "chat":
console.log("chat data", data);
if (data.gid) {
const { gid, ...rest } = data;
dispatch(addChannelMsg({ id: gid, ...rest }));
} else {
dispatch(addUserMsg({ id: data.from_uid, ...data }));
}
break;
default:
break;
}
};
return null;
};
export default NotificationHub;
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import styled from "styled-components";
import { MdSearch, MdAdd, MdMail } from "react-icons/md";
import { useSelector } from "react-redux";
import ChannelIcon from "./ChannelIcon";
import ChannelModal from "./ChannelModal";
import ContactsModal from "./ContactsModal";
const StyledWrapper = styled.div`
position: relative;
height: 56px;
padding: 0 10px 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
.search {
display: flex;
align-items: center;
gap: 5px;
.input {
border: none;
outline: none;
background: none;
font-weight: normal;
font-size: 14px;
line-height: 20px;
}
}
.add {
cursor: pointer;
}
.popup {
user-select: none;
box-shadow: 0px 20px 25px rgba(31, 41, 55, 0.1),
0px 10px 10px rgba(31, 41, 55, 0.04);
border-radius: 8px;
color: #616161;
background: #fff;
position: absolute;
top: 50px;
right: 8px;
display: flex;
flex-direction: column;
.item {
display: flex;
align-items: center;
gap: 4px;
font-weight: 600;
font-size: 14px;
line-height: 20px;
cursor: pointer;
padding: 12px;
}
}
`;
export default function Search() {
const currentUser = useSelector((store) => store.authData.user);
const [popupVisible, setPopupVisible] = useState(false);
const [channelModalVisible, setChannelModalVisible] = useState(false);
const [contactsModalVisible, setContactsModalVisible] = useState(false);
const [isPrivate, setIsPrivate] = useState(false);
const toggleContactsModalVisible = () => {
if (!contactsModalVisible) {
togglePopupVisible();
}
setContactsModalVisible((prev) => !prev);
};
const togglePopupVisible = () => {
setPopupVisible((prev) => !prev);
};
const handleOpenChannelModal = (isPrivate) => {
setIsPrivate(isPrivate);
setChannelModalVisible(true);
togglePopupVisible();
};
const handleCloseModal = () => {
setChannelModalVisible(false);
};
return (
<StyledWrapper>
{channelModalVisible && (
<ChannelModal personal={isPrivate} closeModal={handleCloseModal} />
)}
{contactsModalVisible && (
<ContactsModal closeModal={toggleContactsModalVisible} />
)}
<div className="search">
<MdSearch size={20} color="#A1A1AA" />
<input placeholder="Search..." className="input" />
</div>
<MdAdd
className="add"
onClick={togglePopupVisible}
size={24}
color="#A1A1AA"
/>
{popupVisible && (
<ul className="popup">
{currentUser?.is_admin && (
<li
className="item"
onClick={handleOpenChannelModal.bind(null, false)}
>
<ChannelIcon />
New Channel
</li>
)}
<li
className="item"
onClick={handleOpenChannelModal.bind(null, true)}
>
<ChannelIcon personal={true} />
New Private Channel
</li>
<li className="item" onClick={toggleContactsModalVisible}>
<MdMail size={20} color="#616161" />
New Message
</li>
</ul>
)}
</StyledWrapper>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { useState, useEffect } from "react";
import styled from "styled-components";
import { MdAdd } from "react-icons/md";
import TextareaAutosize from "react-textarea-autosize";
import { useDispatch } from "react-redux";
import { useSendChannelMsgMutation } from "../../app/services/channel";
import { useSendMsgMutation } from "../../app/services/contact";
import { addChannelMsg } from "../../app/slices/message.channel";
import { addUserMsg } from "../../app/slices/message.user";
const StyledSend = styled.div`
position: sticky;
top: calc(100vh - 90px);
left: 16px;
background: #f5f6f7;
border-radius: 8px;
width: 884px;
min-height: 54px;
display: flex;
align-items: center;
gap: 18px;
padding: 4px 18px;
.addon {
cursor: pointer;
}
.input {
width: 100%;
position: relative;
.content {
padding: 4px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: #616161;
width: 100%;
border: none;
background: none;
}
.btn {
cursor: pointer;
border: none;
border-radius: 4px;
padding: 2px 6px;
background: green;
color: #fff;
font-size: 16px;
font-weight: bold;
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
}
}
`;
const Types = {
channel: "#",
user: "@",
};
export default function Send({ name, type = "channel", id = "" }) {
const dispatch = useDispatch();
const [
sendMsg,
{ isLoading: sending, isSuccess: sendSuccess, data: sendData },
] = useSendMsgMutation();
const [
sendChannelMsg,
{
isLoading: channelSending,
isSuccess: sendChannelSuccess,
data: sendChannelData,
},
] = useSendChannelMsgMutation();
const [msg, setMsg] = useState("");
const handleMsgChange = (evt) => {
setMsg(evt.target.value);
};
useEffect(() => {
if (sendSuccess) {
dispatch(addUserMsg({ id, ...sendData }));
setMsg("");
}
}, [sendSuccess, sendData]);
useEffect(() => {
if (sendChannelSuccess) {
const { gid, ...rest } = sendChannelData;
dispatch(addChannelMsg({ id: gid, ...rest }));
setMsg("");
}
}, [sendChannelSuccess, sendChannelData]);
const handleSendMessage = () => {
if (!msg || !type || !id) return;
switch (type) {
case "channel":
sendChannelMsg({ gid: id, message: msg });
break;
case "user":
sendMsg({ uid: id, message: msg });
break;
default:
break;
}
};
return (
<StyledSend className="send">
<MdAdd className="addon" size={20} color="#78787C" />
<div className="input">
<TextareaAutosize
className="content"
maxRows={8}
minRows={1}
onChange={handleMsgChange}
value={msg}
placeholder={`${Types[type]}${name} 发消息`}
/>
<button
disabled={sending || channelSending}
className="btn"
onClick={handleSendMessage}
>
{/* {sending ? `Sending` : `Send`} */}
Send
</button>
</div>
<div className="emoji">emoji</div>
</StyledSend>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from "react";
import { useGetContactsQuery } from "../../app/services/contact";
export default function useFilteredUsers() {
const [input, setInput] = useState("");
const { data: contacts } = useGetContactsQuery();
const [filteredUsers, setFilteredUsers] = useState(contacts);
useEffect(() => {
if (!input) {
setFilteredUsers(contacts);
} else {
let str = ["", ...input.toLowerCase(), ""].join(".*");
let reg = new RegExp(str);
setFilteredUsers(
contacts.filter((c) => {
return reg.test(c.name.toLowerCase());
})
);
}
}, [input, contacts]);
const updateInput = (val) => {
setInput(val);
};
return {
input,
contacts: filteredUsers,
updateInput,
};
}
+5
View File
@@ -0,0 +1,5 @@
export const isObjectEqual = (obj1, obj2) => {
let o1 = Object.entries(obj1).sort().toString();
let o2 = Object.entries(obj2).sort().toString();
return o1 === o2;
};
+47
View File
@@ -0,0 +1,47 @@
// import React from 'react';
import ReactDOM from "react-dom";
import { Toaster } from "react-hot-toast";
import { Route, Routes, HashRouter } from "react-router-dom";
import { Reset } from "styled-reset";
import { persistStore } from "redux-persist";
import { PersistGate } from "redux-persist/integration/react";
import { Provider } from "react-redux";
import store from "./app/store";
// import Welcome from './routes/Welcome'
import NotFoundPage from "./routes/404";
import LoginPage from "./routes/login";
import HomePage from "./routes/home";
import ChatPage from "./routes/chat";
import ContactsPage from "./routes/contacts";
let persistor = persistStore(store);
ReactDOM.render(
<>
<Toaster />
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<HashRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />}>
<Route index element={<ChatPage />} />
<Route path="chat">
<Route index element={<ChatPage />} />
<Route path="channel/:channel_id" element={<ChatPage />} />
<Route path="dm/:user_id" element={<ChatPage />} />
</Route>
<Route path="contacts">
<Route index element={<ContactsPage />} />
<Route path=":user_id" element={<ContactsPage />} />
</Route>
</Route>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</HashRouter>
</PersistGate>
</Provider>
<Reset />
</>,
document.getElementById("root")
);
+6
View File
@@ -0,0 +1,6 @@
// import React from 'react';
import StyledWrapper from "./styled";
export default function NotFoundPage() {
return <StyledWrapper>404 page</StyledWrapper>;
}
+6
View File
@@ -0,0 +1,6 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
`;
export default StyledWrapper;
+186
View File
@@ -0,0 +1,186 @@
import { useEffect } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import Message from "../../common/component/Message";
import ChannelIcon from "../../common/component/ChannelIcon";
import Send from "../../common/component/Send";
import { useGetContactsQuery } from "../../app/services/contact";
// import msgs from "./channel.msg.mock.json";
import Contact from "../../common/component/Contact";
const StyledWrapper = styled.article`
position: relative;
width: 100%;
background: #fff;
height: 100vh;
overflow-y: scroll;
overflow-x: hidden;
.head {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
.txt {
display: flex;
align-items: center;
gap: 5px;
.title {
font-size: 16px;
line-height: 24px;
color: #1c1c1e;
}
.desc {
margin-left: 8px;
font-weight: normal;
font-size: 16px;
line-height: 24px;
color: #616161;
}
}
}
.main {
width: 100%;
display: flex;
align-items: flex-start;
justify-content: space-between;
position: relative;
padding: 0 0 20px 16px;
.notification {
padding: 3px 8px;
font-style: normal;
font-weight: 600;
font-size: 12px;
line-height: 18px;
color: #fff;
position: absolute;
top: 0;
left: 10px;
width: 900px;
height: 24px;
background: linear-gradient(135deg, #3c8ce7 0%, #00eaff 100%);
border-radius: 0px 0px 8px 8px;
display: flex;
align-items: center;
justify-content: space-between;
.clear {
cursor: pointer;
color: inherit;
border: none;
background: none;
outline: none;
}
}
.channel {
width: 684px;
.info {
padding-top: 114px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
.title {
font-weight: bold;
font-size: 36px;
line-height: 44px;
}
.desc {
color: #78787c;
font-weight: 500;
font-size: 16px;
line-height: 24px;
}
.edit {
color: #3c8ce7;
padding: 0;
border: none;
outline: none;
background: none;
font-style: normal;
font-weight: 500;
font-size: 16px;
line-height: 24px;
}
}
.chat {
padding: 18px 0;
}
}
.contacts {
display: flex;
flex-direction: column;
gap: 5px;
/* todo */
width: 226px;
height: calc(100vh - 56px);
overflow-y: scroll;
background: #f5f6f7;
padding: 16px;
}
}
`;
export default function ChannelChat({ cid = "", data = {} }) {
const msgs = useSelector((store) => {
return store.channelMsg[cid] || {};
});
const { data: users } = useGetContactsQuery();
useEffect(() => {
console.log({ cid });
}, [cid]);
const { name, description, is_public, members = [] } = data;
const filteredUsers =
members.length == 0
? users
: users.filter((u) => {
return members.includes(u.uid);
});
console.log("channel message list", msgs);
return (
<StyledWrapper>
<header className="head">
<div className="txt">
<ChannelIcon personal={!is_public} />
<span className="title">{name}</span>
<span className="desc">{description}</span>
</div>
<ul className="members">members</ul>
</header>
<main className="main">
<div className="notification">
<div className="content">25+ new messages since 3:24 PM</div>
<button className="clear">Mark As Read</button>
</div>
<div className="channel">
<div className="info">
<h2 className="title">Welcome to #{name} !</h2>
<p className="desc">This is the start of the #{name} channel. </p>
<button className="edit">Edit Channel</button>
</div>
<div className="chat">
{Object.entries(msgs).map(([mid, msg]) => {
if (!msg) return null;
const { from_uid, content, created_at } = msg;
return (
<Message
key={mid}
time={created_at}
uid={from_uid}
content={content}
/>
);
})}
</div>
</div>
<div className="contacts">
{filteredUsers.map(({ name, status, uid }) => {
return <Contact key={name} uid={uid} status={status} />;
})}
</div>
</main>
<Send id={cid} type="channel" name={name} />
</StyledWrapper>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState, useEffect } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import Message from "../../common/component/Message";
import Send from "../../common/component/Send";
// import msgs from "./channel.msg.mock.json";
import Contact from "../../common/component/Contact";
import { useGetContactsQuery } from "../../app/services/contact";
const StyledWrapper = styled.article`
position: relative;
width: 100%;
background: #fff;
height: 100vh;
overflow-y: scroll;
overflow-x: hidden;
.head {
height: 56px;
padding: 0 20px 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
.txt {
display: flex;
align-items: center;
gap: 5px;
.title {
font-size: 16px;
line-height: 24px;
color: #1c1c1e;
}
.desc {
margin-left: 8px;
font-weight: normal;
font-size: 16px;
line-height: 24px;
color: #616161;
}
}
}
.main {
width: 100%;
display: flex;
align-items: flex-start;
justify-content: space-between;
position: relative;
padding: 0 0 20px 16px;
.chat {
width: 890px;
padding: 18px 0;
}
}
`;
export default function DMChat({ uid = "" }) {
const msgs = useSelector((store) => {
return store.userMsg[uid] || {};
});
const { data: contacts } = useGetContactsQuery();
const [currUser, setCurrUser] = useState(null);
useEffect(() => {
console.log({ uid });
if (uid && contacts) {
setCurrUser(contacts.find((c) => c.uid == uid));
}
}, [uid, contacts]);
if (!currUser) return null;
console.log("user msgs", msgs);
return (
<StyledWrapper>
<header className="head">
<Contact interactive={false} uid={currUser.uid} />
{/* <ul className="members">members</ul> */}
</header>
<main className="main">
<div className="chat">
{Object.entries(msgs).map(([mid, msg]) => {
if (!msg) return null;
const { from_uid, content, created_at } = msg;
return (
<Message
key={mid}
time={created_at}
uid={from_uid}
content={content}
/>
);
})}
</div>
</main>
<Send type="user" name={currUser?.name} id={currUser?.uid} />
</StyledWrapper>
);
}
+152
View File
@@ -0,0 +1,152 @@
// import React from 'react';
import { useState } from "react";
import { NavLink, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import dayjs from "dayjs";
import { MdAdd } from "react-icons/md";
import { AiOutlineCaretDown } from "react-icons/ai";
import { useGetChannelsQuery } from "../../app/services/channel";
import { useGetContactsQuery } from "../../app/services/contact";
import StyledWrapper from "./styled";
import Search from "../../common/component/Search";
import Avatar from "../../common/component/Avatar";
import ChannelIcon from "../../common/component/ChannelIcon";
import ChannelChat from "./ChannelChat";
import DMChat from "./DMChat";
import ContactsModal from "../../common/component/ContactsModal";
import ChannelModal from "../../common/component/ChannelModal";
export default function ChatPage() {
const UserMsgData = useSelector((store) => {
return store.userMsg;
});
const [channelModalVisible, setChannelModalVisible] = useState(false);
const [contactsModalVisible, setContactsModalVisible] = useState(false);
const { channel_id, user_id } = useParams();
const { data: channels } = useGetChannelsQuery();
const { data: contacts } = useGetContactsQuery();
const toggleContactsModalVisible = () => {
setContactsModalVisible((prev) => !prev);
};
const toggleChannelModalVisible = () => {
setChannelModalVisible((prev) => !prev);
};
console.log("channels", channels);
if (!channels || !contacts) return null;
const Sessions = Object.keys(UserMsgData);
const tmpSessionUser = contacts.find((c) => c.uid == user_id);
return (
<>
{channelModalVisible && (
<ChannelModal closeModal={toggleChannelModalVisible} personal={true} />
)}
{contactsModalVisible && (
<ContactsModal closeModal={toggleContactsModalVisible} />
)}
<StyledWrapper>
<div className="left">
<Search />
<div className="list channels">
<h3 className="title">
<span className="txt">
<AiOutlineCaretDown size={18} color="#78787C" />
CHANNELS
</span>
<MdAdd
onClick={toggleChannelModalVisible}
size={18}
color="#78787C"
/>
</h3>
<nav className="nav">
{channels.map(({ gid, is_public, name, description }) => {
return (
<NavLink
title={description}
key={gid}
className="link"
to={`/chat/channel/${gid}`}
>
<span className="txt">
<ChannelIcon personal={!is_public} />
{name}
</span>
<i className="badge">12</i>
</NavLink>
);
})}
</nav>
</div>
<div className="list dms">
<h3 className="title">
<span className="txt">
<AiOutlineCaretDown size={18} color="#78787C" />
DIRECT MESSAGE
</span>
<MdAdd
size={18}
color="#78787C"
onClick={toggleContactsModalVisible}
/>
</h3>
<nav className="nav">
{Sessions.map((uid) => {
let currUser = contacts.find((c) => c.uid == uid);
let latestMid = Object.keys(UserMsgData[uid]).sort().pop();
let latestMsg = UserMsgData[uid][latestMid];
return (
<NavLink key={uid} className="session" to={`/chat/dm/${uid}`}>
<Avatar className="avatar" url={currUser.avatar} id={uid} />
<div className="details">
<div className="up">
<span className="name">{currUser.name}</span>
<time>
{dayjs(latestMsg.created_at).format("YYYY-MM-DD")}
</time>
</div>
<div className="down">
<div className="msg">{latestMsg.content}</div>
<i className="badge">3</i>
</div>
</div>
</NavLink>
);
})}
{user_id && !Sessions.includes(user_id) && (
<NavLink className="session" to={`/chat/dm/${user_id}`}>
<Avatar
className="avatar"
url={tmpSessionUser.avatar}
id={user_id}
/>
<div className="details">
<div className="up">
<span className="name">{tmpSessionUser.name}</span>
<time></time>
</div>
<div className="down">
<div className="msg"></div>
<i className="badge">3</i>
</div>
</div>
</NavLink>
)}
</nav>
</div>
</div>
<div className="right">
{channel_id && (
<ChannelChat
cid={channel_id}
data={channels.find(({ gid }) => gid == channel_id)}
/>
)}
{user_id && <DMChat uid={user_id} />}
</div>
</StyledWrapper>
</>
);
}
+153
View File
@@ -0,0 +1,153 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
height: 100%;
> .left {
display: flex;
flex-direction: column;
width: 260px;
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
.list {
margin: 12px 8px;
.title {
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
cursor: pointer;
> .txt {
user-select: none;
display: flex;
align-items: center;
gap: 5px;
font-weight: bold;
font-size: 12px;
line-height: 20px;
color: #78787c;
}
}
> .nav {
display: flex;
flex-direction: column;
gap: 4px;
a {
text-decoration: none;
}
.link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-radius: 4px;
&:hover,
&.active {
background: rgba(116, 127, 141, 0.1);
}
> .txt {
display: flex;
align-items: center;
gap: 5px;
color: #1c1c1e;
font-weight: 600;
font-size: 14px;
line-height: 20px;
}
> .badge {
padding: 3px;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #bfbfbf;
font-weight: 900;
font-size: 10px;
line-height: 10px;
&.dot {
width: 6px;
height: 6px;
padding: 0;
}
}
}
.session {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
&:hover,
&.active {
background: rgba(116, 127, 141, 0.1);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
/* img{
width: 100%;
} */
}
.details {
display: flex;
flex-direction: column;
width: 100%;
.up {
display: flex;
justify-content: space-between;
.name {
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #52525b;
}
time {
font-weight: 500;
font-size: 12px;
line-height: 18px;
color: #78787c;
}
}
.down {
display: flex;
justify-content: space-between;
.msg {
font-weight: normal;
font-size: 12px;
line-height: 18px;
color: #78787c;
}
> .badge {
letter-spacing: -1px;
padding: 2px;
color: #fff;
height: 16px;
min-width: 16px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: #1fe1f9;
font-weight: 900;
font-size: 10px;
line-height: 10px;
&.mute {
background: #bfbfbf;
}
}
}
}
}
}
}
}
> .right {
width: 100%;
}
`;
export default StyledWrapper;
+92
View File
@@ -0,0 +1,92 @@
// import React from "react";
// import toast from "react-hot-toast";
import { useState, useEffect } from "react";
import { NavLink } from "react-router-dom";
import { BsChatText } from "react-icons/bs";
import { RiUserAddLine } from "react-icons/ri";
import { IoShareOutline } from "react-icons/io5";
import styled from "styled-components";
import { useGetContactsQuery } from "../../app/services/contact";
import Avatar from "../../common/component/Avatar";
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
margin-top: 80px;
width: 432px;
gap: 4px;
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
}
.name {
font-weight: bold;
font-size: 18px;
line-height: 100%;
color: #1c1c1e;
}
.email {
font-weight: normal;
font-size: 14px;
line-height: 20px;
color: #78787c;
}
.icons {
display: flex;
align-items: center;
padding: 26px 0;
gap: 28px;
.icon {
}
}
.line {
width: 100%;
height: 1px;
border: none;
background-color: rgba(0, 0, 0, 0.1);
}
`;
export default function Profile({ uid = null }) {
const { data: contacts } = useGetContactsQuery();
const [profile, setProfile] = useState(null);
useEffect(() => {
if (contacts && contacts) {
setProfile(contacts.find((c) => c.uid == uid));
} else {
setProfile(null);
}
}, [uid, contacts]);
if (!profile) return null;
console.log({ profile });
const { name, email, avatar } = profile;
return (
<StyledWrapper>
<Avatar className="avatar" url={avatar} id={uid} name={name} />
<h2 className="name">{name}</h2>
<span className="email">{email}</span>
<ul className="icons">
<li className="icon chat">
<NavLink to={`/chat/dm/${uid}`}>
<BsChatText size={20} color="#616161" />
</NavLink>
</li>
<li className="icon add">
{/* <NavLink to={`/chat/dm/${uid}`}> */}
<RiUserAddLine size={20} color="#616161" />
{/* </NavLink> */}
</li>
<li className="icon share">
{/* <NavLink to={`/chat/dm/${uid}`}> */}
<IoShareOutline size={20} color="#616161" />
{/* </NavLink> */}
</li>
</ul>
<hr className="line" />
</StyledWrapper>
);
}
+39
View File
@@ -0,0 +1,39 @@
// import { useState, useEffect } from "react";
import { NavLink, useParams } from "react-router-dom";
import { useGetContactsQuery } from "../../app/services/contact";
import Search from "../../common/component/Search";
import Contact from "../../common/component/Contact";
import Profile from "./Profile";
import StyledWrapper from "./styled";
export default function ContactsPage() {
const { user_id } = useParams();
const { data: contacts } = useGetContactsQuery();
console.log({ contacts, user_id });
if (!contacts) return null;
return (
<StyledWrapper>
<div className="left">
<Search />
<div className="list">
<nav className="nav">
{contacts.map(({ uid, status }) => {
return (
<NavLink key={uid} className="session" to={`/contacts/${uid}`}>
<Contact uid={uid} status={status} />
</NavLink>
);
})}
</nav>
</div>
</div>
{user_id && (
<div className="right">
<Profile uid={user_id} />
</div>
)}
</StyledWrapper>
);
}
+37
View File
@@ -0,0 +1,37 @@
import styled from "styled-components";
const StyledWrapper = styled.div`
display: flex;
height: 100%;
> .left {
display: flex;
flex-direction: column;
width: 260px;
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
.list {
margin: 12px 8px;
> .nav {
display: flex;
flex-direction: column;
gap: 4px;
a {
text-decoration: none;
}
.session {
&:hover,
&.active {
background: rgba(116, 127, 141, 0.1);
}
}
}
}
}
.right {
height: 100vh;
width: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
}
`;
export default StyledWrapper;
+63
View File
@@ -0,0 +1,63 @@
// import React from 'react';
import styled from "styled-components";
import { AiOutlineCaretDown, AiOutlineSound } from "react-icons/ai";
import { MdOutlineKeyboardVoice } from "react-icons/md";
import { useSelector } from "react-redux";
const StyledWrapper = styled.div`
position: absolute;
bottom: 0;
left: 0;
padding: 16px 12px;
width: -webkit-fill-available;
display: flex;
align-items: center;
justify-content: space-between;
.profile {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.toggle {
}
}
.settings {
gap: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.icon {
cursor: pointer;
}
}
`;
export default function CurrentUser({ collaspe = false }) {
const { user } = useSelector((store) => store.authData);
if (!user) return null;
const { name } = user;
return (
<StyledWrapper>
<div className="profile" title={name}>
<img
title={name}
src={`https://avatars.dicebear.com/api/adventurer-neutral/${name}.svg`}
alt="user avatar"
className="avatar"
/>
<AiOutlineCaretDown className="toggle" width={20} color="#C4C4C4" />
</div>
{!collaspe && (
<div className="settings">
<AiOutlineSound className="icon" size={15} color="#1C1C1E" />
<MdOutlineKeyboardVoice className="icon" size={15} color="#1C1C1E" />
</div>
)}
</StyledWrapper>
);
}
+71
View File
@@ -0,0 +1,71 @@
// import React from 'react';
import styled from "styled-components";
import { HiChevronDoubleLeft } from "react-icons/hi";
const StyledWrapper = styled.div`
height: 56px;
padding: 0 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
&.collaspe {
padding-right: 5px;
}
.server {
display: flex;
align-items: center;
gap: 8px;
.logo {
width: 24px;
height: 24px;
background-color: #fff;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
img {
width: 16px;
height: 16px;
}
}
.title {
white-space: nowrap;
font-weight: normal;
font-size: 14px;
line-height: 20px;
color: #4b5563;
}
}
.arrow {
cursor: pointer;
transform-origin: center;
transition: transform 0.5s ease-in-out;
display: flex;
width: 15px;
height: 15px;
.icon {
width: 100%;
height: 100%;
}
&.collaspe {
transform: rotate(180deg);
}
}
`;
export default function ServerDropList({ data, toggle, collaspe = false }) {
if (!data) return null;
return (
<StyledWrapper className={collaspe ? "collaspe" : ""}>
<div className="server">
<div className="logo">
<img src={data.logo} alt="logo" />
</div>
{!collaspe && <h2 className="title">{data.name}</h2>}
</div>
<div onClick={toggle} className={`arrow ${collaspe ? "collaspe" : ""}`}>
<HiChevronDoubleLeft className="icon" color="#BFBFBF" />
</div>
</StyledWrapper>
);
}
+93
View File
@@ -0,0 +1,93 @@
// import React from 'react';
import { RiAddFill } from "react-icons/ri";
import styled from "styled-components";
import { IoLogoGithub } from "react-icons/io5";
const StyledWrapper = styled.div`
padding: 0 6px;
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 20px;
> hr {
border: none;
width: 40%;
height: 1px;
background: rgba(255, 255, 255, 0.2);
}
.tools {
padding: 0 10px;
display: flex;
flex-direction: column;
gap: 10px;
.tool,
.add {
cursor: pointer;
gap: 9px;
display: flex;
align-items: center;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #4b5563;
}
.tool {
.logo {
border-radius: 5.5px;
background: #fff;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
.icon {
width: 21px;
height: 21px;
}
}
.title {
white-space: nowrap;
}
&.add .logo {
background: none;
.icon {
width: 40px;
height: 40px;
}
}
}
}
`;
export default function Tools({ collaspe = false }) {
return (
<StyledWrapper>
<hr />
<ul className="tools">
<li className="tool">
<div className="logo">
<img
className="icon"
src="https://static.nicegoodthings.com/project/ext/webrowse.logo.png"
alt="logo"
/>
</div>
{!collaspe && <h2 className="title">Webrowse</h2>}
</li>
<li className="tool">
<div className="logo">
<IoLogoGithub size={40} className="icon" />
</div>
{!collaspe && <h2 className="title">Github</h2>}
</li>
<li className="tool add">
<div className="logo">
<RiAddFill className="icon" size={40} color="#4B5563" />
</div>
{!collaspe && <h2 className="title">Add new app</h2>}
</li>
</ul>
</StyledWrapper>
);
}
+50
View File
@@ -0,0 +1,50 @@
// import React from 'react';
import { useState } from "react";
import StyledWrapper from "./styled";
import { Outlet, NavLink } from "react-router-dom";
import ServerDropList from "./ServerDropList";
import Tools from "./Tools";
import usePreload from "./usePreload";
import CurrentUser from "./CurrentUser";
import ChatIcon from "../../assets/icons/chat.svg";
import ContactIcon from "../../assets/icons/contact.svg";
import NotificationHub from "../../common/component/NotificationHub";
export default function HomePage() {
const { data, error, success } = usePreload();
const [collaspe, setCollaspe] = useState(false);
const toggleCollaspe = () => {
setCollaspe((prev) => !prev);
};
console.log({ data, error, success });
return (
<>
<NotificationHub />
<StyledWrapper>
<div className={`col left ${collaspe ? "collaspe" : ""}`}>
<ServerDropList
data={data?.server}
collaspe={collaspe}
toggle={toggleCollaspe}
/>
<nav className="nav">
<NavLink className="link" to={"/chat"}>
<img src={ChatIcon} alt="chat icon" /> {!collaspe && `Chat`}
</NavLink>
<NavLink className="link" to={"/contacts"}>
<img src={ContactIcon} alt="contact icon" />{" "}
{!collaspe && `Contacts`}
</NavLink>
</nav>
<Tools collaspe={collaspe} />
<CurrentUser collaspe={collaspe} />
</div>
<div className="col right">
<Outlet />
</div>
</StyledWrapper>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import styled from "styled-components";
const StyledWrapper = styled.div`
display: flex;
width: 100vw;
height: 100vh;
background-color: #f5f6f7;
> .col {
height: 100%;
display: flex;
flex-direction: column;
&.left {
position: relative;
/* background: #0891B2; */
width: 180px;
box-shadow: inset -1px 0px 0px rgba(0, 0, 0, 0.1);
transition: all 0.5s ease-in;
&.collaspe {
width: 64px;
}
}
&.right {
width: 100%;
}
> .nav {
display: flex;
flex-direction: column;
gap: 4px;
padding: 24px 8px 10px 8px;
.link {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
padding: 10px 8px;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #4b5563;
border-radius: 8px;
&:hover,
&.active {
background-color: rgba(0, 0, 0, 0.08);
}
}
}
}
`;
export default StyledWrapper;
+39
View File
@@ -0,0 +1,39 @@
// import React from 'react';
import { useGetContactsQuery } from "../../app/services/contact";
import { useGetChannelsQuery } from "../../app/services/channel";
import { useGetServerQuery } from "../../app/services/server";
// pollingInterval: 0,
const querySetting = {
refetchOnMountOrArgChange: true,
};
export default function usePreload() {
const {
isLoading: contactsLoading,
isSuccess: contactsSuccess,
isError: contactsError,
data: contacts,
} = useGetContactsQuery(undefined, querySetting);
const {
isLoading: serverLoading,
isSuccess: serverSuccess,
isError: serverError,
data: server,
} = useGetServerQuery(undefined, querySetting);
const {
isLoading: groupsLoading,
isSuccess: groupsSuccess,
isError: groupsError,
data: groups,
} = useGetChannelsQuery(undefined, querySetting);
return {
loading: contactsLoading && groupsLoading && serverLoading,
error: contactsError && groupsError && serverError,
success: contactsSuccess && groupsSuccess && serverSuccess,
data: {
contacts,
server,
groups,
},
};
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from "react";
import StyledWrapper from "./styled";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { useLoginMutation } from "../../app/services/auth";
import { setAuthData } from "../../app/slices/auth.data";
export default function LoginPage() {
const navigateTo = useNavigate();
const dispatch = useDispatch();
const [login] = useLoginMutation();
const [input, setInput] = useState({
email: "",
password: "",
});
const handleLogin = async (evt) => {
evt.preventDefault();
console.log("wtf", input);
const { data, error } = await login({
...input,
device: "web",
device_token: "test",
});
if (error) {
console.log(error);
switch (error.status) {
case 401:
toast.error("username or password incorrect");
break;
default:
break;
}
return;
}
if (data) {
dispatch(setAuthData(data));
toast.success("login success");
navigateTo("/");
}
};
const handleInput = (evt) => {
const { type } = evt.target.dataset;
const { value } = evt.target;
console.log(type, value);
setInput((prev) => {
prev[type] = value;
return { ...prev };
});
};
const { email, password } = input;
return (
<StyledWrapper>
<div className="form">
<form onSubmit={handleLogin}>
<input
name="email"
value={email}
required
placeholder="email"
data-type="email"
onChange={handleInput}
/>
<input
type="password"
value={password}
name="password"
required
data-type="password"
onChange={handleInput}
placeholder="password"
/>
<button type="submit">login</button>
</form>
</div>
</StyledWrapper>
);
}
+22
View File
@@ -0,0 +1,22 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
.form{
padding: 30px 15px;
border: 1px solid #eee;
form{
display: flex;
flex-direction: column;
gap: 5px;
input{
padding:4px 6px
}
}
}
`;
export default StyledWrapper;