build: format the code with prettier
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return <div>404 page</div>;
|
||||
}
|
||||
|
||||
@@ -1,68 +1,74 @@
|
||||
import { useEffect, FC } from 'react';
|
||||
import { FC, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
||||
|
||||
import { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
|
||||
import { useLoginMutation } from "@/app/services/auth";
|
||||
import toast from "react-hot-toast";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import StyledButton from '../../components/styled/Button';
|
||||
import StyledButton from "../../components/styled/Button";
|
||||
|
||||
export type GithubLoginSource = "widget" | "webapp"
|
||||
export type GithubLoginSource = "widget" | "webapp";
|
||||
type Props = {
|
||||
code: string, from?: GithubLoginSource
|
||||
}
|
||||
code: string;
|
||||
from?: GithubLoginSource;
|
||||
};
|
||||
const GithubCallback: FC<Props> = ({ code, from = "webapp" }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
const { t: ct } = useTranslation();
|
||||
//拿本地存的magic token
|
||||
const magic_token = localStorage.getItem(KEY_LOCAL_MAGIC_TOKEN);
|
||||
const [login, { isLoading, isSuccess, error }] = useLoginMutation();
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
login({
|
||||
magic_token,
|
||||
code,
|
||||
type: "github"
|
||||
});
|
||||
}
|
||||
}, [code]);
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.login"));
|
||||
// 通知widget
|
||||
if (from == 'widget') {
|
||||
localStorage.setItem("widget", `${new Date().getTime()}`);
|
||||
}
|
||||
// webapp 跳回首页
|
||||
if (from == 'webapp') {
|
||||
location.href = "/";
|
||||
}
|
||||
}
|
||||
}, [isSuccess, from]);
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
console.log(error);
|
||||
// todo: why?
|
||||
switch ((error as FetchBaseQueryError).status) {
|
||||
case 410:
|
||||
toast.error(
|
||||
"No associated account found, please contact user admin for an invitation link to join."
|
||||
);
|
||||
break;
|
||||
default:
|
||||
toast.error("Something Error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
const handleClose = () => {
|
||||
window.close();
|
||||
};
|
||||
if (error) return <span className='text-red-500 text-lg'>Something Error</span>;
|
||||
return <section className='flex-center flex-col gap-3'>
|
||||
<StyledButton onClick={handleClose}>{ct("action.close")}</StyledButton>
|
||||
{isSuccess && from == 'widget' && <h1>{t("github_cb_tip")}</h1>}
|
||||
<span className='text-3xl text-green-600 font-bold'>{isLoading ? t("github_logging_in") : t("github_login_success")}</span>
|
||||
</section>;
|
||||
const { t } = useTranslation("auth");
|
||||
const { t: ct } = useTranslation();
|
||||
//拿本地存的magic token
|
||||
const magic_token = localStorage.getItem(KEY_LOCAL_MAGIC_TOKEN);
|
||||
const [login, { isLoading, isSuccess, error }] = useLoginMutation();
|
||||
useEffect(() => {
|
||||
if (code) {
|
||||
login({
|
||||
magic_token,
|
||||
code,
|
||||
type: "github"
|
||||
});
|
||||
}
|
||||
}, [code]);
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.login"));
|
||||
// 通知widget
|
||||
if (from == "widget") {
|
||||
localStorage.setItem("widget", `${new Date().getTime()}`);
|
||||
}
|
||||
// webapp 跳回首页
|
||||
if (from == "webapp") {
|
||||
location.href = "/";
|
||||
}
|
||||
}
|
||||
}, [isSuccess, from]);
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
console.log(error);
|
||||
// todo: why?
|
||||
switch ((error as FetchBaseQueryError).status) {
|
||||
case 410:
|
||||
toast.error(
|
||||
"No associated account found, please contact user admin for an invitation link to join."
|
||||
);
|
||||
break;
|
||||
default:
|
||||
toast.error("Something Error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
const handleClose = () => {
|
||||
window.close();
|
||||
};
|
||||
if (error) return <span className="text-red-500 text-lg">Something Error</span>;
|
||||
return (
|
||||
<section className="flex-center flex-col gap-3">
|
||||
<StyledButton onClick={handleClose}>{ct("action.close")}</StyledButton>
|
||||
{isSuccess && from == "widget" && <h1>{t("github_cb_tip")}</h1>}
|
||||
<span className="text-3xl text-green-600 font-bold">
|
||||
{isLoading ? t("github_logging_in") : t("github_login_success")}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GithubCallback;
|
||||
export default GithubCallback;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLazyGetGeneratedLicenseQuery } from "@/app/services/server";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
import Button from "@/components/styled/Button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useLazyGetGeneratedLicenseQuery } from "@/app/services/server";
|
||||
import Button from "@/components/styled/Button";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
|
||||
type Props = {
|
||||
sid: string;
|
||||
@@ -31,7 +32,11 @@ const PaymentSuccess = ({ sid }: Props) => {
|
||||
};
|
||||
return (
|
||||
<section className="flex flex-col items-center bg-slate-100 dark:bg-slate-800 rounded-2xl w-4/5 md:w-[512px] p-6">
|
||||
<img className="w-28 h-28" src="https://s.voce.chat/web_client/assets/img/check.png" alt="check icon" />
|
||||
<img
|
||||
className="w-28 h-28"
|
||||
src="https://s.voce.chat/web_client/assets/img/check.png"
|
||||
alt="check icon"
|
||||
/>
|
||||
<h1 className="font-bold text-3xl pt-5">{t("payment_success")}</h1>
|
||||
<p className="text-lg pb-7 mt-2 text-gray-400 dark:text-gray-600">
|
||||
{upserting ? t("tip_renewing") : ""}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { DOMAttributes, ReactNode } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import PaymentSuccess from "./PaymentSuccess";
|
||||
|
||||
import GithubCallback, { GithubLoginSource } from "./GithubCallback";
|
||||
import PaymentSuccess from "./PaymentSuccess";
|
||||
|
||||
const StyledWrapper = ({ children }: DOMAttributes<HTMLDivElement> & { children?: ReactNode }) => {
|
||||
|
||||
return <div className="flex-center dark:bg-gray-700 dark:text-white w-screen h-screen break-words leading-normal">
|
||||
{children}
|
||||
</div>;
|
||||
return (
|
||||
<div className="flex-center dark:bg-gray-700 dark:text-white w-screen h-screen break-words leading-normal">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// type Props = {
|
||||
// type: "payment_success";
|
||||
|
||||
@@ -1,58 +1,64 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { ViewportList } from 'react-viewport-list';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ViewportList } from "react-viewport-list";
|
||||
|
||||
import InviteModal from "@/components/InviteModal";
|
||||
import User from "@/components/User";
|
||||
import IconAdd from "@/assets/icons/add.svg";
|
||||
import InviteModal from "@/components/InviteModal";
|
||||
|
||||
|
||||
type Props = {
|
||||
membersVisible: boolean,
|
||||
uids: number[],
|
||||
addVisible: boolean,
|
||||
cid: number,
|
||||
ownerId: number
|
||||
}
|
||||
|
||||
const Members = ({ uids, addVisible, ownerId, cid, membersVisible }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [addMemberModalVisible, setAddMemberModalVisible] = useState(false);
|
||||
const toggleAddVisible = () => {
|
||||
setAddMemberModalVisible((prev) => !prev);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{addMemberModalVisible && <InviteModal cid={cid} closeModal={toggleAddVisible} />}
|
||||
<div ref={ref} className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${membersVisible ? "flex" : "hidden"}`}>
|
||||
{addVisible && (
|
||||
<div className="cursor-pointer flex items-center justify-start gap-1 select-none rounded-lg p-2.5 md:hover:bg-gray-500/10" onClick={toggleAddVisible}>
|
||||
<IconAdd className="w-6 h-6 dark:fill-slate-300" />
|
||||
<div className="font-semibold text-sm text-gray-600 dark:text-gray-50">{t("add_channel_members")}</div>
|
||||
</div>
|
||||
)}
|
||||
<ViewportList
|
||||
initialPrerender={15}
|
||||
viewportRef={ref}
|
||||
items={uids}
|
||||
>
|
||||
{(uid: number) => {
|
||||
return (
|
||||
<User
|
||||
enableContextMenu={true}
|
||||
cid={cid}
|
||||
owner={ownerId == uid}
|
||||
key={uid}
|
||||
uid={uid}
|
||||
dm
|
||||
popover
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ViewportList>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
membersVisible: boolean;
|
||||
uids: number[];
|
||||
addVisible: boolean;
|
||||
cid: number;
|
||||
ownerId: number;
|
||||
};
|
||||
|
||||
export default Members;
|
||||
const Members = ({ uids, addVisible, ownerId, cid, membersVisible }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [addMemberModalVisible, setAddMemberModalVisible] = useState(false);
|
||||
const toggleAddVisible = () => {
|
||||
setAddMemberModalVisible((prev) => !prev);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{addMemberModalVisible && <InviteModal cid={cid} closeModal={toggleAddVisible} />}
|
||||
<div
|
||||
ref={ref}
|
||||
className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${
|
||||
membersVisible ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
{addVisible && (
|
||||
<div
|
||||
className="cursor-pointer flex items-center justify-start gap-1 select-none rounded-lg p-2.5 md:hover:bg-gray-500/10"
|
||||
onClick={toggleAddVisible}
|
||||
>
|
||||
<IconAdd className="w-6 h-6 dark:fill-slate-300" />
|
||||
<div className="font-semibold text-sm text-gray-600 dark:text-gray-50">
|
||||
{t("add_channel_members")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ViewportList initialPrerender={15} viewportRef={ref} items={uids}>
|
||||
{(uid: number) => {
|
||||
return (
|
||||
<User
|
||||
enableContextMenu={true}
|
||||
cid={cid}
|
||||
owner={ownerId == uid}
|
||||
key={uid}
|
||||
uid={uid}
|
||||
dm
|
||||
popover
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ViewportList>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Members;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { FC, FormEvent } from "react";
|
||||
import usePinMessage from "@/hooks/usePinMessage";
|
||||
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
|
||||
import IconClose from "@/assets/icons/close.svg";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import PinnedMessage from "@/components/PinnedMessage";
|
||||
import usePinMessage from "@/hooks/usePinMessage";
|
||||
import IconClose from "@/assets/icons/close.svg";
|
||||
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
@@ -19,21 +20,32 @@ const PinList: FC<Props> = ({ id }: Props) => {
|
||||
const noPins = pins.length == 0;
|
||||
return (
|
||||
<div className="p-4 drop-shadow-md overflow-y-scroll min-w-[320px] md:min-w-[486px] md:max-h-[90vh] rounded-xl bg-gray-50 dark:bg-gray-800">
|
||||
<h4 className=" text-gray-600 dark:text-gray-400 mb-4 font-semibold">{t("pinned_msg")}({pins.length})</h4>
|
||||
<h4 className=" text-gray-600 dark:text-gray-400 mb-4 font-semibold">
|
||||
{t("pinned_msg")}({pins.length})
|
||||
</h4>
|
||||
{noPins ? (
|
||||
<div className="flex flex-col items-center gap-2 w-full p-4">
|
||||
<IconSurprise />
|
||||
<div className="w-60 font-semibold text-gray-500 dark:text-gray-300 text-center">{t("pin_empty_tip")}</div>
|
||||
<div className="w-60 font-semibold text-gray-500 dark:text-gray-300 text-center">
|
||||
{t("pin_empty_tip")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{pins.map((data) => {
|
||||
return (
|
||||
<li key={data.mid} className="group relative border border-solid border-slate-100 dark:border-slate-600 rounded-md ">
|
||||
<li
|
||||
key={data.mid}
|
||||
className="group relative border border-solid border-slate-100 dark:border-slate-600 rounded-md "
|
||||
>
|
||||
<PinnedMessage data={data} />
|
||||
<div className="invisible group-hover:visible flex items-center gap-1 absolute top-1 right-1 p-1 border border-solid border-black/10 dark:border-gray-500 rounded-md">
|
||||
{canPin && (
|
||||
<button className="flex bg-none border-none" data-mid={data.mid} onClick={handleUnpin}>
|
||||
<button
|
||||
className="flex bg-none border-none"
|
||||
data-mid={data.mid}
|
||||
onClick={handleUnpin}
|
||||
>
|
||||
<IconClose className="fill-slate-900 dark:fill-slate-300" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { useEffect, memo } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { memo, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import Tippy from "@tippyjs/react";
|
||||
|
||||
import PinList from "./PinList";
|
||||
import FavList from "../FavList";
|
||||
import { updateChannelVisibleAside } from "@/app/slices/footprint";
|
||||
import { updateRememberedNavs } from "@/app/slices/ui";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import ChannelIcon from "@/components/ChannelIcon";
|
||||
import GoBackNav from "@/components/GoBackNav";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import Layout from "../Layout";
|
||||
import IconFav from "@/assets/icons/bookmark.svg";
|
||||
import IconPeople from "@/assets/icons/people.svg";
|
||||
import IconPin from "@/assets/icons/pin.svg";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import GoBackNav from "@/components/GoBackNav";
|
||||
import Members from "./Members";
|
||||
import FavList from "../FavList";
|
||||
import Layout from "../Layout";
|
||||
import VoiceChat from "../VoiceChat";
|
||||
import { updateChannelVisibleAside } from "@/app/slices/footprint";
|
||||
import Dashboard from "../VoiceChat/Dashboard";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
import Members from "./Members";
|
||||
import PinList from "./PinList";
|
||||
|
||||
type Props = {
|
||||
cid?: number;
|
||||
dropFiles?: File[];
|
||||
@@ -30,12 +31,7 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
userIds,
|
||||
data,
|
||||
loginUser,
|
||||
visibleAside
|
||||
} = useAppSelector((store) => {
|
||||
const { userIds, data, loginUser, visibleAside } = useAppSelector((store) => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
userIds: store.users.ids,
|
||||
@@ -57,83 +53,91 @@ function ChannelChat({ cid = 0, dropFiles = [] }: Props) {
|
||||
}, [pathname]);
|
||||
|
||||
const toggleMembersVisible = () => {
|
||||
dispatch(updateChannelVisibleAside({
|
||||
id: cid,
|
||||
aside: visibleAside !== "members" ? "members" : null
|
||||
}));
|
||||
dispatch(
|
||||
updateChannelVisibleAside({
|
||||
id: cid,
|
||||
aside: visibleAside !== "members" ? "members" : null
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
if (!data) return null;
|
||||
const { name, description, is_public, members = [], owner } = data;
|
||||
const memberIds = is_public ? userIds : members.slice(0).sort((n) => (n == owner ? -1 : 0));
|
||||
const addVisible = loginUser?.is_admin || owner == loginUser?.uid;
|
||||
const pinCount = data?.pinned_messages?.length || 0;
|
||||
const toolClass = `relative cursor-pointer hidden md:block`;
|
||||
return <Layout
|
||||
to={cid}
|
||||
context="channel"
|
||||
dropFiles={dropFiles}
|
||||
aside={
|
||||
<ul className="flex flex-col gap-6">
|
||||
<Tooltip tip={t("pin")} placement="left">
|
||||
<Tippy
|
||||
placement="left-start"
|
||||
popperOptions={{ strategy: "fixed" }}
|
||||
offset={[0, 150]}
|
||||
interactive
|
||||
trigger="click"
|
||||
content={<PinList id={cid} />}
|
||||
>
|
||||
<li className={`${toolClass}`}>
|
||||
{pinCount > 0 ? <span className="absolute -top-2 -right-2 flex-center w-4 h-4 rounded-full bg-primary-400 text-white font-bold text-[10px]">{pinCount}</span> : null}
|
||||
<IconPin className="fill-gray-500" />
|
||||
</li>
|
||||
</Tippy>
|
||||
</Tooltip>
|
||||
<ServerVersionChecker version="0.3.5" empty={true}>
|
||||
<VoiceChat context={`channel`} id={cid} />
|
||||
</ServerVersionChecker>
|
||||
<Tooltip tip={t("fav")} placement="left">
|
||||
<Tippy
|
||||
placement="left-start"
|
||||
popperOptions={{ strategy: "fixed" }}
|
||||
offset={[0, 164]}
|
||||
interactive
|
||||
trigger="click"
|
||||
content={<FavList cid={cid} />}
|
||||
>
|
||||
<li className={`${toolClass}`}>
|
||||
<IconFav className="fill-gray-500" />
|
||||
</li>
|
||||
</Tippy>
|
||||
</Tooltip>
|
||||
<li
|
||||
className={`${toolClass}`}
|
||||
onClick={toggleMembersVisible}
|
||||
>
|
||||
<Tooltip tip={t("channel_members")} placement="left">
|
||||
<IconPeople className={visibleAside == "members" ? "fill-gray-600" : ""} />
|
||||
return (
|
||||
<Layout
|
||||
to={cid}
|
||||
context="channel"
|
||||
dropFiles={dropFiles}
|
||||
aside={
|
||||
<ul className="flex flex-col gap-6">
|
||||
<Tooltip tip={t("pin")} placement="left">
|
||||
<Tippy
|
||||
placement="left-start"
|
||||
popperOptions={{ strategy: "fixed" }}
|
||||
offset={[0, 150]}
|
||||
interactive
|
||||
trigger="click"
|
||||
content={<PinList id={cid} />}
|
||||
>
|
||||
<li className={`${toolClass}`}>
|
||||
{pinCount > 0 ? (
|
||||
<span className="absolute -top-2 -right-2 flex-center w-4 h-4 rounded-full bg-primary-400 text-white font-bold text-[10px]">
|
||||
{pinCount}
|
||||
</span>
|
||||
) : null}
|
||||
<IconPin className="fill-gray-500" />
|
||||
</li>
|
||||
</Tippy>
|
||||
</Tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
header={
|
||||
<header className="px-5 py-4 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
|
||||
<GoBackNav />
|
||||
<div className="flex items-center gap-1">
|
||||
<ChannelIcon personal={!is_public} />
|
||||
<span className="text-gray-800 dark:text-white">{name}</span>
|
||||
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
users={
|
||||
<Members uids={memberIds} addVisible={addVisible} cid={cid} ownerId={owner} membersVisible={visibleAside == "members"} />
|
||||
}
|
||||
voice={
|
||||
<Dashboard visible={visibleAside == "voice"} id={cid} context="channel" />
|
||||
}
|
||||
/>;
|
||||
<ServerVersionChecker version="0.3.5" empty={true}>
|
||||
<VoiceChat context={`channel`} id={cid} />
|
||||
</ServerVersionChecker>
|
||||
<Tooltip tip={t("fav")} placement="left">
|
||||
<Tippy
|
||||
placement="left-start"
|
||||
popperOptions={{ strategy: "fixed" }}
|
||||
offset={[0, 164]}
|
||||
interactive
|
||||
trigger="click"
|
||||
content={<FavList cid={cid} />}
|
||||
>
|
||||
<li className={`${toolClass}`}>
|
||||
<IconFav className="fill-gray-500" />
|
||||
</li>
|
||||
</Tippy>
|
||||
</Tooltip>
|
||||
<li className={`${toolClass}`} onClick={toggleMembersVisible}>
|
||||
<Tooltip tip={t("channel_members")} placement="left">
|
||||
<IconPeople className={visibleAside == "members" ? "fill-gray-600" : ""} />
|
||||
</Tooltip>
|
||||
</li>
|
||||
</ul>
|
||||
}
|
||||
header={
|
||||
<header className="px-5 py-4 flex items-center justify-center md:justify-between shadow-[inset_0_-1px_0_rgb(0_0_0_/_10%)]">
|
||||
<GoBackNav />
|
||||
<div className="flex items-center gap-1">
|
||||
<ChannelIcon personal={!is_public} />
|
||||
<span className="text-gray-800 dark:text-white">{name}</span>
|
||||
<span className="ml-2 text-gray-500 hidden md:block">{description}</span>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
users={
|
||||
<Members
|
||||
uids={memberIds}
|
||||
addVisible={addVisible}
|
||||
cid={cid}
|
||||
ownerId={owner}
|
||||
membersVisible={visibleAside == "members"}
|
||||
/>
|
||||
}
|
||||
voice={<Dashboard visible={visibleAside == "voice"} id={cid} context="channel" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default memo(ChannelChat, (prev, next) => prev.cid == next.cid);
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { FC, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import FavList from "../FavList";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import FavIcon from "@/assets/icons/bookmark.svg";
|
||||
import User from "@/components/User";
|
||||
import Layout from "../Layout";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import GoBackNav from "@/components/GoBackNav";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import User from "@/components/User";
|
||||
import FavIcon from "@/assets/icons/bookmark.svg";
|
||||
import FavList from "../FavList";
|
||||
import Layout from "../Layout";
|
||||
import VoiceChat from "../VoiceChat";
|
||||
|
||||
type Props = {
|
||||
uid: number;
|
||||
dropFiles?: File[];
|
||||
@@ -18,7 +20,7 @@ const DMChat: FC<Props> = ({ uid = 0, dropFiles }) => {
|
||||
const navigate = useNavigate();
|
||||
const { currUser } = useAppSelector((store) => {
|
||||
return {
|
||||
currUser: store.users.byId[uid],
|
||||
currUser: store.users.byId[uid]
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { MouseEvent, FC } from "react";
|
||||
import { FC, MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import FavoredMessage from "@/components/Message/FavoredMessage";
|
||||
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
|
||||
import IconRemove from "@/assets/icons/close.svg";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import IconRemove from "@/assets/icons/close.svg";
|
||||
import IconSurprise from "@/assets/icons/emoji.surprise.svg";
|
||||
|
||||
type Props = { cid?: number; uid?: number };
|
||||
const FavList: FC<Props> = ({ cid = null, uid = null }) => {
|
||||
@@ -17,17 +18,24 @@ const FavList: FC<Props> = ({ cid = null, uid = null }) => {
|
||||
const noFavs = favorites.length == 0;
|
||||
return (
|
||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-xl min-w-[500px] max-h-[500px] overflow-auto drop-shadow-[0px_25px_50px_rgba(31,_41,_55,_0.25)]">
|
||||
<h4 className="font-bold text-gray-600 dark:text-gray-400 mb-4">{t('fav_msg')}({favorites.length})</h4>
|
||||
<h4 className="font-bold text-gray-600 dark:text-gray-400 mb-4">
|
||||
{t("fav_msg")}({favorites.length})
|
||||
</h4>
|
||||
{noFavs ? (
|
||||
<div className="flex flex-col gap-2 w-full items-center p-4">
|
||||
<IconSurprise />
|
||||
<div className="w-60 text-gray-600 dark:text-gray-400 text-center font-bold">{t("fav_empty_tip")}</div>
|
||||
<div className="w-60 text-gray-600 dark:text-gray-400 text-center font-bold">
|
||||
{t("fav_empty_tip")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{favorites.map(({ id }) => {
|
||||
return (
|
||||
<li key={id} className="relative border border-solid border-slate-200 dark:border-gray-600 rounded-md group">
|
||||
<li
|
||||
key={id}
|
||||
className="relative border border-solid border-slate-200 dark:border-gray-600 rounded-md group"
|
||||
>
|
||||
<FavoredMessage id={id} />
|
||||
<div className="flex items-center absolute top-2 right-2 border border-solid border-gray-300 dark:border-gray-600 rounded-md overflow-hidden invisible group-hover:visible">
|
||||
<button className="flex-center w-6 h-6 p-1" data-id={id} onClick={handleRemove}>
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { BASE_ORIGIN } from "@/app/config";
|
||||
import { resetAuthData } from "@/app/slices/auth.data";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import QRCode from "@/components/QRCode";
|
||||
import Button from "@/components/styled/Button";
|
||||
import useLogout from "@/hooks/useLogout";
|
||||
|
||||
// type Props = {};
|
||||
|
||||
const GuestBlankPlaceholder = () => {
|
||||
@@ -23,13 +25,23 @@ const GuestBlankPlaceholder = () => {
|
||||
};
|
||||
return (
|
||||
<section className="flex flex-col items-center text-center">
|
||||
<h2 className="text-3xl text-gray-600 dark:text-gray-50 font-bold">{t("welcome", { name: serverName })}</h2>
|
||||
<h2 className="text-3xl text-gray-600 dark:text-gray-50 font-bold">
|
||||
{t("welcome", { name: serverName })}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-gray-400 dark:text-gray-200 my-3 text-sm">{t("guest_login_tip")}</span>
|
||||
<span className="text-gray-400 dark:text-gray-200 my-3 text-sm">
|
||||
{t("guest_login_tip")}
|
||||
</span>
|
||||
<div className="w-44 h-44 self-center mb-4">
|
||||
<QRCode level="Q" size={1200} link={`https://voce.chat/login?s=${encodeURIComponent(BASE_ORIGIN)}`} />
|
||||
<QRCode
|
||||
level="Q"
|
||||
size={1200}
|
||||
link={`https://voce.chat/login?s=${encodeURIComponent(BASE_ORIGIN)}`}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSignIn} className="small">{t("sign_in")}</Button>
|
||||
<Button onClick={handleSignIn} className="small">
|
||||
{t("sign_in")}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// import { useState, useEffect } from "react";
|
||||
// import { NavLink } from "react-router-dom";
|
||||
// import { useTranslation } from "react-i18next";
|
||||
import ChannelIcon from "@/components/ChannelIcon";
|
||||
import Layout from "../Layout";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import ChannelIcon from "@/components/ChannelIcon";
|
||||
import GoBackNav from "@/components/GoBackNav";
|
||||
import Layout from "../Layout";
|
||||
|
||||
type Props = {
|
||||
cid?: number;
|
||||
@@ -13,7 +13,7 @@ export default function GuestChannelChat({ cid = 0 }: Props) {
|
||||
// const { t } = useTranslation("chat");
|
||||
const { data } = useAppSelector((store) => {
|
||||
return {
|
||||
data: store.channels.byId[cid],
|
||||
data: store.channels.byId[cid]
|
||||
};
|
||||
});
|
||||
if (!data) return null;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect, FC } from "react";
|
||||
import clsx from "clsx";
|
||||
import { renderPreviewMessage } from "../../chat/utils";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { fromNowTime } from "@/utils";
|
||||
import { renderPreviewMessage } from "../../chat/utils";
|
||||
|
||||
interface IProps {
|
||||
id: number;
|
||||
@@ -39,13 +40,33 @@ const Session: FC<IProps> = ({ id, mid }) => {
|
||||
|
||||
return (
|
||||
<li className="session">
|
||||
<NavLink className={({ isActive: linkActive }) => clsx(`nav flex gap-2 rounded-lg p-2 w-full md:hover:bg-gray-500/20`, linkActive && "bg-gray-500/20")} to={`/chat/channel/${id}`}>
|
||||
<NavLink
|
||||
className={({ isActive: linkActive }) =>
|
||||
clsx(
|
||||
`nav flex gap-2 rounded-lg p-2 w-full md:hover:bg-gray-500/20`,
|
||||
linkActive && "bg-gray-500/20"
|
||||
)
|
||||
}
|
||||
to={`/chat/channel/${id}`}
|
||||
>
|
||||
<div className="flex shrink-0">
|
||||
<Avatar width={40} height={40} className="icon rounded-full" type="channel" name={name} src={icon} />
|
||||
<Avatar
|
||||
width={40}
|
||||
height={40}
|
||||
className="icon rounded-full"
|
||||
type="channel"
|
||||
name={name}
|
||||
src={icon}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={clsx(`flex items-center gap-2 font-semibold text-sm text-gray-500 dark:text-white truncate`, previewMsg.created_at && "max-w-[190px]")}>
|
||||
<span
|
||||
className={clsx(
|
||||
`flex items-center gap-2 font-semibold text-sm text-gray-500 dark:text-white truncate`,
|
||||
previewMsg.created_at && "max-w-[190px]"
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600 max-w-[80px] truncate">
|
||||
@@ -53,7 +74,9 @@ const Session: FC<IProps> = ({ id, mid }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500 w-36 truncate">{renderPreviewMessage(previewMsg)}</span>
|
||||
<span className="text-xs text-gray-500 w-36 truncate">
|
||||
{renderPreviewMessage(previewMsg)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FC } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import Session from "./Session";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import LoginTip from "../Layout/LoginTip";
|
||||
import Session from "./Session";
|
||||
|
||||
export interface ChatSession {
|
||||
key: string;
|
||||
id: number;
|
||||
|
||||
@@ -1,42 +1,57 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import IconAdd from '@/assets/icons/add.person.svg';
|
||||
import IconBlock from '@/assets/icons/block.svg';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import { useUpdateContactStatusMutation } from '../../../app/services/user';
|
||||
import { ContactAction } from '../../../types/user';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import IconAdd from "@/assets/icons/add.person.svg";
|
||||
import IconBlock from "@/assets/icons/block.svg";
|
||||
import { useUpdateContactStatusMutation } from "../../../app/services/user";
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import { ContactAction } from "../../../types/user";
|
||||
|
||||
type Props = {
|
||||
uid: number
|
||||
}
|
||||
|
||||
const AddContactTip = (props: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const [updateContactStatus] = useUpdateContactStatusMutation();
|
||||
const { targetUser, enableContact } = useAppSelector(store => {
|
||||
return { targetUser: store.users.byId[props.uid], enableContact: store.server.contact_verification_enable };
|
||||
});
|
||||
const handleContactStatus = (action: ContactAction) => {
|
||||
updateContactStatus({ target_uid: props.uid, action });
|
||||
};
|
||||
const itemClass = `cursor-pointer flex flex-col items-center gap-1 rounded-lg w-32 text-primary-400 bg-gray-50 dark:bg-gray-800 text-sm pt-3.5 pb-3`;
|
||||
if (!targetUser || !enableContact) return null;
|
||||
if (targetUser.status == "added") return null;
|
||||
const blocked = targetUser.status == "blocked";
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-100 dark:bg-slate-600">
|
||||
<h3 className='text-gray-700 dark:text-gray-300 text-sm font-semibold'>{blocked ? t("contact_block_tip") : t("contact_tip")}</h3>
|
||||
<ul className='flex gap-4'>
|
||||
{!blocked && <li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
|
||||
<IconAdd className="fill-primary-400" />
|
||||
<span>{t("add_contact")}</span>
|
||||
</li>}
|
||||
<li className={itemClass} onClick={blocked ? handleContactStatus.bind(null, "unblock") : handleContactStatus.bind(null, "block")}>
|
||||
<IconBlock className="stroke-primary-400" />
|
||||
<span>{blocked ? t("unblock") : t("block")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
uid: number;
|
||||
};
|
||||
|
||||
export default AddContactTip;
|
||||
const AddContactTip = (props: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const [updateContactStatus] = useUpdateContactStatusMutation();
|
||||
const { targetUser, enableContact } = useAppSelector((store) => {
|
||||
return {
|
||||
targetUser: store.users.byId[props.uid],
|
||||
enableContact: store.server.contact_verification_enable
|
||||
};
|
||||
});
|
||||
const handleContactStatus = (action: ContactAction) => {
|
||||
updateContactStatus({ target_uid: props.uid, action });
|
||||
};
|
||||
const itemClass = `cursor-pointer flex flex-col items-center gap-1 rounded-lg w-32 text-primary-400 bg-gray-50 dark:bg-gray-800 text-sm pt-3.5 pb-3`;
|
||||
if (!targetUser || !enableContact) return null;
|
||||
if (targetUser.status == "added") return null;
|
||||
const blocked = targetUser.status == "blocked";
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-100 dark:bg-slate-600">
|
||||
<h3 className="text-gray-700 dark:text-gray-300 text-sm font-semibold">
|
||||
{blocked ? t("contact_block_tip") : t("contact_tip")}
|
||||
</h3>
|
||||
<ul className="flex gap-4">
|
||||
{!blocked && (
|
||||
<li className={itemClass} onClick={handleContactStatus.bind(null, "add")}>
|
||||
<IconAdd className="fill-primary-400" />
|
||||
<span>{t("add_contact")}</span>
|
||||
</li>
|
||||
)}
|
||||
<li
|
||||
className={itemClass}
|
||||
onClick={
|
||||
blocked
|
||||
? handleContactStatus.bind(null, "unblock")
|
||||
: handleContactStatus.bind(null, "block")
|
||||
}
|
||||
>
|
||||
<IconBlock className="stroke-primary-400" />
|
||||
<span>{blocked ? t("unblock") : t("block")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddContactTip;
|
||||
|
||||
@@ -4,152 +4,224 @@ import { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import IconCallOff from '@/assets/icons/call.off.svg';
|
||||
import IconCallAnswer from '@/assets/icons/call.svg';
|
||||
import IconMicOff from '@/assets/icons/mic.off.svg';
|
||||
// import IconMic from '@/assets/icons/mic.on.svg';
|
||||
// import IconCamera from '@/assets/icons/camera.svg';
|
||||
// import IconCameraOff from '@/assets/icons/camera.off.svg';
|
||||
// import IconScreen from '@/assets/icons/share.screen.svg';
|
||||
import { updateCallInfo } from "@/app/slices/voice";
|
||||
import { useVoice } from "@/components/Voice";
|
||||
import { playAgoraVideo } from "@/utils";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import { useVoice } from "@/components/Voice";
|
||||
import Operations from "@/components/Voice/Operations";
|
||||
import { playAgoraVideo } from "@/utils";
|
||||
import IconCallOff from "@/assets/icons/call.off.svg";
|
||||
import IconCallAnswer from "@/assets/icons/call.svg";
|
||||
import IconMicOff from "@/assets/icons/mic.off.svg";
|
||||
|
||||
type VoicingMember = {
|
||||
id: number,
|
||||
muted: boolean,
|
||||
video: boolean,
|
||||
speaking: boolean,
|
||||
name: string,
|
||||
avatar?: string
|
||||
}
|
||||
id: number;
|
||||
muted: boolean;
|
||||
video: boolean;
|
||||
speaking: boolean;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
type BlockProps = {
|
||||
onlyToSelf: boolean,
|
||||
sendByMe: boolean;
|
||||
connected: boolean;
|
||||
from: VoicingMember,
|
||||
to: VoicingMember
|
||||
}
|
||||
onlyToSelf: boolean;
|
||||
sendByMe: boolean;
|
||||
connected: boolean;
|
||||
from: VoicingMember;
|
||||
to: VoicingMember;
|
||||
};
|
||||
const VoicingBlocks = ({ onlyToSelf, sendByMe, connected, from, to }: BlockProps) => {
|
||||
const blocks = [from, to].map(({ id, speaking, name, avatar, video, muted }, idx) => {
|
||||
const showWaiting = idx == 1 && !connected && !sendByMe && !onlyToSelf;
|
||||
const showToWaiting = idx == 1 && !connected && sendByMe;
|
||||
const isMyself = sendByMe ? idx == 0 : idx == 1;
|
||||
const hiddenFrom = onlyToSelf && idx == 0;
|
||||
return <div key={id} className={clsx("group relative flex-center", video ? "w-[500px] h-[280px] overflow-hidden rounded" : "", hiddenFrom && "hidden")}>
|
||||
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity", showToWaiting && "animate-pulse", showWaiting && "opacity-40")}>
|
||||
{speaking && <div className={clsx("z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", "w-[86px] h-[86px]")}></div>}
|
||||
{showToWaiting && <div className={clsx("z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-400", "w-[88px] h-[88px]")}></div>}
|
||||
<Avatar
|
||||
width={80}
|
||||
height={80}
|
||||
className={clsx("z-20 w-full h-full rounded-full object-cover")}
|
||||
src={avatar}
|
||||
name={name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-30 absolute left-0 top-0 w-full h-full" id={`CAMERA_${id}`}>
|
||||
{/* camera video */}
|
||||
</div>
|
||||
{video && <span className={clsx("text-gray-300 bg-black/50 rounded absolute z-40", "left-1 bottom-1 py-1 px-2 text-xs")} title={name}>
|
||||
{name}
|
||||
</span>}
|
||||
{muted && !isMyself && <span className={clsx("bg-black/50 rounded absolute z-40 right-1 bottom-1 p-1", video && "invisible group-hover:visible")} title={name}>
|
||||
<IconMicOff className="w-4 h-4 fill-gray-300" />
|
||||
</span>}
|
||||
</div>;
|
||||
}
|
||||
const blocks = [from, to].map(({ id, speaking, name, avatar, video, muted }, idx) => {
|
||||
const showWaiting = idx == 1 && !connected && !sendByMe && !onlyToSelf;
|
||||
const showToWaiting = idx == 1 && !connected && sendByMe;
|
||||
const isMyself = sendByMe ? idx == 0 : idx == 1;
|
||||
const hiddenFrom = onlyToSelf && idx == 0;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={clsx(
|
||||
"group relative flex-center",
|
||||
video ? "w-[500px] h-[280px] overflow-hidden rounded" : "",
|
||||
hiddenFrom && "hidden"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"w-20 h-20 flex shrink-0 relative transition-opacity",
|
||||
showToWaiting && "animate-pulse",
|
||||
showWaiting && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{speaking && (
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking",
|
||||
"w-[86px] h-[86px]"
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
{showToWaiting && (
|
||||
<div
|
||||
className={clsx(
|
||||
"z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-gray-400",
|
||||
"w-[88px] h-[88px]"
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
<Avatar
|
||||
width={80}
|
||||
height={80}
|
||||
className={clsx("z-20 w-full h-full rounded-full object-cover")}
|
||||
src={avatar}
|
||||
name={name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-30 absolute left-0 top-0 w-full h-full" id={`CAMERA_${id}`}>
|
||||
{/* camera video */}
|
||||
</div>
|
||||
{video && (
|
||||
<span
|
||||
className={clsx(
|
||||
"text-gray-300 bg-black/50 rounded absolute z-40",
|
||||
"left-1 bottom-1 py-1 px-2 text-xs"
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
{muted && !isMyself && (
|
||||
<span
|
||||
className={clsx(
|
||||
"bg-black/50 rounded absolute z-40 right-1 bottom-1 p-1",
|
||||
video && "invisible group-hover:visible"
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<IconMicOff className="w-4 h-4 fill-gray-300" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <>{blocks}</>;
|
||||
});
|
||||
return <>{blocks}</>;
|
||||
};
|
||||
type Props = {
|
||||
uid: number
|
||||
}
|
||||
uid: number;
|
||||
};
|
||||
const DMVoice = ({ uid }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
const { voice: {
|
||||
callingFrom, callingTo, voicingMembers
|
||||
}, userData, loginUser } = useAppSelector(store => {
|
||||
return {
|
||||
voice: store.voice,
|
||||
userData: store.users.byId,
|
||||
loginUser: store.authData.user
|
||||
};
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
const {
|
||||
voice: { callingFrom, callingTo, voicingMembers },
|
||||
userData,
|
||||
loginUser
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
voice: store.voice,
|
||||
userData: store.users.byId,
|
||||
loginUser: store.authData.user
|
||||
};
|
||||
});
|
||||
const { leave, joinVoice, joining } = useVoice({ id: callingTo, context: "dm" });
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach((id) => {
|
||||
playAgoraVideo(id);
|
||||
});
|
||||
const { leave, joinVoice, joining } = useVoice({ id: callingTo, context: "dm" });
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach(id => {
|
||||
playAgoraVideo(id);
|
||||
});
|
||||
}, [voicingMembers.ids]);
|
||||
if (![callingFrom, callingTo].includes(uid)) return null;
|
||||
}, [voicingMembers.ids]);
|
||||
if (![callingFrom, callingTo].includes(uid)) return null;
|
||||
|
||||
const { name: fromUsername, avatar: fromAvatar } = userData[callingFrom];
|
||||
const { name: toUsername, avatar: toAvatar, uid: toUid } = userData[callingTo];
|
||||
const sendByMe = loginUser?.uid !== toUid;
|
||||
const onlyToSelf = voicingMembers.ids.length == 1 && voicingMembers.ids[0] == callingTo;
|
||||
const handleCancel = () => {
|
||||
console.log('cancel');
|
||||
if (sendByMe || voicingMembers.ids.length == 2 || onlyToSelf) {
|
||||
leave();
|
||||
}
|
||||
dispatch(updateCallInfo({ from: 0, to: 0 }));
|
||||
};
|
||||
const handleAnswer = () => {
|
||||
joinVoice();
|
||||
};
|
||||
const connected = voicingMembers.ids.length == 2;
|
||||
// const { muted, shareScreen, video } = voicingInfo ?? {} as VoicingInfo;
|
||||
const { speakingVolume: toSpeakingVol = 0, muted: toMuted = false, video: toVideo = false, shareScreen: toScreen = false } = voicingMembers.byId[callingTo] ?? {};
|
||||
const toSpeaking = toSpeakingVol > 50;
|
||||
const { speakingVolume: fromSpeakingVol = 0, muted: fromMuted = false, video: fromVideo = false, shareScreen: fromScreen = false } = voicingMembers.byId[callingFrom] ?? {};
|
||||
const fromSpeaking = fromSpeakingVol > 50;
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-200 dark:bg-slate-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<VoicingBlocks onlyToSelf={onlyToSelf} sendByMe={sendByMe} connected={connected} from={{
|
||||
id: callingFrom,
|
||||
muted: fromMuted,
|
||||
video: fromVideo || fromScreen,
|
||||
speaking: fromSpeaking,
|
||||
name: fromUsername,
|
||||
avatar: fromAvatar
|
||||
}} to={{
|
||||
muted: toMuted,
|
||||
id: callingTo,
|
||||
video: toVideo || toScreen,
|
||||
speaking: toSpeaking,
|
||||
name: toUsername,
|
||||
avatar: toAvatar
|
||||
}} />
|
||||
</div>
|
||||
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||
{(sendByMe || connected || onlyToSelf) && <Tooltip tip={"Leave"} placement="top">
|
||||
<button onClick={handleCancel} className='flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded'>
|
||||
<IconCallOff className="w-6 h-6" />
|
||||
</button>
|
||||
</Tooltip>}
|
||||
{!sendByMe && !connected && !onlyToSelf &&
|
||||
<Tooltip tip={"Answer"} placement="top">
|
||||
<button disabled={joining} onClick={handleAnswer} className='flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded'>
|
||||
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
{connected && <>
|
||||
<Operations id={callingTo} context="dm" mode="dm" />
|
||||
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const { name: fromUsername, avatar: fromAvatar } = userData[callingFrom];
|
||||
const { name: toUsername, avatar: toAvatar, uid: toUid } = userData[callingTo];
|
||||
const sendByMe = loginUser?.uid !== toUid;
|
||||
const onlyToSelf = voicingMembers.ids.length == 1 && voicingMembers.ids[0] == callingTo;
|
||||
const handleCancel = () => {
|
||||
console.log("cancel");
|
||||
if (sendByMe || voicingMembers.ids.length == 2 || onlyToSelf) {
|
||||
leave();
|
||||
}
|
||||
dispatch(updateCallInfo({ from: 0, to: 0 }));
|
||||
};
|
||||
const handleAnswer = () => {
|
||||
joinVoice();
|
||||
};
|
||||
const connected = voicingMembers.ids.length == 2;
|
||||
// const { muted, shareScreen, video } = voicingInfo ?? {} as VoicingInfo;
|
||||
const {
|
||||
speakingVolume: toSpeakingVol = 0,
|
||||
muted: toMuted = false,
|
||||
video: toVideo = false,
|
||||
shareScreen: toScreen = false
|
||||
} = voicingMembers.byId[callingTo] ?? {};
|
||||
const toSpeaking = toSpeakingVol > 50;
|
||||
const {
|
||||
speakingVolume: fromSpeakingVol = 0,
|
||||
muted: fromMuted = false,
|
||||
video: fromVideo = false,
|
||||
shareScreen: fromScreen = false
|
||||
} = voicingMembers.byId[callingFrom] ?? {};
|
||||
const fromSpeaking = fromSpeakingVol > 50;
|
||||
return (
|
||||
<div className="py-4 px-10 flex flex-col items-center gap-3 bg-slate-200 dark:bg-slate-800">
|
||||
<div className="flex items-center gap-4">
|
||||
<VoicingBlocks
|
||||
onlyToSelf={onlyToSelf}
|
||||
sendByMe={sendByMe}
|
||||
connected={connected}
|
||||
from={{
|
||||
id: callingFrom,
|
||||
muted: fromMuted,
|
||||
video: fromVideo || fromScreen,
|
||||
speaking: fromSpeaking,
|
||||
name: fromUsername,
|
||||
avatar: fromAvatar
|
||||
}}
|
||||
to={{
|
||||
muted: toMuted,
|
||||
id: callingTo,
|
||||
video: toVideo || toScreen,
|
||||
speaking: toSpeaking,
|
||||
name: toUsername,
|
||||
avatar: toAvatar
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={clsx("flex gap-3", connected ? "h-full items-end" : "")}>
|
||||
{(sendByMe || connected || onlyToSelf) && (
|
||||
<Tooltip tip={"Leave"} placement="top">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex-center bg-red-600 hover:bg-red-700 py-2 px-3 rounded"
|
||||
>
|
||||
<IconCallOff className="w-6 h-6" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sendByMe && !connected && !onlyToSelf && (
|
||||
<Tooltip tip={"Answer"} placement="top">
|
||||
<button
|
||||
disabled={joining}
|
||||
onClick={handleAnswer}
|
||||
className="flex-center bg-green-600 hover:bg-green-700 py-2 px-3 rounded"
|
||||
>
|
||||
<IconCallAnswer className="w-6 h-6 fill-white" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{connected && (
|
||||
<>
|
||||
<Operations id={callingTo} context="dm" mode="dm" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DMVoice;
|
||||
export default DMVoice;
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
// import { useEffect } from "react";
|
||||
import { ChatPrefixes } from "@/app/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatPrefixes } from "@/app/config";
|
||||
import { ChatContext } from "@/types/common";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
name: string
|
||||
context: ChatContext;
|
||||
name: string;
|
||||
};
|
||||
const DnDTip = ({ context, name }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`z-50 flex-center absolute left-0 top-0 w-full h-full bg-black/50`}
|
||||
>
|
||||
<div className={`z-50 flex-center absolute left-0 top-0 w-full h-full bg-black/50`}>
|
||||
<div className={`p-4 drop-shadow-md rounded-lg bg-primary-300`}>
|
||||
<div className="p-4 pt-16 border-2 border-dashed border-primary-300 rounded-md flex flex-col items-center text-white">
|
||||
<h4 className="text-xl font-semibold">{`${t("send_to")} ${ChatPrefixes[context]}${name}`}</h4>
|
||||
<h4 className="text-xl font-semibold">{`${t("send_to")} ${
|
||||
ChatPrefixes[context]
|
||||
}${name}`}</h4>
|
||||
<span className="text-sm">Photos accept jpg, png, max size limit to 10M.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import Button from "@/components/styled/Button";
|
||||
|
||||
// type Props = {};
|
||||
|
||||
const LicenseUpgradeTip = () => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// import { useEffect } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { resetAuthData } from "@/app/slices/auth.data";
|
||||
import Button from "@/components/styled/Button";
|
||||
import useLogout from "@/hooks/useLogout";
|
||||
|
||||
type Props = {
|
||||
placement?: "session" | "chat"
|
||||
placement?: "session" | "chat";
|
||||
};
|
||||
|
||||
const LoginTip = ({ placement = "chat" }: Props) => {
|
||||
@@ -23,14 +25,21 @@ const LoginTip = ({ placement = "chat" }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-between bg-slate-200/80 dark:bg-gray-800 rounded-lg p-4 border border-solid border-gray-200 dark:border-gray-500",
|
||||
placement == "session" ? "!w-[96%] md:hidden fixed bottom-2 left-1/2 -translate-x-1/2" : "w-full"
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center justify-between bg-slate-200/80 dark:bg-gray-800 rounded-lg p-4 border border-solid border-gray-200 dark:border-gray-500",
|
||||
placement == "session"
|
||||
? "!w-[96%] md:hidden fixed bottom-2 left-1/2 -translate-x-1/2"
|
||||
: "w-full"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs md:text-base text-gray-400 dark:text-gray-100">
|
||||
<i className="mr-2 text-xs md:text-lg ">👋</i>
|
||||
{t("sign_in_tip")}
|
||||
</span>
|
||||
<Button onClick={handleSignIn} className="small">{ct("action.login")}</Button>
|
||||
<Button onClick={handleSignIn} className="small">
|
||||
{ct("action.login")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import clsx from 'clsx';
|
||||
// import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import getUnreadCount from '../utils';
|
||||
import { ChatContext } from '../../../types/common';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import { ChatContext } from "../../../types/common";
|
||||
import getUnreadCount from "../utils";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
id: number,
|
||||
scrollToBottom?: () => void
|
||||
}
|
||||
context: ChatContext;
|
||||
id: number;
|
||||
scrollToBottom?: () => void;
|
||||
};
|
||||
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
|
||||
const NewMessageBottomTip = ({ context, id, scrollToBottom }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const {
|
||||
readIndex,
|
||||
mids,
|
||||
messageData,
|
||||
loginUid,
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
readIndex: context == "channel" ? store.footprint.readChannels[id] : store.footprint.readUsers[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
loginUid: store.authData.user?.uid ?? 0,
|
||||
data: context == "channel" ? store.channels.byId[id] : undefined,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
const { unreads = 0 } = getUnreadCount({
|
||||
mids,
|
||||
readIndex,
|
||||
messageData,
|
||||
loginUid
|
||||
});
|
||||
console.log("unreads", unreads);
|
||||
const { t } = useTranslation("chat");
|
||||
const { readIndex, mids, messageData, loginUid } = useAppSelector((store) => {
|
||||
return {
|
||||
readIndex:
|
||||
context == "channel" ? store.footprint.readChannels[id] : store.footprint.readUsers[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
loginUid: store.authData.user?.uid ?? 0,
|
||||
data: context == "channel" ? store.channels.byId[id] : undefined,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
const { unreads = 0 } = getUnreadCount({
|
||||
mids,
|
||||
readIndex,
|
||||
messageData,
|
||||
loginUid
|
||||
});
|
||||
console.log("unreads", unreads);
|
||||
|
||||
|
||||
return (
|
||||
<aside className={clsx(`z-[999] absolute bottom-20 right-4
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
`z-[999] absolute bottom-20 right-4
|
||||
justify-between text-xs
|
||||
rounded-full py-1 px-3 text-white
|
||||
bg-gradient-to-tl from-[#3C8CE7] to-[#00EAFF]`,
|
||||
unreads > 0 ? "flex" : "hidden")}>
|
||||
<button onClick={scrollToBottom}>{t("new_msg", { num: unreads })}</button>
|
||||
</aside>
|
||||
);
|
||||
unreads > 0 ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<button onClick={scrollToBottom}>{t("new_msg", { num: unreads })}</button>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMessageBottomTip;
|
||||
export default NewMessageBottomTip;
|
||||
|
||||
@@ -1,46 +1,49 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { triggerScrollHeight } from './MessageFeed';
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { triggerScrollHeight } from "./MessageFeed";
|
||||
|
||||
type Props = {
|
||||
count: number,
|
||||
queryKey: string
|
||||
}
|
||||
count: number;
|
||||
queryKey: string;
|
||||
};
|
||||
// linear-gradient(135deg,_#3C8CE7_0%,_#00EAFF_100%)
|
||||
const NewMessageBottomTip = ({ count, queryKey }: Props) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation("chat");
|
||||
useEffect(() => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
const { scrollHeight, scrollTop, offsetHeight } = container;
|
||||
const deltaNum = scrollHeight - scrollTop - offsetHeight;
|
||||
const showTheTip = deltaNum > triggerScrollHeight && count > 0;
|
||||
console.log("show the tip", showTheTip);
|
||||
setVisible(showTheTip);
|
||||
}
|
||||
}, [queryKey, count]);
|
||||
const handleMarkRead = () => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
// scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<aside className={clsx(`sticky top-0
|
||||
const [visible, setVisible] = useState(false);
|
||||
const { t } = useTranslation("chat");
|
||||
useEffect(() => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
const { scrollHeight, scrollTop, offsetHeight } = container;
|
||||
const deltaNum = scrollHeight - scrollTop - offsetHeight;
|
||||
const showTheTip = deltaNum > triggerScrollHeight && count > 0;
|
||||
console.log("show the tip", showTheTip);
|
||||
setVisible(showTheTip);
|
||||
}
|
||||
}, [queryKey, count]);
|
||||
const handleMarkRead = () => {
|
||||
const container = document.querySelector(queryKey) as HTMLElement;
|
||||
if (container) {
|
||||
// scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
`sticky top-0
|
||||
justify-between text-xs
|
||||
w-[95%] rounded-b-lg px-3 py-1 text-white z-10
|
||||
bg-gradient-to-tl from-[#3C8CE7] to-[#00EAFF]`,
|
||||
visible ? "flex" : "hidden")}>
|
||||
<span> {t("new_msg", { num: count })}</span>
|
||||
<button onClick={handleMarkRead}>
|
||||
{t("mark_read")}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
visible ? "flex" : "hidden"
|
||||
)}
|
||||
>
|
||||
<span> {t("new_msg", { num: count })}</span>
|
||||
<button onClick={handleMarkRead}>{t("mark_read")}</button>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewMessageBottomTip;
|
||||
export default NewMessageBottomTip;
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { FC, useState } from "react";
|
||||
import { useKey } from "rooks";
|
||||
import toast from "react-hot-toast";
|
||||
import useDeleteMessage from "@/hooks/useDeleteMessage";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import { useKey } from "rooks";
|
||||
|
||||
import { updateSelectMessages } from "@/app/slices/ui";
|
||||
import IconForward from "@/assets/icons/forward.svg";
|
||||
import IconBookmark from "@/assets/icons/bookmark.svg";
|
||||
import IconDelete from "@/assets/icons/delete.svg";
|
||||
import IconClose from "@/assets/icons/close.circle.svg";
|
||||
import ForwardModal from "@/components/ForwardModal";
|
||||
import DeleteMessageConfirmModal from "@/components/DeleteMessageConfirm";
|
||||
import { useAppDispatch, useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import DeleteMessageConfirmModal from "@/components/DeleteMessageConfirm";
|
||||
import ForwardModal from "@/components/ForwardModal";
|
||||
import useDeleteMessage from "@/hooks/useDeleteMessage";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import IconBookmark from "@/assets/icons/bookmark.svg";
|
||||
import IconClose from "@/assets/icons/close.circle.svg";
|
||||
import IconDelete from "@/assets/icons/delete.svg";
|
||||
import IconForward from "@/assets/icons/forward.svg";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext;
|
||||
@@ -66,10 +67,17 @@ const Operations: FC<Props> = ({ context, id }) => {
|
||||
<button className={optClass} onClick={handleFav}>
|
||||
<IconBookmark />
|
||||
</button>
|
||||
<button className={optClass} disabled={!canDel} onClick={toggleDeleteModal.bind(null, false)}>
|
||||
<button
|
||||
className={optClass}
|
||||
disabled={!canDel}
|
||||
onClick={toggleDeleteModal.bind(null, false)}
|
||||
>
|
||||
<IconDelete />
|
||||
</button>
|
||||
<IconClose className="cursor-pointer absolute right-5 top-1/2 -translate-y-1/2" onClick={handleClose} />
|
||||
<IconClose
|
||||
className="cursor-pointer absolute right-5 top-1/2 -translate-y-1/2"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</div>
|
||||
{forwardModalVisible && <ForwardModal mids={mids} closeModal={toggleForwardModal} />}
|
||||
{deleteModalVisible && (
|
||||
|
||||
@@ -1,54 +1,60 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Waveform } from '@uiball/loaders';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { Waveform } from "@uiball/loaders";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from '@/app/store';
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import EditIcon from "@/assets/icons/edit.svg";
|
||||
|
||||
type ChannelHeaderProps = {
|
||||
cid: number
|
||||
}
|
||||
cid: number;
|
||||
};
|
||||
const ChannelHeader = ({ cid }: ChannelHeaderProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation("chat");
|
||||
const { data, loginUser } = useAppSelector(store => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
data: store.channels.byId[cid]
|
||||
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className="pt-14 px-1 md:px-0 flex flex-col items-start gap-2">
|
||||
<h2 className="font-bold text-4xl dark:text-white">{t("welcome_channel", { name: data?.name })}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t("welcome_desc", { name: data?.name })} </p>
|
||||
{loginUser?.is_admin && (
|
||||
<NavLink to={`/setting/channel/${cid}/overview?f=${pathname}`} className="flex items-center gap-1 bg-clip-text text-fill-transparent bg-gradient-to-r from-blue-500 to-primary-400 ">
|
||||
<EditIcon className="w-4 h-4 fill-blue-500" />
|
||||
{t("edit_channel")}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const { pathname } = useLocation();
|
||||
const { t } = useTranslation("chat");
|
||||
const { data, loginUser } = useAppSelector((store) => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
data: store.channels.byId[cid]
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className="pt-14 px-1 md:px-0 flex flex-col items-start gap-2">
|
||||
<h2 className="font-bold text-4xl dark:text-white">
|
||||
{t("welcome_channel", { name: data?.name })}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300">{t("welcome_desc", { name: data?.name })} </p>
|
||||
{loginUser?.is_admin && (
|
||||
<NavLink
|
||||
to={`/setting/channel/${cid}/overview?f=${pathname}`}
|
||||
className="flex items-center gap-1 bg-clip-text text-fill-transparent bg-gradient-to-r from-blue-500 to-primary-400 "
|
||||
>
|
||||
<EditIcon className="w-4 h-4 fill-blue-500" />
|
||||
{t("edit_channel")}
|
||||
</NavLink>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
context?: {
|
||||
id: number,
|
||||
isChannel: boolean,
|
||||
loadingMore: boolean
|
||||
}
|
||||
}
|
||||
context?: {
|
||||
id: number;
|
||||
isChannel: boolean;
|
||||
loadingMore: boolean;
|
||||
};
|
||||
};
|
||||
const CustomHeader = ({ context }: Props) => {
|
||||
if (!context) return null;
|
||||
const { id, isChannel, loadingMore } = context;
|
||||
return <>
|
||||
{isChannel ? <ChannelHeader cid={id} /> : null}
|
||||
<div className={clsx("mt-2 w-full py-2 ", loadingMore ? "flex-center" : "hidden")} >
|
||||
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
|
||||
</div>
|
||||
</>;
|
||||
if (!context) return null;
|
||||
const { id, isChannel, loadingMore } = context;
|
||||
return (
|
||||
<>
|
||||
{isChannel ? <ChannelHeader cid={id} /> : null}
|
||||
<div className={clsx("mt-2 w-full py-2 ", loadingMore ? "flex-center" : "hidden")}>
|
||||
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomHeader;
|
||||
export default CustomHeader;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef } from "react";
|
||||
|
||||
// @ts-ignore
|
||||
const CustomList = forwardRef(({ style, ...props }, ref) => {
|
||||
// @ts-ignore
|
||||
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
|
||||
// @ts-ignore
|
||||
return <div style={{ ...style, width: `calc(100% - 2rem)` }} {...props} ref={ref} />;
|
||||
});
|
||||
|
||||
|
||||
export default CustomList;
|
||||
export default CustomList;
|
||||
|
||||
@@ -1,147 +1,154 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
// import clsx from 'clsx';
|
||||
// import { useTranslation } from 'react-i18next';
|
||||
import { useDebounce } from 'rooks';
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
||||
import { useDebounce } from "rooks";
|
||||
|
||||
import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from '@/app/services/message';
|
||||
import { useAppSelector } from '@/app/store';
|
||||
import { renderMessageFragment } from '../../utils';
|
||||
import { useLazyLoadMoreMessagesQuery, useReadMessageMutation } from "@/app/services/message";
|
||||
import { updateHistoryMark } from "@/app/slices/footprint";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import { renderMessageFragment } from "../../utils";
|
||||
import NewMessageBottomTip from "../NewMessageBottomTip";
|
||||
import CustomList from './CustomList';
|
||||
import CustomHeader from './CustomHeader';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateHistoryMark } from '@/app/slices/footprint';
|
||||
import { ChatContext } from '@/types/common';
|
||||
import CustomHeader from "./CustomHeader";
|
||||
import CustomList from "./CustomList";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
id: number
|
||||
}
|
||||
context: ChatContext;
|
||||
id: number;
|
||||
};
|
||||
// const firstMsgIndex = 10000;
|
||||
// let prevMids: number[] = [];
|
||||
const VirtualMessageFeed = ({ context, id }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] = useLazyLoadMoreMessagesQuery();
|
||||
const vList = useRef<VirtuosoHandle | null>(null);
|
||||
const [updateReadIndex] = useReadMessageMutation();
|
||||
const updateReadDebounced = useDebounce(updateReadIndex, 300);
|
||||
const {
|
||||
historyMid = "",
|
||||
mids = [],
|
||||
selects,
|
||||
messageData,
|
||||
loginUser,
|
||||
footprint
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
historyMid: context == "dm" ? store.footprint.historyUsers[id] : store.footprint.historyChannels[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
footprint: store.footprint,
|
||||
loginUser: store.authData.user,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
const dispatch = useDispatch();
|
||||
// const { t } = useTranslation("chat");
|
||||
// const [firstItemIndex, setFirstItemIndex] = useState(firstMsgIndex);
|
||||
const [atBottom, setAtBottom] = useState(false);
|
||||
const [loadMoreMessage, { isLoading: loadingMore, isSuccess, data: historyData }] =
|
||||
useLazyLoadMoreMessagesQuery();
|
||||
const vList = useRef<VirtuosoHandle | null>(null);
|
||||
const [updateReadIndex] = useReadMessageMutation();
|
||||
const updateReadDebounced = useDebounce(updateReadIndex, 300);
|
||||
const {
|
||||
historyMid = "",
|
||||
mids = [],
|
||||
selects,
|
||||
messageData,
|
||||
loginUser,
|
||||
footprint
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
historyMid:
|
||||
context == "dm" ? store.footprint.historyUsers[id] : store.footprint.historyChannels[id],
|
||||
mids: context == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
selects: store.ui.selectMessages[`${context}_${id}`],
|
||||
footprint: store.footprint,
|
||||
loginUser: store.authData.user,
|
||||
messageData: store.message || {}
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess && historyData) {
|
||||
if (historyData.length == 0) {
|
||||
// 到顶了
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: "reached" }));
|
||||
} else {
|
||||
// 记录最新的mid
|
||||
const [{ mid }] = historyData;
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` }));
|
||||
}
|
||||
}
|
||||
}, [isSuccess, historyData, mids, context, id]);
|
||||
// useEffect(() => {
|
||||
// console.log("diff mids", prevMids, mids);
|
||||
// const newCount = mids.length - prevMids.length;
|
||||
// setFirstItemIndex((prev) => prev - newCount);
|
||||
// }, [mids]);
|
||||
useEffect(() => {
|
||||
if (isSuccess && historyData) {
|
||||
if (historyData.length == 0) {
|
||||
// 到顶了
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: "reached" }));
|
||||
} else {
|
||||
// 记录最新的mid
|
||||
const [{ mid }] = historyData;
|
||||
dispatch(updateHistoryMark({ type: context, id, mid: `${mid}` }));
|
||||
}
|
||||
}
|
||||
}, [isSuccess, historyData, mids, context, id]);
|
||||
// useEffect(() => {
|
||||
// console.log("diff mids", prevMids, mids);
|
||||
// const newCount = mids.length - prevMids.length;
|
||||
// setFirstItemIndex((prev) => prev - newCount);
|
||||
// }, [mids]);
|
||||
|
||||
// 加载更多
|
||||
const handleTopStateChange = (isTop: boolean) => {
|
||||
console.log("reach top ", isTop);
|
||||
if (isTop) {
|
||||
if (historyMid === "reached") return;
|
||||
let lastMid = mids.slice(0, 1)[0];
|
||||
if (historyMid) {
|
||||
lastMid = +historyMid;
|
||||
}
|
||||
// prevMids = mids;
|
||||
loadMoreMessage({ context, id, mid: lastMid });
|
||||
}
|
||||
};
|
||||
// 自动跟随
|
||||
const handleFollowOutput = (isAtBottom: boolean) => {
|
||||
const [lastMid] = mids ? mids.slice(-1) : [0];
|
||||
const ts = new Date().getTime();
|
||||
// tricky
|
||||
const isSentByMyself = ts - lastMid < 1000;
|
||||
if (isAtBottom || isSentByMyself) {
|
||||
return isAtBottom ? "smooth" : true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 滚动到底部
|
||||
const handleScrollBottom = useCallback(() => {
|
||||
const vl = vList!.current;
|
||||
if (vl) {
|
||||
vl.scrollToIndex(mids.length - 1);
|
||||
}
|
||||
}, [mids]);
|
||||
const handleBottomStateChange = (bottom: boolean) => {
|
||||
setAtBottom(bottom);
|
||||
};
|
||||
const readIndex = context == "channel" ? footprint.readChannels[id] : footprint.readUsers[id];
|
||||
return <>
|
||||
<Virtuoso
|
||||
// logLevel={LogLevel.DEBUG}
|
||||
overscan={50}
|
||||
context={{ loadingMore, id, isChannel: context == "channel" }}
|
||||
id={`VOCECHAT_FEED_${context}_${id}`}
|
||||
className='px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll'
|
||||
ref={vList}
|
||||
components={{
|
||||
List: CustomList,
|
||||
Header: CustomHeader,
|
||||
}}
|
||||
// firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={mids.length - 1}
|
||||
// startReached={handleLoadMore}
|
||||
data={mids}
|
||||
atTopThreshold={context == "channel" ? 160 : 0}
|
||||
atTopStateChange={handleTopStateChange}
|
||||
atBottomStateChange={handleBottomStateChange}
|
||||
atBottomThreshold={400}
|
||||
followOutput={handleFollowOutput}
|
||||
itemContent={(idx, mid) => {
|
||||
// 计算出真正的index
|
||||
// const idx = index - firstItemIndex;
|
||||
const curr = messageData[mid];
|
||||
if (!curr) return <div className='w-full h-[1px] invisible'></div>;
|
||||
const isFirst = idx == 0;
|
||||
const prev = isFirst ? null : messageData[mids[idx - 1]];
|
||||
const read = curr?.from_uid == loginUser?.uid || mid <= readIndex;
|
||||
return renderMessageFragment({
|
||||
selectMode: !!selects,
|
||||
updateReadIndex: updateReadDebounced,
|
||||
read,
|
||||
prev,
|
||||
curr,
|
||||
contextId: id,
|
||||
context
|
||||
});
|
||||
}} />
|
||||
{!atBottom && <NewMessageBottomTip context={context} id={id} scrollToBottom={handleScrollBottom} />}
|
||||
// 加载更多
|
||||
const handleTopStateChange = (isTop: boolean) => {
|
||||
console.log("reach top ", isTop);
|
||||
if (isTop) {
|
||||
if (historyMid === "reached") return;
|
||||
let lastMid = mids.slice(0, 1)[0];
|
||||
if (historyMid) {
|
||||
lastMid = +historyMid;
|
||||
}
|
||||
// prevMids = mids;
|
||||
loadMoreMessage({ context, id, mid: lastMid });
|
||||
}
|
||||
};
|
||||
// 自动跟随
|
||||
const handleFollowOutput = (isAtBottom: boolean) => {
|
||||
const [lastMid] = mids ? mids.slice(-1) : [0];
|
||||
const ts = new Date().getTime();
|
||||
// tricky
|
||||
const isSentByMyself = ts - lastMid < 1000;
|
||||
if (isAtBottom || isSentByMyself) {
|
||||
return isAtBottom ? "smooth" : true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 滚动到底部
|
||||
const handleScrollBottom = useCallback(() => {
|
||||
const vl = vList!.current;
|
||||
if (vl) {
|
||||
vl.scrollToIndex(mids.length - 1);
|
||||
}
|
||||
}, [mids]);
|
||||
const handleBottomStateChange = (bottom: boolean) => {
|
||||
setAtBottom(bottom);
|
||||
};
|
||||
const readIndex = context == "channel" ? footprint.readChannels[id] : footprint.readUsers[id];
|
||||
return (
|
||||
<>
|
||||
<Virtuoso
|
||||
// logLevel={LogLevel.DEBUG}
|
||||
overscan={50}
|
||||
context={{ loadingMore, id, isChannel: context == "channel" }}
|
||||
id={`VOCECHAT_FEED_${context}_${id}`}
|
||||
className="px-1 md:px-4 py-4.5 overflow-x-hidden overflow-y-scroll"
|
||||
ref={vList}
|
||||
components={{
|
||||
List: CustomList,
|
||||
Header: CustomHeader
|
||||
}}
|
||||
// firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={mids.length - 1}
|
||||
// startReached={handleLoadMore}
|
||||
data={mids}
|
||||
atTopThreshold={context == "channel" ? 160 : 0}
|
||||
atTopStateChange={handleTopStateChange}
|
||||
atBottomStateChange={handleBottomStateChange}
|
||||
atBottomThreshold={400}
|
||||
followOutput={handleFollowOutput}
|
||||
itemContent={(idx, mid) => {
|
||||
// 计算出真正的index
|
||||
// const idx = index - firstItemIndex;
|
||||
const curr = messageData[mid];
|
||||
if (!curr) return <div className="w-full h-[1px] invisible"></div>;
|
||||
const isFirst = idx == 0;
|
||||
const prev = isFirst ? null : messageData[mids[idx - 1]];
|
||||
const read = curr?.from_uid == loginUser?.uid || mid <= readIndex;
|
||||
return renderMessageFragment({
|
||||
selectMode: !!selects,
|
||||
updateReadIndex: updateReadDebounced,
|
||||
read,
|
||||
prev,
|
||||
curr,
|
||||
contextId: id,
|
||||
context
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!atBottom && (
|
||||
<NewMessageBottomTip context={context} id={id} scrollToBottom={handleScrollBottom} />
|
||||
)}
|
||||
</>
|
||||
;
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualMessageFeed;
|
||||
export default VirtualMessageFeed;
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
import { useRef, useEffect, FC, ReactElement } from "react";
|
||||
import { FC, ReactElement, useEffect, useRef } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { NativeTypes } from "react-dnd-html5-backend";
|
||||
import clsx from "clsx";
|
||||
import { toast } from "react-hot-toast";
|
||||
import clsx from "clsx";
|
||||
|
||||
|
||||
import Send from "@/components/Send";
|
||||
import Operations from "./Operations";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import LoginTip from "./LoginTip";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
import LicenseUpgradeTip from "./LicenseOutdatedTip";
|
||||
import DnDTip from "./DnDTip";
|
||||
import IconWarning from '@/assets/icons/warning.svg';
|
||||
import ImagePreview from "@/components/ImagePreview";
|
||||
|
||||
import VirtualMessageFeed from "./VirtualMessageFeed";
|
||||
import DMVoice from "./DMVoicing";
|
||||
import AddContactTip from "./AddContactTip";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import ImagePreview from "@/components/ImagePreview";
|
||||
import Send from "@/components/Send";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import IconWarning from "@/assets/icons/warning.svg";
|
||||
import AddContactTip from "./AddContactTip";
|
||||
import DMVoice from "./DMVoicing";
|
||||
import DnDTip from "./DnDTip";
|
||||
import LicenseUpgradeTip from "./LicenseOutdatedTip";
|
||||
import LoginTip from "./LoginTip";
|
||||
import Operations from "./Operations";
|
||||
import VirtualMessageFeed from "./VirtualMessageFeed";
|
||||
|
||||
interface Props {
|
||||
readonly?: boolean;
|
||||
@@ -62,10 +60,10 @@ const Layout: FC<Props> = ({
|
||||
// console.log("iii", inputMode);
|
||||
if (inputMode !== "text") {
|
||||
toast("DnD not allowed in this input mode", {
|
||||
icon: <IconWarning className="w-5 h-5" />,
|
||||
icon: <IconWarning className="w-5 h-5" />
|
||||
});
|
||||
return;
|
||||
};
|
||||
}
|
||||
if (files.length) {
|
||||
const filesData = files.map((file) => {
|
||||
const { size, type, name } = file;
|
||||
@@ -99,8 +97,11 @@ const Layout: FC<Props> = ({
|
||||
<section ref={drop} className={`relative h-full w-full rounded-r-2xl flex`}>
|
||||
<main className="flex flex-col flex-1">
|
||||
{header}
|
||||
<div className="w-full h-full flex items-start justify-between relative" >
|
||||
<div className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full" ref={messagesContainer}>
|
||||
<div className="w-full h-full flex items-start justify-between relative">
|
||||
<div
|
||||
className="rounded-br-2xl flex flex-col absolute bottom-0 w-full h-full"
|
||||
ref={messagesContainer}
|
||||
>
|
||||
{context == "dm" && <DMVoice uid={to} />}
|
||||
{context == "dm" && <AddContactTip uid={to} />}
|
||||
{/* 消息流 */}
|
||||
@@ -121,15 +122,23 @@ const Layout: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{aside && <div className={clsx("flex z-50 p-3 absolute right-0 top-14 md:right-0 md:top-0 md:translate-x-full flex-col")}>
|
||||
{aside}
|
||||
</div>}
|
||||
{users && <div className="hidden md:block">{users}</div>}
|
||||
{voice && <div className="z-10 max-md:absolute max-md:right-11 max-md:top-14 max-md:overflow-y-scroll bg-white dark:!bg-gray-700 md:block">{voice}</div>}
|
||||
{!readonly && inputMode == "text" && isActive && (
|
||||
<DnDTip context={context} name={name} />
|
||||
{aside && (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex z-50 p-3 absolute right-0 top-14 md:right-0 md:top-0 md:translate-x-full flex-col"
|
||||
)}
|
||||
>
|
||||
{aside}
|
||||
</div>
|
||||
)}
|
||||
</section >
|
||||
{users && <div className="hidden md:block">{users}</div>}
|
||||
{voice && (
|
||||
<div className="z-10 max-md:absolute max-md:right-11 max-md:top-14 max-md:overflow-y-scroll bg-white dark:!bg-gray-700 md:block">
|
||||
{voice}
|
||||
</div>
|
||||
)}
|
||||
{!readonly && inputMode == "text" && isActive && <DnDTip context={context} name={name} />}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { FC } from "react";
|
||||
import { Waveform } from "@uiball/loaders";
|
||||
|
||||
type Props = {
|
||||
};
|
||||
type Props = {};
|
||||
const LoadMore: FC<Props> = () => {
|
||||
return (
|
||||
<div data-load-more className="mt-2 flex-center w-full py-2" >
|
||||
<div data-load-more className="mt-2 flex-center w-full py-2">
|
||||
<Waveform size={18} lineWeight={4} speed={1} color="#ccc" />
|
||||
</div>
|
||||
);
|
||||
|
||||
+137
-87
@@ -1,92 +1,142 @@
|
||||
// import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
import { useAppSelector } from '../../app/store';
|
||||
import User from '../../components/User';
|
||||
import IconMic from '@/assets/icons/mic.on.svg';
|
||||
import IconMicOff from '@/assets/icons/mic.off.svg';
|
||||
import IconCallOff from '@/assets/icons/headphone.svg';
|
||||
import IconSoundOn from '@/assets/icons/sound.on.svg';
|
||||
import IconSoundOff from '@/assets/icons/sound.off.svg';
|
||||
import IconCamera from '@/assets/icons/camera.svg';
|
||||
import IconScreen from '@/assets/icons/share.screen.svg';
|
||||
import { useVoice } from '../../components/Voice';
|
||||
import Signal from '../../components/Signal';
|
||||
import Tooltip from '../../components/Tooltip';
|
||||
import { ChatContext } from '../../types/common';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import IconCamera from "@/assets/icons/camera.svg";
|
||||
import IconCallOff from "@/assets/icons/headphone.svg";
|
||||
import IconMicOff from "@/assets/icons/mic.off.svg";
|
||||
import IconMic from "@/assets/icons/mic.on.svg";
|
||||
import IconScreen from "@/assets/icons/share.screen.svg";
|
||||
import IconSoundOff from "@/assets/icons/sound.off.svg";
|
||||
import IconSoundOn from "@/assets/icons/sound.on.svg";
|
||||
import { useAppSelector } from "../../app/store";
|
||||
import Signal from "../../components/Signal";
|
||||
import Tooltip from "../../components/Tooltip";
|
||||
import User from "../../components/User";
|
||||
import { useVoice } from "../../components/Voice";
|
||||
import { ChatContext } from "../../types/common";
|
||||
|
||||
type Props = {
|
||||
id: number,
|
||||
context?: ChatContext
|
||||
}
|
||||
|
||||
const RTCWidget = ({ id, context = "channel" }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const { leave, voicingInfo, setMute, setDeafen, joining = true, openCamera, closeCamera, startShareScreen, stopShareScreen } = useVoice({ context, id });
|
||||
const { callFrom, callTo, loginUser, channelData, userData } = useAppSelector(store => {
|
||||
return {
|
||||
callFrom: store.voice.callingFrom,
|
||||
callTo: store.voice.callingTo,
|
||||
userData: store.users.byId,
|
||||
channelData: store.channels.byId,
|
||||
loginUser: store.authData.user,
|
||||
};
|
||||
});
|
||||
if (!loginUser || !voicingInfo || joining) return null;
|
||||
// const name = voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : userData[voicingInfo.id]?.name;
|
||||
// if (!name) return null;
|
||||
const isReConnecting = voicingInfo.connectionState == "RECONNECTING";
|
||||
const targetDMUsername = callFrom == loginUser.uid ? userData[callTo]?.name : userData[callFrom]?.name;
|
||||
return (
|
||||
<div className='bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 mb-4 text-sm'>
|
||||
<div className="border-b border-b-gray-200 dark:border-b-gray-800 pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Signal strength={voicingInfo.downlinkNetworkQuality} />
|
||||
<div className="flex flex-col">
|
||||
<span className={clsx('text-green-800 font-bold', isReConnecting && `text-red-500`)}>{isReConnecting ? `Voice Reconnecting...` : `Voice Connected`}</span>
|
||||
<span className='text-gray-600 dark:text-gray-400 text-xs truncate max-w-[170px]' >
|
||||
{voicingInfo.context == "channel" ? "Channel" : "DM"} / {voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : targetDMUsername}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip tip={t("leave_voice")} placement="top">
|
||||
<IconCallOff onClick={leave} role="button" className="fill-red-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={clsx("flex items-center justify-around gap-2 mt-3 text-sm font-semibold text-gray-800 dark:text-gray-200")}>
|
||||
<button onClick={voicingInfo.video ? closeCamera : openCamera} className={clsx('rounded-lg py-2 px-5 flex items-center gap-1', voicingInfo.video ? "bg-green-700 text-gray-200" : "bg-white/50 dark:bg-black/50 ")}>
|
||||
<IconCamera className={clsx("dark:fill-gray-200 w-6 h-6", voicingInfo.video ? "fill-gray-200" : "fill-gray-800")} />
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button onClick={voicingInfo.shareScreen ? stopShareScreen : startShareScreen} className={clsx('rounded-lg py-2 px-5 flex items-center gap-1', voicingInfo.shareScreen ? "bg-green-700 text-gray-200" : "bg-white/50 dark:bg-black/50 ")}>
|
||||
<IconScreen className={clsx("dark:fill-gray-200 w-6 h-6", voicingInfo.shareScreen ? "fill-gray-200" : "fill-gray-800")} />
|
||||
<span>Screen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<User uid={loginUser.uid} compact />
|
||||
<div className="flex flex-col">
|
||||
<span className='dark:text-white text-sm font-bold'>{loginUser.name}</span>
|
||||
<span className='text-gray-400 text-xs'>#{loginUser.uid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 px-1">
|
||||
<Tooltip tip={voicingInfo.deafen ? t("undeafen") : t("deafen")} placement="top">
|
||||
{voicingInfo.deafen ? <IconSoundOff role="button" onClick={setDeafen.bind(null, false)} /> : <IconSoundOn role="button" onClick={setDeafen.bind(null, true)} />}
|
||||
</Tooltip>
|
||||
<Tooltip tip={voicingInfo.muted ? t("unmute") : t("mute")} placement="top">
|
||||
{voicingInfo.muted ?
|
||||
<IconMicOff className="w-6 h-6" onClick={setMute.bind(null, false)} role="button" />
|
||||
:
|
||||
<IconMic className="w-6 h-6" onClick={setMute.bind(null, true)} role="button" />}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
id: number;
|
||||
context?: ChatContext;
|
||||
};
|
||||
|
||||
export default RTCWidget;
|
||||
const RTCWidget = ({ id, context = "channel" }: Props) => {
|
||||
const { t } = useTranslation("chat");
|
||||
const {
|
||||
leave,
|
||||
voicingInfo,
|
||||
setMute,
|
||||
setDeafen,
|
||||
joining = true,
|
||||
openCamera,
|
||||
closeCamera,
|
||||
startShareScreen,
|
||||
stopShareScreen
|
||||
} = useVoice({ context, id });
|
||||
const { callFrom, callTo, loginUser, channelData, userData } = useAppSelector((store) => {
|
||||
return {
|
||||
callFrom: store.voice.callingFrom,
|
||||
callTo: store.voice.callingTo,
|
||||
userData: store.users.byId,
|
||||
channelData: store.channels.byId,
|
||||
loginUser: store.authData.user
|
||||
};
|
||||
});
|
||||
if (!loginUser || !voicingInfo || joining) return null;
|
||||
// const name = voicingInfo.context == "channel" ? channelData[voicingInfo.id]?.name : userData[voicingInfo.id]?.name;
|
||||
// if (!name) return null;
|
||||
const isReConnecting = voicingInfo.connectionState == "RECONNECTING";
|
||||
const targetDMUsername =
|
||||
callFrom == loginUser.uid ? userData[callTo]?.name : userData[callFrom]?.name;
|
||||
return (
|
||||
<div className="bg-gray-100 dark:bg-gray-900 flex flex-col p-2 rounded-3xl m-3 mb-4 text-sm">
|
||||
<div className="border-b border-b-gray-200 dark:border-b-gray-800 pb-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<Signal strength={voicingInfo.downlinkNetworkQuality} />
|
||||
<div className="flex flex-col">
|
||||
<span className={clsx("text-green-800 font-bold", isReConnecting && `text-red-500`)}>
|
||||
{isReConnecting ? `Voice Reconnecting...` : `Voice Connected`}
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400 text-xs truncate max-w-[170px]">
|
||||
{voicingInfo.context == "channel" ? "Channel" : "DM"} /{" "}
|
||||
{voicingInfo.context == "channel"
|
||||
? channelData[voicingInfo.id]?.name
|
||||
: targetDMUsername}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip tip={t("leave_voice")} placement="top">
|
||||
<IconCallOff onClick={leave} role="button" className="fill-red-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center justify-around gap-2 mt-3 text-sm font-semibold text-gray-800 dark:text-gray-200"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={voicingInfo.video ? closeCamera : openCamera}
|
||||
className={clsx(
|
||||
"rounded-lg py-2 px-5 flex items-center gap-1",
|
||||
voicingInfo.video ? "bg-green-700 text-gray-200" : "bg-white/50 dark:bg-black/50 "
|
||||
)}
|
||||
>
|
||||
<IconCamera
|
||||
className={clsx(
|
||||
"dark:fill-gray-200 w-6 h-6",
|
||||
voicingInfo.video ? "fill-gray-200" : "fill-gray-800"
|
||||
)}
|
||||
/>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={voicingInfo.shareScreen ? stopShareScreen : startShareScreen}
|
||||
className={clsx(
|
||||
"rounded-lg py-2 px-5 flex items-center gap-1",
|
||||
voicingInfo.shareScreen
|
||||
? "bg-green-700 text-gray-200"
|
||||
: "bg-white/50 dark:bg-black/50 "
|
||||
)}
|
||||
>
|
||||
<IconScreen
|
||||
className={clsx(
|
||||
"dark:fill-gray-200 w-6 h-6",
|
||||
voicingInfo.shareScreen ? "fill-gray-200" : "fill-gray-800"
|
||||
)}
|
||||
/>
|
||||
<span>Screen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<User uid={loginUser.uid} compact />
|
||||
<div className="flex flex-col">
|
||||
<span className="dark:text-white text-sm font-bold">{loginUser.name}</span>
|
||||
<span className="text-gray-400 text-xs">#{loginUser.uid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 px-1">
|
||||
<Tooltip tip={voicingInfo.deafen ? t("undeafen") : t("deafen")} placement="top">
|
||||
{voicingInfo.deafen ? (
|
||||
<IconSoundOff role="button" onClick={setDeafen.bind(null, false)} />
|
||||
) : (
|
||||
<IconSoundOn role="button" onClick={setDeafen.bind(null, true)} />
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip tip={voicingInfo.muted ? t("unmute") : t("mute")} placement="top">
|
||||
{voicingInfo.muted ? (
|
||||
<IconMicOff className="w-6 h-6" onClick={setMute.bind(null, false)} role="button" />
|
||||
) : (
|
||||
<IconMic className="w-6 h-6" onClick={setMute.bind(null, true)} role="button" />
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RTCWidget;
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { FC, ReactElement } from "react";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate, useLocation, useMatch } from "react-router-dom";
|
||||
import { usePinChatMutation, useUnpinChatMutation, useUpdateMuteSettingMutation } from "@/app/services/user";
|
||||
import { useLocation, useMatch, useNavigate } from "react-router-dom";
|
||||
import Tippy from "@tippyjs/react";
|
||||
|
||||
import { useReadMessageMutation } from "@/app/services/message";
|
||||
import {
|
||||
usePinChatMutation,
|
||||
useUnpinChatMutation,
|
||||
useUpdateMuteSettingMutation
|
||||
} from "@/app/services/user";
|
||||
import { removeUserSession } from "@/app/slices/message.user";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import ContextMenu, { Item } from "@/components/ContextMenu";
|
||||
import useUserOperation from "@/hooks/useUserOperation";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import { compareVersion } from "@/utils";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext;
|
||||
pinned: boolean;
|
||||
@@ -93,57 +99,57 @@ const SessionContextMenu: FC<Props> = ({
|
||||
const items =
|
||||
context == "dm"
|
||||
? [
|
||||
pinVisible && {
|
||||
title: pinTxt,
|
||||
handler: handlePinChat
|
||||
},
|
||||
{
|
||||
title: t("action.mark_read"),
|
||||
handler: handleReadAll
|
||||
},
|
||||
{
|
||||
title: t("setting"),
|
||||
handler: handleDMSetting
|
||||
},
|
||||
canCopyEmail && {
|
||||
title: t("action.copy_email"),
|
||||
handler: copyEmail
|
||||
},
|
||||
{
|
||||
title: t("action.hide_session"),
|
||||
danger: true,
|
||||
handler: handleRemoveSession
|
||||
}
|
||||
]
|
||||
pinVisible && {
|
||||
title: pinTxt,
|
||||
handler: handlePinChat
|
||||
},
|
||||
{
|
||||
title: t("action.mark_read"),
|
||||
handler: handleReadAll
|
||||
},
|
||||
{
|
||||
title: t("setting"),
|
||||
handler: handleDMSetting
|
||||
},
|
||||
canCopyEmail && {
|
||||
title: t("action.copy_email"),
|
||||
handler: copyEmail
|
||||
},
|
||||
{
|
||||
title: t("action.hide_session"),
|
||||
danger: true,
|
||||
handler: handleRemoveSession
|
||||
}
|
||||
]
|
||||
: [
|
||||
pinVisible && {
|
||||
title: pinTxt,
|
||||
handler: handlePinChat
|
||||
},
|
||||
{
|
||||
title: t("setting"),
|
||||
underline: true,
|
||||
handler: handleChannelSetting
|
||||
},
|
||||
{
|
||||
title: t("action.mark_read"),
|
||||
// underline: true
|
||||
handler: handleReadAll
|
||||
},
|
||||
{
|
||||
title: channelMuted ? t("action.unmute") : t("action.mute"),
|
||||
handler: handleChannelMute
|
||||
},
|
||||
canInviteChannel && {
|
||||
title: t("action.invite_people"),
|
||||
handler: setInviteChannelId.bind(null, id)
|
||||
},
|
||||
canDeleteChannel && {
|
||||
title: t("action.delete_channel"),
|
||||
danger: true,
|
||||
handler: deleteChannel.bind(null, id)
|
||||
}
|
||||
];
|
||||
pinVisible && {
|
||||
title: pinTxt,
|
||||
handler: handlePinChat
|
||||
},
|
||||
{
|
||||
title: t("setting"),
|
||||
underline: true,
|
||||
handler: handleChannelSetting
|
||||
},
|
||||
{
|
||||
title: t("action.mark_read"),
|
||||
// underline: true
|
||||
handler: handleReadAll
|
||||
},
|
||||
{
|
||||
title: channelMuted ? t("action.unmute") : t("action.mute"),
|
||||
handler: handleChannelMute
|
||||
},
|
||||
canInviteChannel && {
|
||||
title: t("action.invite_people"),
|
||||
handler: setInviteChannelId.bind(null, id)
|
||||
},
|
||||
canDeleteChannel && {
|
||||
title: t("action.delete_channel"),
|
||||
danger: true,
|
||||
handler: deleteChannel.bind(null, id)
|
||||
}
|
||||
];
|
||||
return (
|
||||
<Tippy
|
||||
interactive
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect, FC } from "react";
|
||||
import clsx from "clsx";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { NativeTypes } from "react-dnd-html5-backend";
|
||||
import { useNavigate, NavLink, useMatch } from "react-router-dom";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import getUnreadCount, { renderPreviewMessage } from "../utils";
|
||||
import User from "@/components/User";
|
||||
import { NavLink, useMatch, useNavigate } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import User from "@/components/User";
|
||||
import useContextMenu from "@/hooks/useContextMenu";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import { fromNowTime } from "@/utils";
|
||||
import IconLock from "@/assets/icons/lock.svg";
|
||||
import IconMute from "@/assets/icons/mute.svg";
|
||||
import IconVoicing from "@/assets/icons/voicing.svg";
|
||||
import useContextMenu from "@/hooks/useContextMenu";
|
||||
import useUploadFile from "@/hooks/useUploadFile";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { fromNowTime } from "@/utils";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import getUnreadCount, { renderPreviewMessage } from "../utils";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
|
||||
interface IProps {
|
||||
type?: ChatContext;
|
||||
@@ -66,23 +67,31 @@ const Session: FC<IProps> = ({
|
||||
mid: number;
|
||||
is_public: boolean;
|
||||
}>();
|
||||
const { callingFrom, callingTo, messageData, userData, channelData, readIndex, loginUid, mids, muted, voiceList } = useAppSelector(
|
||||
(store) => {
|
||||
return {
|
||||
callingFrom: store.voice.callingFrom,
|
||||
callingTo: store.voice.callingTo,
|
||||
voiceList: store.voice.list,
|
||||
mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
loginUid: store.authData.user?.uid || 0,
|
||||
readIndex:
|
||||
type == "dm" ? store.footprint.readUsers[id] : store.footprint.readChannels[id],
|
||||
messageData: store.message,
|
||||
userData: store.users.byId,
|
||||
channelData: store.channels.byId,
|
||||
muted: type == "dm" ? store.footprint.muteUsers[id] : store.footprint.muteChannels[id]
|
||||
};
|
||||
}
|
||||
);
|
||||
const {
|
||||
callingFrom,
|
||||
callingTo,
|
||||
messageData,
|
||||
userData,
|
||||
channelData,
|
||||
readIndex,
|
||||
loginUid,
|
||||
mids,
|
||||
muted,
|
||||
voiceList
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
callingFrom: store.voice.callingFrom,
|
||||
callingTo: store.voice.callingTo,
|
||||
voiceList: store.voice.list,
|
||||
mids: type == "dm" ? store.userMessage.byId[id] : store.channelMessage[id],
|
||||
loginUid: store.authData.user?.uid || 0,
|
||||
readIndex: type == "dm" ? store.footprint.readUsers[id] : store.footprint.readChannels[id],
|
||||
messageData: store.message,
|
||||
userData: store.users.byId,
|
||||
channelData: store.channels.byId,
|
||||
muted: type == "dm" ? store.footprint.muteUsers[id] : store.footprint.muteChannels[id]
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const tmp = type == "dm" ? userData[id] : channelData[id];
|
||||
@@ -106,9 +115,12 @@ const Session: FC<IProps> = ({
|
||||
messageData,
|
||||
loginUid
|
||||
});
|
||||
const isVoicing = type == "channel" ? voiceList.some((item) => {
|
||||
return item.context == type && item.id === id;
|
||||
}) : (id == callingFrom || id == callingTo);
|
||||
const isVoicing =
|
||||
type == "channel"
|
||||
? voiceList.some((item) => {
|
||||
return item.context == type && item.id === id;
|
||||
})
|
||||
: id == callingFrom || id == callingTo;
|
||||
console.log("unreads", unreads, isCurrentPath);
|
||||
|
||||
return (
|
||||
@@ -125,7 +137,14 @@ const Session: FC<IProps> = ({
|
||||
>
|
||||
<NavLink
|
||||
ref={drop}
|
||||
className={({ isActive: linkActive }) => clsx(`nav flex gap-2 rounded-lg p-2 w-full`, isActive && "shadow-[inset_0_0_0_2px_#52edff]", linkActive && "bg-gray-500/20", pinned ? "md:hover:bg-gray-300/20" : "md:hover:bg-gray-500/20")}
|
||||
className={({ isActive: linkActive }) =>
|
||||
clsx(
|
||||
`nav flex gap-2 rounded-lg p-2 w-full`,
|
||||
isActive && "shadow-[inset_0_0_0_2px_#52edff]",
|
||||
linkActive && "bg-gray-500/20",
|
||||
pinned ? "md:hover:bg-gray-300/20" : "md:hover:bg-gray-500/20"
|
||||
)
|
||||
}
|
||||
to={navPath}
|
||||
onContextMenu={handleContextMenuEvent}
|
||||
>
|
||||
@@ -147,7 +166,12 @@ const Session: FC<IProps> = ({
|
||||
<div className="w-full flex flex-col justify-between overflow-hidden">
|
||||
<div className="flex items-center justify-between ">
|
||||
<span className={clsx(`flex items-center gap-1`)}>
|
||||
<i className={clsx("not-italic font-semibold text-sm text-gray-500 dark:text-white max-w-[120px] truncate", !previewMsg.created_at && "max-w-[190px]")}>
|
||||
<i
|
||||
className={clsx(
|
||||
"not-italic font-semibold text-sm text-gray-500 dark:text-white max-w-[120px] truncate",
|
||||
!previewMsg.created_at && "max-w-[190px]"
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</i>
|
||||
{!is_public && <IconLock className="dark:fill-gray-400" />}
|
||||
@@ -157,10 +181,21 @@ const Session: FC<IProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={clsx("text-xs text-gray-500 truncate", unreads > 0 ? `w-36` : ``)}>{renderPreviewMessage(previewMsg)}</span>
|
||||
{(unreads > 0 && !isCurrentPath) ? <strong className={clsx(`text-white px-1.5 py-[3px] font-bold text-[10px] leading-[10px] rounded-[10px]`, muted ? "bg-black/10 dark:bg-gray-500" : "bg-primary-400")}>
|
||||
{unreads > 99 ? "99+" : unreads}
|
||||
</strong> : (muted && <IconMute className="w-3 h-3 fill-black/10 dark:fill-gray-500" />)}
|
||||
<span className={clsx("text-xs text-gray-500 truncate", unreads > 0 ? `w-36` : ``)}>
|
||||
{renderPreviewMessage(previewMsg)}
|
||||
</span>
|
||||
{unreads > 0 && !isCurrentPath ? (
|
||||
<strong
|
||||
className={clsx(
|
||||
`text-white px-1.5 py-[3px] font-bold text-[10px] leading-[10px] rounded-[10px]`,
|
||||
muted ? "bg-black/10 dark:bg-gray-500" : "bg-primary-400"
|
||||
)}
|
||||
>
|
||||
{unreads > 99 ? "99+" : unreads}
|
||||
</strong>
|
||||
) : (
|
||||
muted && <IconMute className="w-3 h-3 fill-black/10 dark:fill-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</NavLink>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { FC, memo, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { FC, memo, useEffect, useRef, useState } from "react";
|
||||
import { ViewportList } from "react-viewport-list";
|
||||
import Session from "./Session";
|
||||
import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
|
||||
import InviteModal from "@/components/InviteModal";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import { PinChatTargetChannel, PinChatTargetUser } from "@/types/sse";
|
||||
import InviteModal from "@/components/InviteModal";
|
||||
import DeleteChannelConfirmModal from "../../settingChannel/DeleteConfirmModal";
|
||||
import Session from "./Session";
|
||||
|
||||
export interface ChatSession {
|
||||
type: ChatContext;
|
||||
id: number;
|
||||
@@ -37,7 +38,7 @@ const SessionList: FC<Props> = ({ tempSession }) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// const pinDMs=
|
||||
// const pinDMs=
|
||||
const getSessionObj = (id: number, type: "dm" | "channel") => {
|
||||
const mids = type == "dm" ? userMessage[id] : channelMessage[id];
|
||||
if (!mids || mids.length == 0) {
|
||||
@@ -47,45 +48,53 @@ const SessionList: FC<Props> = ({ tempSession }) => {
|
||||
const mid = [...mids].sort((a, b) => +a - +b).pop();
|
||||
return { id, mid, type } as ChatSession;
|
||||
};
|
||||
const pinTmps = pins.map((p) => {
|
||||
const { target } = p;
|
||||
if ("uid" in target) {
|
||||
return getSessionObj((target as PinChatTargetUser).uid, "dm");
|
||||
}
|
||||
if ("gid" in target) {
|
||||
return getSessionObj((target as PinChatTargetChannel).gid, "channel");
|
||||
}
|
||||
return null;
|
||||
}).filter((p) => !!p);
|
||||
const channelPinIds = pins.map(p => {
|
||||
if (p.target.gid) {
|
||||
return p.target.gid;
|
||||
}
|
||||
return null;
|
||||
|
||||
}).filter(id => !!id);
|
||||
const dmPinIds = pins.map(p => {
|
||||
if (p.target.uid) {
|
||||
return p.target.uid;
|
||||
}
|
||||
return null;
|
||||
|
||||
}).filter(id => !!id);
|
||||
const cSessions = channelIDs.filter(id => {
|
||||
return !channelPinIds.includes(id);
|
||||
}).map((id) => {
|
||||
return getSessionObj(id, "channel");
|
||||
});
|
||||
const uSessions = DMs.filter(id => {
|
||||
const pinTmps = pins
|
||||
.map((p) => {
|
||||
const { target } = p;
|
||||
if ("uid" in target) {
|
||||
return getSessionObj((target as PinChatTargetUser).uid, "dm");
|
||||
}
|
||||
if ("gid" in target) {
|
||||
return getSessionObj((target as PinChatTargetChannel).gid, "channel");
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((p) => !!p);
|
||||
const channelPinIds = pins
|
||||
.map((p) => {
|
||||
if (p.target.gid) {
|
||||
return p.target.gid;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((id) => !!id);
|
||||
const dmPinIds = pins
|
||||
.map((p) => {
|
||||
if (p.target.uid) {
|
||||
return p.target.uid;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((id) => !!id);
|
||||
const cSessions = channelIDs
|
||||
.filter((id) => {
|
||||
return !channelPinIds.includes(id);
|
||||
})
|
||||
.map((id) => {
|
||||
return getSessionObj(id, "channel");
|
||||
});
|
||||
const uSessions = DMs.filter((id) => {
|
||||
return !dmPinIds.includes(id);
|
||||
}).map((id) => {
|
||||
return getSessionObj(id, "dm");
|
||||
});
|
||||
const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort((a, b) => {
|
||||
const { mid: aMid = 0 } = a;
|
||||
const { mid: bMid = 0 } = b;
|
||||
return bMid - aMid;
|
||||
});
|
||||
const temps = [...(cSessions as ChatSession[]), ...(uSessions as ChatSession[])].sort(
|
||||
(a, b) => {
|
||||
const { mid: aMid = 0 } = a;
|
||||
const { mid: bMid = 0 } = b;
|
||||
return bMid - aMid;
|
||||
}
|
||||
);
|
||||
// console.log("before qqqq", temps);
|
||||
const newSessions = tempSession ? [tempSession, ...temps] : temps;
|
||||
// console.log("qqqq", newSessions);
|
||||
@@ -105,29 +114,27 @@ const SessionList: FC<Props> = ({ tempSession }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{pinSessions.length ? <ul className="flex flex-col gap-0.5 py-1 px-2 bg-primary-500/10">
|
||||
{pinSessions.map((p) => {
|
||||
const { type, id, mid = 0 } = p;
|
||||
const key = `${type}_${id}`;
|
||||
return (
|
||||
<Session
|
||||
key={key}
|
||||
type={type}
|
||||
pinned={true}
|
||||
id={id}
|
||||
mid={mid}
|
||||
setInviteChannelId={setInviteChannelId}
|
||||
setDeleteChannelId={setDeleteId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul> : null}
|
||||
{pinSessions.length ? (
|
||||
<ul className="flex flex-col gap-0.5 py-1 px-2 bg-primary-500/10">
|
||||
{pinSessions.map((p) => {
|
||||
const { type, id, mid = 0 } = p;
|
||||
const key = `${type}_${id}`;
|
||||
return (
|
||||
<Session
|
||||
key={key}
|
||||
type={type}
|
||||
pinned={true}
|
||||
id={id}
|
||||
mid={mid}
|
||||
setInviteChannelId={setInviteChannelId}
|
||||
setDeleteChannelId={setDeleteId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
<ul ref={ref} className="flex flex-1 flex-col gap-0.5 p-2 overflow-auto">
|
||||
<ViewportList
|
||||
initialPrerender={10}
|
||||
viewportRef={ref}
|
||||
items={sessions}
|
||||
>
|
||||
<ViewportList initialPrerender={10} viewportRef={ref} items={sessions}>
|
||||
{(s) => {
|
||||
const { type, id, mid = 0 } = s;
|
||||
const key = `${type}_${id}`;
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
// import { useEffect } from 'react';
|
||||
// import { useDispatch } from 'react-redux';
|
||||
import VoiceManagement from './VoiceManagement';
|
||||
import { useVoice } from '../../../components/Voice';
|
||||
import { ChatContext } from '../../../types/common';
|
||||
import { useVoice } from "../../../components/Voice";
|
||||
import { ChatContext } from "../../../types/common";
|
||||
import VoiceManagement from "./VoiceManagement";
|
||||
|
||||
type Props = {
|
||||
visible: boolean,
|
||||
context?: ChatContext,
|
||||
id: number,
|
||||
}
|
||||
visible: boolean;
|
||||
context?: ChatContext;
|
||||
id: number;
|
||||
};
|
||||
|
||||
const Dashboard = ({ context = "channel", id, visible }: Props) => {
|
||||
const { voicingInfo } = useVoice({ id, context });
|
||||
return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 border border-black/10 md:border-none md:shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}>
|
||||
<VoiceManagement id={id} context={context} info={voicingInfo} />
|
||||
</div>;
|
||||
return (
|
||||
<div
|
||||
className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 border border-black/10 md:border-none md:shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${
|
||||
visible ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<VoiceManagement id={id} context={context} info={voicingInfo} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard;
|
||||
|
||||
@@ -1,110 +1,128 @@
|
||||
import clsx from 'clsx';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { VoicingInfo, updatePin } from '../../../app/slices/voice';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import Avatar from '../../../components/Avatar';
|
||||
import IconMic from '@/assets/icons/mic.on.svg';
|
||||
import IconMicOff from '@/assets/icons/mic.off.svg';
|
||||
import IconFullscreen from '@/assets/icons/fullscreen.svg';
|
||||
import { playAgoraVideo } from '@/utils';
|
||||
import { ChatContext } from '../../../types/common';
|
||||
import Operations from '@/components/Voice/Operations';
|
||||
import { updateChannelVisibleAside } from '@/app/slices/footprint';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateChannelVisibleAside } from "@/app/slices/footprint";
|
||||
import Operations from "@/components/Voice/Operations";
|
||||
import { playAgoraVideo } from "@/utils";
|
||||
import IconFullscreen from "@/assets/icons/fullscreen.svg";
|
||||
import IconMicOff from "@/assets/icons/mic.off.svg";
|
||||
import IconMic from "@/assets/icons/mic.on.svg";
|
||||
import { updatePin, VoicingInfo } from "../../../app/slices/voice";
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import Avatar from "../../../components/Avatar";
|
||||
import { ChatContext } from "../../../types/common";
|
||||
|
||||
type Props = {
|
||||
id: number,
|
||||
context: ChatContext,
|
||||
info: VoicingInfo | null,
|
||||
}
|
||||
|
||||
const VoiceManagement = ({ id, context, info, }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const { userData, voicingMembers } = useAppSelector(store => {
|
||||
return {
|
||||
userData: store.users.byId,
|
||||
voicingMembers: store.voice.voicingMembers
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach(id => {
|
||||
playAgoraVideo(id);
|
||||
});
|
||||
}, [voicingMembers.ids]);
|
||||
const handleFullscreen = (uid?: number) => {
|
||||
if (context == "channel") {
|
||||
dispatch(updateChannelVisibleAside({ id, aside: "voice_fullscreen" }));
|
||||
if (uid) {
|
||||
dispatch(updatePin({ uid, action: "pin" }));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!info) return null;
|
||||
const nameClass = clsx(`text-sm text-gray-500 max-w-[120px] truncate font-semibold dark:text-white`);
|
||||
const members = voicingMembers.ids;
|
||||
const membersData = voicingMembers.byId;
|
||||
if (info.joining) {
|
||||
return <div className='w-full h-full flex-center p-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
Connecting to voice channel...
|
||||
</div>;
|
||||
}
|
||||
if (info.connectionState == "RECONNECTING") {
|
||||
return <div className='w-full h-full flex-center flex-col gap-1 p-1 '>
|
||||
<span className='text-red-300'>
|
||||
Reconnecting...
|
||||
</span>
|
||||
<span className='text-xs text-red-500'>Please check network connection!</span>
|
||||
</div>;
|
||||
}
|
||||
return (
|
||||
<div className='w-full h-full py-2 flex flex-col'>
|
||||
<ul className='flex grow flex-col'>
|
||||
{members.map((uid) => {
|
||||
const curr = userData[uid];
|
||||
if (!curr) return null;
|
||||
const { muted, speakingVolume = 0 } = membersData[uid];
|
||||
const speaking = speakingVolume > 50;
|
||||
return <li key={uid} className='pb-4'>
|
||||
<div className="flex items-center justify-between gap-6 pb-2">
|
||||
<div className="flex items-center gap-2 transition-opacity">
|
||||
<div className={clsx("w-8 h-8 flex shrink-0 relative")}>
|
||||
{speaking && <div className="z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking w-[36px] h-[36px]"></div>}
|
||||
<Avatar
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full rounded-full object-cover z-20"
|
||||
src={curr.avatar}
|
||||
name={curr.name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span className={nameClass} title={curr?.name}>
|
||||
{curr?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* {deafen ? <IconHeadphoneOff className="w-4" /> : <IconHeadphone className="w-4" />} */}
|
||||
{muted ? <IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" /> : <IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden group rounded relative" onDoubleClick={handleFullscreen.bind(null, uid)}>
|
||||
<div id={`CAMERA_${uid}`} className="w-[98%] m-auto">
|
||||
{/* camera placeholder */}
|
||||
</div>
|
||||
<button onClick={handleFullscreen.bind(null, uid)} className="invisible group-hover:visible w-5 h-5 p-1 bg-black/40 top-1 right-1.5 absolute rounded">
|
||||
<IconFullscreen className="w-full h-full fill-white" />
|
||||
</button>
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
<div className="grid grid-rows-2 grid-cols-4 gap-2">
|
||||
<Operations id={id} context={context} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
id: number;
|
||||
context: ChatContext;
|
||||
info: VoicingInfo | null;
|
||||
};
|
||||
|
||||
export default VoiceManagement;
|
||||
const VoiceManagement = ({ id, context, info }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const { userData, voicingMembers } = useAppSelector((store) => {
|
||||
return {
|
||||
userData: store.users.byId,
|
||||
voicingMembers: store.voice.voicingMembers
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach((id) => {
|
||||
playAgoraVideo(id);
|
||||
});
|
||||
}, [voicingMembers.ids]);
|
||||
const handleFullscreen = (uid?: number) => {
|
||||
if (context == "channel") {
|
||||
dispatch(updateChannelVisibleAside({ id, aside: "voice_fullscreen" }));
|
||||
if (uid) {
|
||||
dispatch(updatePin({ uid, action: "pin" }));
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!info) return null;
|
||||
const nameClass = clsx(
|
||||
`text-sm text-gray-500 max-w-[120px] truncate font-semibold dark:text-white`
|
||||
);
|
||||
const members = voicingMembers.ids;
|
||||
const membersData = voicingMembers.byId;
|
||||
if (info.joining) {
|
||||
return (
|
||||
<div className="w-full h-full flex-center p-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Connecting to voice channel...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (info.connectionState == "RECONNECTING") {
|
||||
return (
|
||||
<div className="w-full h-full flex-center flex-col gap-1 p-1 ">
|
||||
<span className="text-red-300">Reconnecting...</span>
|
||||
<span className="text-xs text-red-500">Please check network connection!</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full py-2 flex flex-col">
|
||||
<ul className="flex grow flex-col">
|
||||
{members.map((uid) => {
|
||||
const curr = userData[uid];
|
||||
if (!curr) return null;
|
||||
const { muted, speakingVolume = 0 } = membersData[uid];
|
||||
const speaking = speakingVolume > 50;
|
||||
return (
|
||||
<li key={uid} className="pb-4">
|
||||
<div className="flex items-center justify-between gap-6 pb-2">
|
||||
<div className="flex items-center gap-2 transition-opacity">
|
||||
<div className={clsx("w-8 h-8 flex shrink-0 relative")}>
|
||||
{speaking && (
|
||||
<div className="z-10 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking w-[36px] h-[36px]"></div>
|
||||
)}
|
||||
<Avatar
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full rounded-full object-cover z-20"
|
||||
src={curr.avatar}
|
||||
name={curr.name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span className={nameClass} title={curr?.name}>
|
||||
{curr?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* {deafen ? <IconHeadphoneOff className="w-4" /> : <IconHeadphone className="w-4" />} */}
|
||||
{muted ? (
|
||||
<IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" />
|
||||
) : (
|
||||
<IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden group rounded relative"
|
||||
onDoubleClick={handleFullscreen.bind(null, uid)}
|
||||
>
|
||||
<div id={`CAMERA_${uid}`} className="w-[98%] m-auto">
|
||||
{/* camera placeholder */}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFullscreen.bind(null, uid)}
|
||||
className="invisible group-hover:visible w-5 h-5 p-1 bg-black/40 top-1 right-1.5 absolute rounded"
|
||||
>
|
||||
<IconFullscreen className="w-full h-full fill-white" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="grid grid-rows-2 grid-cols-4 gap-2">
|
||||
<Operations id={id} context={context} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceManagement;
|
||||
|
||||
@@ -1,77 +1,88 @@
|
||||
// import React from 'react';
|
||||
// import Tippy from '@tippyjs/react';
|
||||
// import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateChannelVisibleAside, updateDMVisibleAside } from '../../../app/slices/footprint';
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import IconHeadphone from '@/assets/icons/headphone.svg';
|
||||
import Tooltip from '../../../components/Tooltip';
|
||||
import { useVoice } from '../../../components/Voice';
|
||||
import { useGetAgoraStatusQuery } from '../../../app/services/server';
|
||||
import { ChatContext } from '../../../types/common';
|
||||
import { updateCallInfo } from '../../../app/slices/voice';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
import { useGetAgoraStatusQuery } from "@/app/services/server";
|
||||
import { updateChannelVisibleAside, updateDMVisibleAside } from "@/app/slices/footprint";
|
||||
import { updateCallInfo } from "@/app/slices/voice";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import { useVoice } from "@/components/Voice";
|
||||
import IconHeadphone from "@/assets/icons/headphone.svg";
|
||||
|
||||
type Props = {
|
||||
context?: ChatContext
|
||||
id: number,
|
||||
}
|
||||
|
||||
const VoiceChat = ({ id, context = "channel" }: Props) => {
|
||||
const { joinVoice, joined, joining = false, joinedAtThisContext } = useVoice({ id, context });
|
||||
const dispatch = useDispatch();
|
||||
const { loginUser, voiceList, visibleAside } = useAppSelector(store => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
contextData: context == "channel" ? store.channels.byId[id] : store.users.byId[id],
|
||||
voiceList: store.voice.list,
|
||||
visibleAside: context == "channel" ? store.footprint.channelAsides[id] : null,
|
||||
};
|
||||
});
|
||||
const { data: enabled } = useGetAgoraStatusQuery();
|
||||
const { t } = useTranslation("chat");
|
||||
const toggleDashboard = () => {
|
||||
const data = {
|
||||
id,
|
||||
aside: visibleAside == "voice" ? null : "voice" as const
|
||||
};
|
||||
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
|
||||
};
|
||||
const handleJoin = () => {
|
||||
if (joining || joined) {
|
||||
alert("You have joined another channel, please leave first!");
|
||||
return;
|
||||
}
|
||||
joinVoice();
|
||||
const data = {
|
||||
id,
|
||||
aside: "voice" as const
|
||||
};
|
||||
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
|
||||
// 实时显示calling box
|
||||
if (!joinedAtThisContext && context == "dm") {
|
||||
dispatch(updateCallInfo({ from: loginUser?.uid ?? 0, to: id, calling: false }));
|
||||
}
|
||||
};
|
||||
if (!loginUser || !enabled) return null;
|
||||
const visible = visibleAside == "voice";
|
||||
const memberCount = voiceList.find((v) => v.context == context && v.id == id)?.memberCount ?? 0;
|
||||
const badgeClass = `absolute -top-2 -right-2 w-4 h-4 rounded-full bg-primary-400 text-white `;
|
||||
return (
|
||||
<Tooltip disabled={visible} tip={t("voice")} placement="left">
|
||||
<li className={`relative group`} >
|
||||
<IconHeadphone className={visible ? "fill-gray-600" : "fill-gray-500"} role="button" onClick={joinedAtThisContext ? toggleDashboard : handleJoin} />
|
||||
{visible ?
|
||||
null
|
||||
: <>
|
||||
{memberCount > 0 && <span className={`${badgeClass} flex-center font-bold text-[10px] group-hover:invisible`}>{memberCount}</span>}
|
||||
<span className={`${badgeClass} text-xs flex-center invisible group-hover:visible`}>
|
||||
<em className='not-italic'>+</em>
|
||||
</span>
|
||||
</>}
|
||||
</li>
|
||||
</Tooltip>
|
||||
);
|
||||
context?: ChatContext;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export default VoiceChat;
|
||||
const VoiceChat = ({ id, context = "channel" }: Props) => {
|
||||
const { joinVoice, joined, joining = false, joinedAtThisContext } = useVoice({ id, context });
|
||||
const dispatch = useDispatch();
|
||||
const { loginUser, voiceList, visibleAside } = useAppSelector((store) => {
|
||||
return {
|
||||
loginUser: store.authData.user,
|
||||
contextData: context == "channel" ? store.channels.byId[id] : store.users.byId[id],
|
||||
voiceList: store.voice.list,
|
||||
visibleAside: context == "channel" ? store.footprint.channelAsides[id] : null
|
||||
};
|
||||
});
|
||||
const { data: enabled } = useGetAgoraStatusQuery();
|
||||
const { t } = useTranslation("chat");
|
||||
const toggleDashboard = () => {
|
||||
const data = {
|
||||
id,
|
||||
aside: visibleAside == "voice" ? null : ("voice" as const)
|
||||
};
|
||||
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
|
||||
};
|
||||
const handleJoin = () => {
|
||||
if (joining || joined) {
|
||||
alert("You have joined another channel, please leave first!");
|
||||
return;
|
||||
}
|
||||
joinVoice();
|
||||
const data = {
|
||||
id,
|
||||
aside: "voice" as const
|
||||
};
|
||||
dispatch(context == "channel" ? updateChannelVisibleAside(data) : updateDMVisibleAside(data));
|
||||
// 实时显示calling box
|
||||
if (!joinedAtThisContext && context == "dm") {
|
||||
dispatch(updateCallInfo({ from: loginUser?.uid ?? 0, to: id, calling: false }));
|
||||
}
|
||||
};
|
||||
if (!loginUser || !enabled) return null;
|
||||
const visible = visibleAside == "voice";
|
||||
const memberCount = voiceList.find((v) => v.context == context && v.id == id)?.memberCount ?? 0;
|
||||
const badgeClass = `absolute -top-2 -right-2 w-4 h-4 rounded-full bg-primary-400 text-white `;
|
||||
return (
|
||||
<Tooltip disabled={visible} tip={t("voice")} placement="left">
|
||||
<li className={`relative group`}>
|
||||
<IconHeadphone
|
||||
className={visible ? "fill-gray-600" : "fill-gray-500"}
|
||||
role="button"
|
||||
onClick={joinedAtThisContext ? toggleDashboard : handleJoin}
|
||||
/>
|
||||
{visible ? null : (
|
||||
<>
|
||||
{memberCount > 0 && (
|
||||
<span
|
||||
className={`${badgeClass} flex-center font-bold text-[10px] group-hover:invisible`}
|
||||
>
|
||||
{memberCount}
|
||||
</span>
|
||||
)}
|
||||
<span className={`${badgeClass} text-xs flex-center invisible group-hover:visible`}>
|
||||
<em className="not-italic">+</em>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceChat;
|
||||
|
||||
+180
-122
@@ -1,126 +1,184 @@
|
||||
import { useEffect, useState, MouseEvent } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import clsx from 'clsx';
|
||||
import IconMic from '@/assets/icons/mic.on.svg';
|
||||
import IconMicOff from '@/assets/icons/mic.off.svg';
|
||||
import IconPin from '@/assets/icons/pin.svg';
|
||||
import IconExitScreen from '@/assets/icons/fullscreen.exit.svg';
|
||||
import { ChatContext } from '../../types/common';
|
||||
import { useAppSelector } from '../../app/store';
|
||||
import Avatar from '../../components/Avatar';
|
||||
import { playAgoraVideo } from '../../utils';
|
||||
import Tooltip from '../../components/Tooltip';
|
||||
import { useVoice } from '../../components/Voice';
|
||||
import { updatePin } from '../../app/slices/voice';
|
||||
import Operations from '@/components/Voice/Operations';
|
||||
import { MouseEvent, useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import clsx from "clsx";
|
||||
|
||||
import Operations from "@/components/Voice/Operations";
|
||||
import IconExitScreen from "@/assets/icons/fullscreen.exit.svg";
|
||||
import IconMicOff from "@/assets/icons/mic.off.svg";
|
||||
import IconMic from "@/assets/icons/mic.on.svg";
|
||||
import IconPin from "@/assets/icons/pin.svg";
|
||||
import { updatePin } from "../../app/slices/voice";
|
||||
import { useAppSelector } from "../../app/store";
|
||||
import Avatar from "../../components/Avatar";
|
||||
import Tooltip from "../../components/Tooltip";
|
||||
import { useVoice } from "../../components/Voice";
|
||||
import { ChatContext } from "../../types/common";
|
||||
import { playAgoraVideo } from "../../utils";
|
||||
|
||||
type Props = {
|
||||
context: ChatContext,
|
||||
id: number
|
||||
}
|
||||
|
||||
const VoiceFullscreen = ({ id, context }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [speakingUid, setSpeakingUid] = useState(0);
|
||||
const { voicingInfo, exitFullscreen } = useVoice({ id, context });
|
||||
const { name, userData, voicingMembers } = useAppSelector(store => {
|
||||
return {
|
||||
userData: store.users.byId,
|
||||
voicingMembers: store.voice.voicingMembers,
|
||||
name: context == "channel" ? store.channels.byId[id].name : store.users.byId[id].name
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach((id) => {
|
||||
playAgoraVideo(id);
|
||||
const { speakingVolume = 0 } = voicingMembers.byId[id];
|
||||
const speaking = speakingVolume > 50;
|
||||
if (speaking) {
|
||||
setSpeakingUid(id);
|
||||
}
|
||||
});
|
||||
|
||||
}, [voicingMembers]);
|
||||
|
||||
const handleDoubleClick = (evt: MouseEvent<HTMLLIElement>) => {
|
||||
const uid = evt.currentTarget.dataset.uid;
|
||||
if (uid) {
|
||||
dispatch(updatePin({ uid: +uid, action: "pin" }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePin = (evt: MouseEvent<HTMLButtonElement>) => {
|
||||
const uid = evt.currentTarget.dataset.uid ?? "";
|
||||
const action = evt.currentTarget.dataset.action ?? "pin";
|
||||
if (action == "unpin") {
|
||||
dispatch(updatePin({ uid: +uid, action }));
|
||||
} else {
|
||||
dispatch(updatePin({ uid: +uid, action: "pin" }));
|
||||
}
|
||||
};
|
||||
if (!voicingInfo) return null;
|
||||
const pinUid = voicingMembers.pin;
|
||||
const _name = context == "channel" ? `# ${name}` : `@ ${name}`;
|
||||
const members = voicingMembers.ids;
|
||||
const membersData = voicingMembers.byId;
|
||||
const hasPin = typeof pinUid !== "undefined";
|
||||
return (
|
||||
<div className='h-full bg-black text-gray-300 flex flex-col justify-between rounded-r-2xl'>
|
||||
{/* top */}
|
||||
<div className="px-7 py-6 flex justify-between">
|
||||
<span className='text-sm font-semibold'>{_name}</span>
|
||||
<div className="flex gap-4">
|
||||
<IconExitScreen role="button" onClick={exitFullscreen} className="fill-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
{/* middle */}
|
||||
<ul className='flex grow justify-center items-end relative gap-2'>
|
||||
{members.map((uid) => {
|
||||
const curr = userData[uid];
|
||||
if (!curr) return null;
|
||||
const { muted, speakingVolume = 0, shareScreen } = membersData[uid];
|
||||
const speaking = speakingVolume > 50;
|
||||
const special = hasPin ? pinUid == uid : uid == speakingUid;
|
||||
const disablePin = special && !hasPin;
|
||||
return <li key={uid} data-uid={uid} onDoubleClick={handleDoubleClick} className={clsx('bg-gray-700 group', special ? "absolute left-0 top-0 w-full h-[calc(100%_-_110px)] flex-center" : "relative rounded-lg py-1.5 px-12")}>
|
||||
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity")}>
|
||||
{speaking && <div className={clsx("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", special ? "w-[88px] h-[88px]" : "w-[86px] h-[86px]")}></div>}
|
||||
<Avatar
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full rounded-full object-cover z-20"
|
||||
src={curr.avatar}
|
||||
name={curr.name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
{shareScreen ? <div className={clsx("w-1 h-1 absolute z-40 rounded-full bg-green-700/60", special ? "top-2 left-2" : "top-1 left-1 px-2")} /> : null}
|
||||
{!disablePin &&
|
||||
<Tooltip tip={"Remove pin"} disabled={!special} placement="right">
|
||||
<button data-uid={uid} data-action={special ? "unpin" : "pin"} role={"button"} onClick={handlePin} className={clsx("absolute left-1 top-1 z-40 rounded bg-black/50", special ? "px-2 py-0.5" : "px-1 invisible group-hover:visible")}>
|
||||
<IconPin className={clsx(special ? "w-4 fill-green-600" : "w-3 fill-gray-200")} />
|
||||
</button>
|
||||
</Tooltip>}
|
||||
<span className={clsx("text-gray-300 bg-black/50 rounded-lg absolute z-40", special ? "left-2 bottom-2 px-2 py-1 text-sm " : "left-1 bottom-1 p-1 text-xs")} title={curr?.name}>
|
||||
{curr?.name}
|
||||
</span>
|
||||
<div className={clsx("flex items-center gap-2 absolute z-40 rounded bg-black/50", special ? "bottom-2 right-2 px-2 py-0.5" : "bottom-1 right-1 px-2")}>
|
||||
{muted ? <IconMicOff className={clsx("fill-gray-200", special ? "w-4" : "w-3")} /> : <IconMic className={clsx("fill-gray-200 ", special ? "w-4" : "w-3")} />}
|
||||
</div>
|
||||
<div id={`CAMERA_${uid}`} className={clsx("absolute top-0 left-0 z-30 w-full h-full overflow-hidden m-auto", special ? "" : "rounded")}>
|
||||
{/* camera placeholder */}
|
||||
</div>
|
||||
</li>;
|
||||
})}
|
||||
|
||||
</ul>
|
||||
{/* bottom */}
|
||||
<div className='py-4 flex justify-center gap-2'>
|
||||
<Operations id={id} context={context} mode="fullscreen" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
context: ChatContext;
|
||||
id: number;
|
||||
};
|
||||
|
||||
export default VoiceFullscreen;
|
||||
const VoiceFullscreen = ({ id, context }: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [speakingUid, setSpeakingUid] = useState(0);
|
||||
const { voicingInfo, exitFullscreen } = useVoice({ id, context });
|
||||
const { name, userData, voicingMembers } = useAppSelector((store) => {
|
||||
return {
|
||||
userData: store.users.byId,
|
||||
voicingMembers: store.voice.voicingMembers,
|
||||
name: context == "channel" ? store.channels.byId[id].name : store.users.byId[id].name
|
||||
};
|
||||
});
|
||||
useEffect(() => {
|
||||
const ids = voicingMembers.ids;
|
||||
ids.forEach((id) => {
|
||||
playAgoraVideo(id);
|
||||
const { speakingVolume = 0 } = voicingMembers.byId[id];
|
||||
const speaking = speakingVolume > 50;
|
||||
if (speaking) {
|
||||
setSpeakingUid(id);
|
||||
}
|
||||
});
|
||||
}, [voicingMembers]);
|
||||
|
||||
const handleDoubleClick = (evt: MouseEvent<HTMLLIElement>) => {
|
||||
const uid = evt.currentTarget.dataset.uid;
|
||||
if (uid) {
|
||||
dispatch(updatePin({ uid: +uid, action: "pin" }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePin = (evt: MouseEvent<HTMLButtonElement>) => {
|
||||
const uid = evt.currentTarget.dataset.uid ?? "";
|
||||
const action = evt.currentTarget.dataset.action ?? "pin";
|
||||
if (action == "unpin") {
|
||||
dispatch(updatePin({ uid: +uid, action }));
|
||||
} else {
|
||||
dispatch(updatePin({ uid: +uid, action: "pin" }));
|
||||
}
|
||||
};
|
||||
if (!voicingInfo) return null;
|
||||
const pinUid = voicingMembers.pin;
|
||||
const _name = context == "channel" ? `# ${name}` : `@ ${name}`;
|
||||
const members = voicingMembers.ids;
|
||||
const membersData = voicingMembers.byId;
|
||||
const hasPin = typeof pinUid !== "undefined";
|
||||
return (
|
||||
<div className="h-full bg-black text-gray-300 flex flex-col justify-between rounded-r-2xl">
|
||||
{/* top */}
|
||||
<div className="px-7 py-6 flex justify-between">
|
||||
<span className="text-sm font-semibold">{_name}</span>
|
||||
<div className="flex gap-4">
|
||||
<IconExitScreen role="button" onClick={exitFullscreen} className="fill-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
{/* middle */}
|
||||
<ul className="flex grow justify-center items-end relative gap-2">
|
||||
{members.map((uid) => {
|
||||
const curr = userData[uid];
|
||||
if (!curr) return null;
|
||||
const { muted, speakingVolume = 0, shareScreen } = membersData[uid];
|
||||
const speaking = speakingVolume > 50;
|
||||
const special = hasPin ? pinUid == uid : uid == speakingUid;
|
||||
const disablePin = special && !hasPin;
|
||||
return (
|
||||
<li
|
||||
key={uid}
|
||||
data-uid={uid}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={clsx(
|
||||
"bg-gray-700 group",
|
||||
special
|
||||
? "absolute left-0 top-0 w-full h-[calc(100%_-_110px)] flex-center"
|
||||
: "relative rounded-lg py-1.5 px-12"
|
||||
)}
|
||||
>
|
||||
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity")}>
|
||||
{speaking && (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking",
|
||||
special ? "w-[88px] h-[88px]" : "w-[86px] h-[86px]"
|
||||
)}
|
||||
></div>
|
||||
)}
|
||||
<Avatar
|
||||
width={80}
|
||||
height={80}
|
||||
className="w-full h-full rounded-full object-cover z-20"
|
||||
src={curr.avatar}
|
||||
name={curr.name}
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
{shareScreen ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-1 h-1 absolute z-40 rounded-full bg-green-700/60",
|
||||
special ? "top-2 left-2" : "top-1 left-1 px-2"
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{!disablePin && (
|
||||
<Tooltip tip={"Remove pin"} disabled={!special} placement="right">
|
||||
<button
|
||||
data-uid={uid}
|
||||
data-action={special ? "unpin" : "pin"}
|
||||
role={"button"}
|
||||
onClick={handlePin}
|
||||
className={clsx(
|
||||
"absolute left-1 top-1 z-40 rounded bg-black/50",
|
||||
special ? "px-2 py-0.5" : "px-1 invisible group-hover:visible"
|
||||
)}
|
||||
>
|
||||
<IconPin
|
||||
className={clsx(special ? "w-4 fill-green-600" : "w-3 fill-gray-200")}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span
|
||||
className={clsx(
|
||||
"text-gray-300 bg-black/50 rounded-lg absolute z-40",
|
||||
special ? "left-2 bottom-2 px-2 py-1 text-sm " : "left-1 bottom-1 p-1 text-xs"
|
||||
)}
|
||||
title={curr?.name}
|
||||
>
|
||||
{curr?.name}
|
||||
</span>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-2 absolute z-40 rounded bg-black/50",
|
||||
special ? "bottom-2 right-2 px-2 py-0.5" : "bottom-1 right-1 px-2"
|
||||
)}
|
||||
>
|
||||
{muted ? (
|
||||
<IconMicOff className={clsx("fill-gray-200", special ? "w-4" : "w-3")} />
|
||||
) : (
|
||||
<IconMic className={clsx("fill-gray-200 ", special ? "w-4" : "w-3")} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
id={`CAMERA_${uid}`}
|
||||
className={clsx(
|
||||
"absolute top-0 left-0 z-30 w-full h-full overflow-hidden m-auto",
|
||||
special ? "" : "rounded"
|
||||
)}
|
||||
>
|
||||
{/* camera placeholder */}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{/* bottom */}
|
||||
<div className="py-4 flex justify-center gap-2">
|
||||
<Operations id={id} context={context} mode="fullscreen" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceFullscreen;
|
||||
|
||||
+50
-26
@@ -2,19 +2,19 @@ import { memo, useState } from "react";
|
||||
import { useMatch, useParams } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import BlankPlaceholder from "@/components/BlankPlaceholder";
|
||||
import ChannelModal from "@/components/ChannelModal";
|
||||
import ErrorCatcher from "@/components/ErrorCatcher";
|
||||
import Server from "@/components/Server";
|
||||
import UsersModal from "@/components/UsersModal";
|
||||
import ChannelChat from "./ChannelChat";
|
||||
import DMChat from "./DMChat";
|
||||
import UsersModal from "@/components/UsersModal";
|
||||
import ChannelModal from "@/components/ChannelModal";
|
||||
import SessionList from "./SessionList";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import GuestBlankPlaceholder from "./GuestBlankPlaceholder";
|
||||
import GuestChannelChat from "./GuestChannelChat";
|
||||
import GuestSessionList from "./GuestSessionList";
|
||||
import ErrorCatcher from "@/components/ErrorCatcher";
|
||||
import RTCWidget from "./RTCWidget";
|
||||
import SessionList from "./SessionList";
|
||||
import VoiceFullscreen from "./VoiceFullscreen";
|
||||
|
||||
function ChatPage() {
|
||||
@@ -24,12 +24,19 @@ function ChatPage() {
|
||||
const [channelModalVisible, setChannelModalVisible] = useState(false);
|
||||
const [usersModalVisible, setUsersModalVisible] = useState(false);
|
||||
const { channel_id = 0, user_id = 0 } = useParams();
|
||||
const { sessionUids, isGuest, aside = "", callingTo } = useAppSelector((store) => {
|
||||
const {
|
||||
sessionUids,
|
||||
isGuest,
|
||||
aside = "",
|
||||
callingTo
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
callingTo: store.voice.callingTo,
|
||||
isGuest: store.authData.guest,
|
||||
sessionUids: store.userMessage.ids,
|
||||
aside: channel_id ? store.footprint.channelAsides[+channel_id] : store.footprint.dmAsides[store.voice.callingTo]
|
||||
aside: channel_id
|
||||
? store.footprint.channelAsides[+channel_id]
|
||||
: store.footprint.dmAsides[store.voice.callingTo]
|
||||
};
|
||||
});
|
||||
const toggleUsersModalVisible = () => {
|
||||
@@ -39,16 +46,18 @@ function ChatPage() {
|
||||
setChannelModalVisible((prev) => !prev);
|
||||
};
|
||||
const toggleSessionList = () => {
|
||||
setSessionListVisible(prev => !prev);
|
||||
setSessionListVisible((prev) => !prev);
|
||||
};
|
||||
const tmpSession = user_id == 0 ? undefined :
|
||||
sessionUids.findIndex((i) => i == +user_id) == -1
|
||||
const tmpSession =
|
||||
user_id == 0
|
||||
? undefined
|
||||
: sessionUids.findIndex((i) => i == +user_id) == -1
|
||||
? {
|
||||
mid: 0,
|
||||
unread: 0,
|
||||
id: +user_id,
|
||||
type: "dm" as const
|
||||
}
|
||||
mid: 0,
|
||||
unread: 0,
|
||||
id: +user_id,
|
||||
type: "dm" as const
|
||||
}
|
||||
: undefined;
|
||||
// console.log("temp uid", tmpUid);
|
||||
const placeholderVisible = channel_id == 0 && user_id == 0;
|
||||
@@ -66,24 +75,39 @@ function ChatPage() {
|
||||
<ChannelModal closeModal={toggleChannelModalVisible} personal={true} />
|
||||
)}
|
||||
{usersModalVisible && <UsersModal closeModal={toggleUsersModalVisible} />}
|
||||
<div className={clsx(`flex h-screen md:h-full md:pt-2 md:pb-2.5 md:pr-1`, isGuest ? "guest-container md:px-1" : "md:pr-12")}>
|
||||
{sessionListVisible && <div onClick={toggleSessionList} className="z-30 fixed top-0 left-4 w-screen h-screen bg-black/50 transition-all backdrop-blur-sm"></div>}
|
||||
<div className={clsx("left-container pb-14 md:pb-0 flex-col md:rounded-l-2xl w-full h-screen md:h-full md:max-w-[250px] md:min-w-[268px] shadow-[rgb(0_0_0_/_10%)_-1px_0px_0px_inset] bg-white dark:!bg-gray-800",
|
||||
isMainPath ? "flex" : "hidden md:flex"
|
||||
)}>
|
||||
<div
|
||||
className={clsx(
|
||||
`flex h-screen md:h-full md:pt-2 md:pb-2.5 md:pr-1`,
|
||||
isGuest ? "guest-container md:px-1" : "md:pr-12"
|
||||
)}
|
||||
>
|
||||
{sessionListVisible && (
|
||||
<div
|
||||
onClick={toggleSessionList}
|
||||
className="z-30 fixed top-0 left-4 w-screen h-screen bg-black/50 transition-all backdrop-blur-sm"
|
||||
></div>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
"left-container pb-14 md:pb-0 flex-col md:rounded-l-2xl w-full h-screen md:h-full md:max-w-[250px] md:min-w-[268px] shadow-[rgb(0_0_0_/_10%)_-1px_0px_0px_inset] bg-white dark:!bg-gray-800",
|
||||
isMainPath ? "flex" : "hidden md:flex"
|
||||
)}
|
||||
>
|
||||
<Server readonly={isGuest} />
|
||||
{isGuest ? <GuestSessionList /> : <SessionList tempSession={tmpSession} />}
|
||||
<RTCWidget id={+contextId} context={context} />
|
||||
</div>
|
||||
<div className={clsx(`right-container md:rounded-r-2xl w-full bg-white dark:!bg-gray-700`, placeholderVisible && "h-full flex-center", isMainPath && "hidden md:flex")}>
|
||||
<div
|
||||
className={clsx(
|
||||
`right-container md:rounded-r-2xl w-full bg-white dark:!bg-gray-700`,
|
||||
placeholderVisible && "h-full flex-center",
|
||||
isMainPath && "hidden md:flex"
|
||||
)}
|
||||
>
|
||||
{voiceFullscreenVisible && <VoiceFullscreen id={contextId} context={context} />}
|
||||
{placeholderVisible && (isGuest ? <GuestBlankPlaceholder /> : <BlankPlaceholder />)}
|
||||
{channelChatVisible &&
|
||||
(isGuest ? (
|
||||
<GuestChannelChat cid={+channel_id} />
|
||||
) : (
|
||||
<ChannelChat cid={+channel_id} />
|
||||
))}
|
||||
(isGuest ? <GuestChannelChat cid={+channel_id} /> : <ChannelChat cid={+channel_id} />)}
|
||||
{dmChatVisible && <DMChat uid={+user_id} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+18
-10
@@ -1,16 +1,17 @@
|
||||
import React from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { isImage } from "@/utils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { ContentTypes } from "@/app/config";
|
||||
import Checkbox from "@/components/styled/Checkbox";
|
||||
import Divider from "@/components/Divider";
|
||||
import Message from "@/components/Message";
|
||||
import { updateSelectMessages } from "@/app/slices/ui";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import LinkifyText from "@/components/LinkifyText";
|
||||
import i18n from "../../i18n";
|
||||
import { ChatContext } from "@/types/common";
|
||||
import Divider from "@/components/Divider";
|
||||
import LinkifyText from "@/components/LinkifyText";
|
||||
import Message from "@/components/Message";
|
||||
import Checkbox from "@/components/styled/Checkbox";
|
||||
import { isImage } from "@/utils";
|
||||
import i18n from "../../i18n";
|
||||
|
||||
export function getUnreadCount({
|
||||
mids = [],
|
||||
@@ -105,12 +106,19 @@ const MessageWrapper = ({ selectMode = false, context, id, mid, divider, childre
|
||||
return (
|
||||
<div className={`group flex flex-col items-start gap-2 relative w-full `} {...rest}>
|
||||
{divider}
|
||||
<div className={`w-full flex items-center ${selectMode ? "group-hover:bg-slate-100 dark:group-hover:bg-slate-900" : ""}`}>
|
||||
<div
|
||||
className={`w-full flex items-center ${
|
||||
selectMode ? "group-hover:bg-slate-100 dark:group-hover:bg-slate-900" : ""
|
||||
}`}
|
||||
>
|
||||
{selectMode && <Checkbox className="!ml-2" checked={selected} />}
|
||||
{children}
|
||||
</div>
|
||||
{selectMode && (
|
||||
<div className="absolute left-0 top-0 w-full h-full cursor-pointer" onClick={selectMode ? toggleSelect : undefined}></div>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full h-full cursor-pointer"
|
||||
onClick={selectMode ? toggleSelect : undefined}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -140,7 +148,7 @@ export const renderMessageFragment = ({
|
||||
const local_id = curr.properties?.local_id;
|
||||
let divider = null;
|
||||
let time = dayjs(created_at).format("YYYY/MM/DD");
|
||||
if (!prev && typeof prev !== 'undefined') {
|
||||
if (!prev && typeof prev !== "undefined") {
|
||||
// 首条信息
|
||||
divider = time;
|
||||
} else if (prev) {
|
||||
|
||||
+38
-18
@@ -1,19 +1,19 @@
|
||||
import { useState, useEffect, MouseEvent } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import { MouseEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { ContentTypes } from "@/app/config";
|
||||
import { Favorite } from "@/app/slices/favorites";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import FavoredMessage from "@/components/Message/FavoredMessage";
|
||||
import IconAudio from "@/assets/icons/file.audio.svg";
|
||||
import IconVideo from "@/assets/icons/file.video.svg";
|
||||
import IconUnknown from "@/assets/icons/file.unknown.svg";
|
||||
import IconImage from "@/assets/icons/file.image.svg";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import IconChannel from "@/assets/icons/channel.svg";
|
||||
import IconRemove from "@/assets/icons/close.svg";
|
||||
import { ContentTypes } from "@/app/config";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { Favorite } from "@/app/slices/favorites";
|
||||
import useFavMessage from "@/hooks/useFavMessage";
|
||||
import IconAudio from "@/assets/icons/file.audio.svg";
|
||||
import IconImage from "@/assets/icons/file.image.svg";
|
||||
import IconUnknown from "@/assets/icons/file.unknown.svg";
|
||||
import IconVideo from "@/assets/icons/file.video.svg";
|
||||
|
||||
type filter = "audio" | "video" | "image" | "";
|
||||
|
||||
@@ -127,11 +127,16 @@ function FavsPage() {
|
||||
return (
|
||||
<li
|
||||
key={f}
|
||||
className={clsx(f == filter && 'bg-[rgba(116,_127,_141,_0.2)]', `cursor-pointer flex items-center gap-2 p-2 rounded-lg md:hover:bg-[rgba(116,_127,_141,_0.2)]`)}
|
||||
className={clsx(
|
||||
f == filter && "bg-[rgba(116,_127,_141,_0.2)]",
|
||||
`cursor-pointer flex items-center gap-2 p-2 rounded-lg md:hover:bg-[rgba(116,_127,_141,_0.2)]`
|
||||
)}
|
||||
onClick={handleFilter.bind(null, f as filter)}
|
||||
>
|
||||
{icon}
|
||||
<span className="hidden md:block font-bold text-sm text-gray-600 dark:text-gray-100">{title}</span>
|
||||
<span className="hidden md:block font-bold text-sm text-gray-600 dark:text-gray-100">
|
||||
{title}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -145,11 +150,22 @@ function FavsPage() {
|
||||
source: { gid, uid }
|
||||
}
|
||||
] = messages;
|
||||
const tip = <span className={clsx("inline-flex items-center gap-1 mr-2")}>
|
||||
{gid ? <><IconChannel className="w-3 h-3" /> {channelData[gid]?.name}</> : <>
|
||||
From <strong className="font-bold text-gray-800 dark:text-gray-100">{userData[uid]?.name}</strong>
|
||||
</>}
|
||||
</span>;
|
||||
const tip = (
|
||||
<span className={clsx("inline-flex items-center gap-1 mr-2")}>
|
||||
{gid ? (
|
||||
<>
|
||||
<IconChannel className="w-3 h-3" /> {channelData[gid]?.name}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
From{" "}
|
||||
<strong className="font-bold text-gray-800 dark:text-gray-100">
|
||||
{userData[uid]?.name}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="max-w-[600px] flex flex-col gap-1" key={id}>
|
||||
<h4 className="inline-flex items-center text-xs text-gray-400">
|
||||
@@ -158,7 +174,11 @@ function FavsPage() {
|
||||
</h4>
|
||||
<div className="relative group">
|
||||
<FavoredMessage key={id} id={id} />
|
||||
<button className="absolute top-2 right-2 flex-center w-6 h-6 p-1 border border-solid border-slate-200 dark:border-slate-700 rounded invisible group-hover:visible" data-id={id} onClick={handleRemove} >
|
||||
<button
|
||||
className="absolute top-2 right-2 flex-center w-6 h-6 p-1 border border-solid border-slate-200 dark:border-slate-700 rounded invisible group-hover:visible"
|
||||
data-id={id}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<IconRemove className="fill-slate-900 dark:fill-slate-100" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// import { useEffect } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
import { useGuestLoginQuery } from "../app/services/auth";
|
||||
import { useAppSelector } from "../app/store";
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FC } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import IconSetting from "@/assets/icons/setting.svg";
|
||||
|
||||
type Props = {};
|
||||
const Menu: FC<Props> = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -1,79 +1,95 @@
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import { NavLink, useLocation, useMatch } from 'react-router-dom';
|
||||
import { useAppSelector } from '../../app/store';
|
||||
import React from "react";
|
||||
import { NavLink, useLocation, useMatch } from "react-router-dom";
|
||||
import clsx from "clsx";
|
||||
|
||||
import ChatIcon from "@/assets/icons/chat.svg";
|
||||
import UserIcon from "@/assets/icons/user.svg";
|
||||
import SettingIcon from "@/assets/icons/setting.svg";
|
||||
import UserIcon from "@/assets/icons/user.svg";
|
||||
import { useAppSelector } from "../../app/store";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const MobileNavs = () => {
|
||||
const isHomePath = useMatch(`/`);
|
||||
const { pathname } = useLocation();
|
||||
const isChatHomePath = useMatch(`/chat`);
|
||||
const isDMChat = useMatch(`/chat/dm/:user_id`);
|
||||
// const isSettingPage = useMatch(`/setting`);
|
||||
const isChannelChat = useMatch(`/chat/channel/:channel_id`);
|
||||
const {
|
||||
ui: {
|
||||
rememberedNavs: { chat: chatPath, user: userPath }
|
||||
}
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
ui: store.ui,
|
||||
loginUid: store.authData.user?.uid,
|
||||
guest: store.authData.guest
|
||||
};
|
||||
});
|
||||
const isHomePath = useMatch(`/`);
|
||||
const { pathname } = useLocation();
|
||||
const isChatHomePath = useMatch(`/chat`);
|
||||
const isDMChat = useMatch(`/chat/dm/:user_id`);
|
||||
// const isSettingPage = useMatch(`/setting`);
|
||||
const isChannelChat = useMatch(`/chat/channel/:channel_id`);
|
||||
const {
|
||||
ui: {
|
||||
rememberedNavs: { chat: chatPath, user: userPath }
|
||||
}
|
||||
} = useAppSelector((store) => {
|
||||
return {
|
||||
ui: store.ui,
|
||||
loginUid: store.authData.user?.uid,
|
||||
guest: store.authData.guest
|
||||
};
|
||||
});
|
||||
|
||||
const linkClass = `flex`;
|
||||
const isChatPage = isHomePath || pathname.startsWith("/chat");
|
||||
const isChattingPage = !!isDMChat || !!isChannelChat;
|
||||
// console.log("rrr", isDMChat, isChannelChat);
|
||||
const linkClass = `flex`;
|
||||
const isChatPage = isHomePath || pathname.startsWith("/chat");
|
||||
const isChattingPage = !!isDMChat || !!isChannelChat;
|
||||
// console.log("rrr", isDMChat, isChannelChat);
|
||||
|
||||
// 有点绕
|
||||
const chatNav = isChatHomePath ? "/chat" : chatPath || "/chat";
|
||||
const userNav = userPath || "/users";
|
||||
return (
|
||||
<ul className={clsx('flex justify-around py-2 fixed bottom-0 left-0 w-full bg-gray-100 dark:bg-gray-800 md:hidden', isChattingPage && "hidden")}>
|
||||
<li>
|
||||
<NavLink
|
||||
className={() => `${linkClass}`}
|
||||
to={chatNav}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
const active = isActive || isChatPage;
|
||||
return <div className='flex flex-col gap-1 items-center'>
|
||||
<ChatIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
|
||||
<span className={clsx('text-xs', !active ? "text-gray-500" : "text-primary-500")}>Chats</span>
|
||||
</div>;
|
||||
}}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink className={() => `${linkClass}`} to={userNav}>
|
||||
{({ isActive: active }) => {
|
||||
return <div className='flex flex-col gap-1 items-center'>
|
||||
<UserIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
|
||||
<span className={clsx('text-xs', !active ? "text-gray-500" : "text-primary-500")}>Contacts</span>
|
||||
</div>;
|
||||
}}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink className={() => `${linkClass}`} to={'/setting'}>
|
||||
{({ isActive: active }) => {
|
||||
return <div className='flex flex-col gap-1 items-center'>
|
||||
<SettingIcon className={clsx("w-6 h-6", !active ? "fill-gray-500" : "fill-primary-500")} />
|
||||
<span className={clsx('text-xs', !active ? "text-gray-500" : "text-primary-500")}>Settings</span>
|
||||
</div>;
|
||||
}}
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
// 有点绕
|
||||
const chatNav = isChatHomePath ? "/chat" : chatPath || "/chat";
|
||||
const userNav = userPath || "/users";
|
||||
return (
|
||||
<ul
|
||||
className={clsx(
|
||||
"flex justify-around py-2 fixed bottom-0 left-0 w-full bg-gray-100 dark:bg-gray-800 md:hidden",
|
||||
isChattingPage && "hidden"
|
||||
)}
|
||||
>
|
||||
<li>
|
||||
<NavLink className={() => `${linkClass}`} to={chatNav}>
|
||||
{({ isActive }) => {
|
||||
const active = isActive || isChatPage;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<ChatIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
|
||||
<span className={clsx("text-xs", !active ? "text-gray-500" : "text-primary-500")}>
|
||||
Chats
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink className={() => `${linkClass}`} to={userNav}>
|
||||
{({ isActive: active }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<UserIcon className={!active ? "fill-gray-500" : "fill-primary-500"} />
|
||||
<span className={clsx("text-xs", !active ? "text-gray-500" : "text-primary-500")}>
|
||||
Contacts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink className={() => `${linkClass}`} to={"/setting"}>
|
||||
{({ isActive: active }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<SettingIcon
|
||||
className={clsx("w-6 h-6", !active ? "fill-gray-500" : "fill-primary-500")}
|
||||
/>
|
||||
<span className={clsx("text-xs", !active ? "text-gray-500" : "text-primary-500")}>
|
||||
Settings
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNavs;
|
||||
export default MobileNavs;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FC } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Avatar from "@/components/Avatar";
|
||||
|
||||
interface Props {
|
||||
uid: number;
|
||||
@@ -16,7 +17,13 @@ const User: FC<Props> = ({ uid }) => {
|
||||
<div className="px-3 py-2.5 invisible md:visible">
|
||||
<NavLink to={`/setting/my_account?f=${pathname}`}>
|
||||
<div className="w-8 h-8">
|
||||
<Avatar className=" object-cover w-full h-full rounded-full" width={32} height={32} src={user.avatar} name={user.name} />
|
||||
<Avatar
|
||||
className=" object-cover w-full h-full rounded-full"
|
||||
width={32}
|
||||
height={32}
|
||||
src={user.avatar}
|
||||
name={user.name}
|
||||
/>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
+67
-37
@@ -1,27 +1,26 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import { Outlet, NavLink, useLocation, useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { NavLink, Outlet, useLocation, useMatch } from "react-router-dom";
|
||||
|
||||
import User from "./User";
|
||||
import Loading from "@/components/Loading";
|
||||
import Menu from "./Menu";
|
||||
import usePreload from "@/hooks/usePreload";
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import Notification from "@/components/Notification";
|
||||
import Manifest from "@/components/Manifest";
|
||||
import ChatIcon from "@/assets/icons/chat.svg";
|
||||
import UserIcon from "@/assets/icons/user.svg";
|
||||
import FavIcon from "@/assets/icons/bookmark.svg";
|
||||
import FolderIcon from "@/assets/icons/folder.svg";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import MobileNavs from "./MobileNavs";
|
||||
import { updateRememberedNavs } from "@/app/slices/ui";
|
||||
import UnreadTabTip from "@/components/UnreadTabTip";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Loading from "@/components/Loading";
|
||||
import Manifest from "@/components/Manifest";
|
||||
import Notification from "@/components/Notification";
|
||||
import ReLoginModal from "@/components/ReLoginModal";
|
||||
import Voice from "@/components/Voice";
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
|
||||
import Tooltip from "@/components/Tooltip";
|
||||
import UnreadTabTip from "@/components/UnreadTabTip";
|
||||
import Voice from "@/components/Voice";
|
||||
import usePreload from "@/hooks/usePreload";
|
||||
import FavIcon from "@/assets/icons/bookmark.svg";
|
||||
import ChatIcon from "@/assets/icons/chat.svg";
|
||||
import FolderIcon from "@/assets/icons/folder.svg";
|
||||
import UserIcon from "@/assets/icons/user.svg";
|
||||
import Menu from "./Menu";
|
||||
import MobileNavs from "./MobileNavs";
|
||||
import User from "./User";
|
||||
|
||||
function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -69,45 +68,76 @@ function HomePage() {
|
||||
<>
|
||||
{roleChanged && <ReLoginModal />}
|
||||
{!guest && <UnreadTabTip />}
|
||||
{!guest && <ServerVersionChecker empty={true} version="0.3.5">
|
||||
<Voice />
|
||||
</ServerVersionChecker>}
|
||||
{!guest && (
|
||||
<ServerVersionChecker empty={true} version="0.3.5">
|
||||
<Voice />
|
||||
</ServerVersionChecker>
|
||||
)}
|
||||
<Manifest />
|
||||
{!guest && <Notification />}
|
||||
<div className={`vocechat-container flex w-screen h-screen bg-gray-200 dark:bg-gray-900`}>
|
||||
{!guest && (
|
||||
<div className={`hidden md:flex h-full flex-col items-center relative w-16 bg-gray-200 dark:bg-gray-900 transition-all`}>
|
||||
<div
|
||||
className={`hidden md:flex h-full flex-col items-center relative w-16 bg-gray-200 dark:bg-gray-900 transition-all`}
|
||||
>
|
||||
{loginUid && <User uid={loginUid} />}
|
||||
<nav className="flex flex-col gap-1 px-3 py-6">
|
||||
<NavLink
|
||||
className={({ isActive }) => `${linkClass} ${(isActive || isChattingPage) ? "bg-primary-400 md:hover:bg-primary-400" : ""}`}
|
||||
className={({ isActive }) =>
|
||||
`${linkClass} ${
|
||||
isActive || isChattingPage ? "bg-primary-400 md:hover:bg-primary-400" : ""
|
||||
}`
|
||||
}
|
||||
to={chatNav}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return <Tooltip tip={t("chat")}>
|
||||
<ChatIcon className={(isActive || isChattingPage) ? "fill-white" : ""} />
|
||||
</Tooltip>;
|
||||
return (
|
||||
<Tooltip tip={t("chat")}>
|
||||
<ChatIcon className={isActive || isChattingPage ? "fill-white" : ""} />
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => `${linkClass} ${isActive ? 'bg-primary-400 md:hover:bg-primary-400' : ""}`} to={userNav}>
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`${linkClass} ${isActive ? "bg-primary-400 md:hover:bg-primary-400" : ""}`
|
||||
}
|
||||
to={userNav}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return <Tooltip tip={t("members")}>
|
||||
<UserIcon className={isActive ? "fill-white" : ""} />
|
||||
</Tooltip>;
|
||||
return (
|
||||
<Tooltip tip={t("members")}>
|
||||
<UserIcon className={isActive ? "fill-white" : ""} />
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => `${linkClass} ${isActive ? 'bg-primary-400 md:hover:bg-primary-400' : ""}`} to={"/favs"}>
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`${linkClass} ${isActive ? "bg-primary-400 md:hover:bg-primary-400" : ""}`
|
||||
}
|
||||
to={"/favs"}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return <Tooltip tip={t("favs")}>
|
||||
<FavIcon className={isActive ? "fill-white" : ""} />
|
||||
</Tooltip>;
|
||||
return (
|
||||
<Tooltip tip={t("favs")}>
|
||||
<FavIcon className={isActive ? "fill-white" : ""} />
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
<NavLink className={({ isActive }) => `${linkClass} ${isActive ? 'bg-primary-400 md:hover:bg-primary-400' : ""}`} to={"/files"}>
|
||||
<NavLink
|
||||
className={({ isActive }) =>
|
||||
`${linkClass} ${isActive ? "bg-primary-400 md:hover:bg-primary-400" : ""}`
|
||||
}
|
||||
to={"/files"}
|
||||
>
|
||||
{({ isActive }) => {
|
||||
return <Tooltip tip={t("files")}>
|
||||
<FolderIcon className={isActive ? "fill-white" : ""} />
|
||||
</Tooltip>;
|
||||
return (
|
||||
<Tooltip tip={t("files")}>
|
||||
<FolderIcon className={isActive ? "fill-white" : ""} />
|
||||
</Tooltip>
|
||||
);
|
||||
}}
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
+104
-24
@@ -1,10 +1,20 @@
|
||||
import { useEffect, lazy } from "react";
|
||||
import { Route, Routes, HashRouter } from "react-router-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { lazy, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Provider } from "react-redux";
|
||||
import { HashRouter, Route, Routes } from "react-router-dom";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
import Meta from "@/components/Meta";
|
||||
import useDeviceToken from "@/components/Notification/useDeviceToken";
|
||||
import RequireAuth from "@/components/RequireAuth";
|
||||
import RequireNoAuth from "@/components/RequireNoAuth";
|
||||
import RequireSingleTab from "@/components/RequireSingleTab";
|
||||
import { vapidKey } from "../app/config";
|
||||
import store, { useAppSelector } from "../app/store";
|
||||
import NotFoundPage from "./404";
|
||||
import InvitePrivate from "./invitePrivate";
|
||||
import LazyIt from "./lazy";
|
||||
|
||||
// import Welcome from './Welcome'
|
||||
// const HomePage = lazy(() => import("./home"));
|
||||
// const ChatPage = lazy(() => import("./chat"));
|
||||
@@ -25,15 +35,7 @@ const ResourceManagement = lazy(() => import("./resources"));
|
||||
const GuestLogin = lazy(() => import("./guest"));
|
||||
const ChatPage = lazy(() => import("./chat"));
|
||||
const HomePage = lazy(() => import("./home"));
|
||||
import RequireAuth from "@/components/RequireAuth";
|
||||
import RequireNoAuth from "@/components/RequireNoAuth";
|
||||
import Meta from "@/components/Meta";
|
||||
import LazyIt from './lazy';
|
||||
import store, { useAppSelector } from "../app/store";
|
||||
import useDeviceToken from "@/components/Notification/useDeviceToken";
|
||||
import { vapidKey } from "../app/config";
|
||||
import RequireSingleTab from "@/components/RequireSingleTab";
|
||||
import InvitePrivate from "./invitePrivate";
|
||||
|
||||
let toastId: string;
|
||||
const PageRoutes = () => {
|
||||
const {
|
||||
@@ -57,10 +59,40 @@ const PageRoutes = () => {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/guest_login" element={<LazyIt><GuestLogin /></LazyIt>} />
|
||||
<Route path="/invite_private/:channel_id" element={<LazyIt><RequireAuth><InvitePrivate /></RequireAuth></LazyIt>} />
|
||||
<Route path="/cb/:type/:payload" element={<LazyIt><CallbackPage /></LazyIt>} />
|
||||
<Route path="/oauth/:token" element={<LazyIt><OAuthPage /></LazyIt>} />
|
||||
<Route
|
||||
path="/guest_login"
|
||||
element={
|
||||
<LazyIt>
|
||||
<GuestLogin />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/invite_private/:channel_id"
|
||||
element={
|
||||
<LazyIt>
|
||||
<RequireAuth>
|
||||
<InvitePrivate />
|
||||
</RequireAuth>
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/cb/:type/:payload"
|
||||
element={
|
||||
<LazyIt>
|
||||
<CallbackPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/oauth/:token"
|
||||
element={
|
||||
<LazyIt>
|
||||
<OAuthPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
@@ -91,8 +123,22 @@ const PageRoutes = () => {
|
||||
</LazyIt>
|
||||
}
|
||||
>
|
||||
<Route index element={<LazyIt><RegPage /></LazyIt>} />
|
||||
<Route path="set_name/:from?" element={<LazyIt><RegWithUsernamePage /></LazyIt>} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<LazyIt>
|
||||
<RegPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="set_name/:from?"
|
||||
element={
|
||||
<LazyIt>
|
||||
<RegWithUsernamePage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
path="/email_login"
|
||||
@@ -104,7 +150,14 @@ const PageRoutes = () => {
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route path="/onboarding" element={<LazyIt><OnboardingPage /></LazyIt>} />
|
||||
<Route
|
||||
path="/onboarding"
|
||||
element={
|
||||
<LazyIt>
|
||||
<OnboardingPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
key={"main"}
|
||||
@@ -129,9 +182,30 @@ const PageRoutes = () => {
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route path=":nav?" element={<LazyIt><SettingPage /></LazyIt>} />
|
||||
<Route path="channel/:cid/:nav?" element={<LazyIt><SettingChannelPage /></LazyIt>} />
|
||||
<Route path="dm/:uid/:nav?" element={<LazyIt><SettingDMPage /></LazyIt>} />
|
||||
<Route
|
||||
path=":nav?"
|
||||
element={
|
||||
<LazyIt>
|
||||
<SettingPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="channel/:cid/:nav?"
|
||||
element={
|
||||
<LazyIt>
|
||||
<SettingChannelPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dm/:uid/:nav?"
|
||||
element={
|
||||
<LazyIt>
|
||||
<SettingDMPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
index
|
||||
@@ -142,7 +216,14 @@ const PageRoutes = () => {
|
||||
}
|
||||
/>
|
||||
<Route path="chat">
|
||||
<Route index element={<LazyIt><ChatPage /></LazyIt>} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<LazyIt>
|
||||
<ChatPage />
|
||||
</LazyIt>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="channel/:channel_id"
|
||||
element={
|
||||
@@ -202,7 +283,6 @@ const PageRoutes = () => {
|
||||
};
|
||||
|
||||
export default function ReduxRoutes() {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Meta />
|
||||
|
||||
@@ -1,61 +1,72 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useJoinPrivateChannelMutation, useLazyGetChannelQuery } from '../../app/services/channel';
|
||||
import StyledButton from '../../components/styled/Button';
|
||||
import { useCheckMagicTokenValidMutation } from '../../app/services/auth';
|
||||
import { useAppSelector } from '../../app/store';
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useCheckMagicTokenValidMutation } from "../../app/services/auth";
|
||||
import { useJoinPrivateChannelMutation, useLazyGetChannelQuery } from "../../app/services/channel";
|
||||
import { useAppSelector } from "../../app/store";
|
||||
import StyledButton from "../../components/styled/Button";
|
||||
|
||||
const InvitePrivate = () => {
|
||||
const { channel_id } = useParams();
|
||||
const server = useAppSelector(store => store.server);
|
||||
const navigateTo = useNavigate();
|
||||
const [joinChannel, { isLoading, data, isSuccess }] = useJoinPrivateChannelMutation();
|
||||
const [fetchChannelInfo, { data: channel, isSuccess: fetchChannelSuccess }] = useLazyGetChannelQuery();
|
||||
const [checkTokenInvalid, { data: isTokenValid, isLoading: checkingToken }] =
|
||||
useCheckMagicTokenValidMutation();
|
||||
let [searchParams] = useSearchParams(new URLSearchParams(location.search));
|
||||
const magic_token = searchParams.get("magic_token") ?? "";
|
||||
useEffect(() => {
|
||||
if (channel_id) {
|
||||
fetchChannelInfo(+channel_id);
|
||||
}
|
||||
}, [channel_id]);
|
||||
useEffect(() => {
|
||||
if (magic_token) {
|
||||
checkTokenInvalid(magic_token);
|
||||
}
|
||||
}, [magic_token]);
|
||||
useEffect(() => {
|
||||
if (data && isSuccess) {
|
||||
// joinChannel(data)
|
||||
navigateTo(`/chat/channel/${data.gid}`);
|
||||
}
|
||||
}, [isSuccess, data]);
|
||||
const handleJoin = async () => {
|
||||
const resp = await joinChannel({ magic_token });
|
||||
if ("error" in resp) {
|
||||
if (resp.error.originalStatus === 409) {
|
||||
// alert("The invite link is invalid or expired");
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!fetchChannelSuccess) return null;
|
||||
return (
|
||||
<div className="flex-center flex-col gap-4 h-screen overflow-x-hidden overflow-y-auto dark:bg-gray-700 dark:text-slate-100">
|
||||
<div className="flex flex-col gap-4 items-center py-8 px-10 rounded-lg shadow-md bg-slate-100/30 dark:bg-gray-800 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src={server.logo} className='w-20 h-20' alt="server logo" />
|
||||
<h2 className="text-2xl font-bold">
|
||||
{server.name}
|
||||
</h2>
|
||||
</div>
|
||||
<span>
|
||||
{checkingToken ? "Checking..." : isTokenValid ? <>You are invited to join private channel <strong className='text-primary-400'>#{channel?.name}</strong></> : "The invite link is invalid or expired"}
|
||||
</span>
|
||||
<StyledButton disabled={isLoading || checkingToken || !isTokenValid} onClick={handleJoin}>Join</StyledButton>
|
||||
</div>
|
||||
const { channel_id } = useParams();
|
||||
const server = useAppSelector((store) => store.server);
|
||||
const navigateTo = useNavigate();
|
||||
const [joinChannel, { isLoading, data, isSuccess }] = useJoinPrivateChannelMutation();
|
||||
const [fetchChannelInfo, { data: channel, isSuccess: fetchChannelSuccess }] =
|
||||
useLazyGetChannelQuery();
|
||||
const [checkTokenInvalid, { data: isTokenValid, isLoading: checkingToken }] =
|
||||
useCheckMagicTokenValidMutation();
|
||||
let [searchParams] = useSearchParams(new URLSearchParams(location.search));
|
||||
const magic_token = searchParams.get("magic_token") ?? "";
|
||||
useEffect(() => {
|
||||
if (channel_id) {
|
||||
fetchChannelInfo(+channel_id);
|
||||
}
|
||||
}, [channel_id]);
|
||||
useEffect(() => {
|
||||
if (magic_token) {
|
||||
checkTokenInvalid(magic_token);
|
||||
}
|
||||
}, [magic_token]);
|
||||
useEffect(() => {
|
||||
if (data && isSuccess) {
|
||||
// joinChannel(data)
|
||||
navigateTo(`/chat/channel/${data.gid}`);
|
||||
}
|
||||
}, [isSuccess, data]);
|
||||
const handleJoin = async () => {
|
||||
const resp = await joinChannel({ magic_token });
|
||||
if ("error" in resp) {
|
||||
if (resp.error.originalStatus === 409) {
|
||||
// alert("The invite link is invalid or expired");
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!fetchChannelSuccess) return null;
|
||||
return (
|
||||
<div className="flex-center flex-col gap-4 h-screen overflow-x-hidden overflow-y-auto dark:bg-gray-700 dark:text-slate-100">
|
||||
<div className="flex flex-col gap-4 items-center py-8 px-10 rounded-lg shadow-md bg-slate-100/30 dark:bg-gray-800 text-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<img src={server.logo} className="w-20 h-20" alt="server logo" />
|
||||
<h2 className="text-2xl font-bold">{server.name}</h2>
|
||||
</div>
|
||||
);
|
||||
<span>
|
||||
{checkingToken ? (
|
||||
"Checking..."
|
||||
) : isTokenValid ? (
|
||||
<>
|
||||
You are invited to join private channel{" "}
|
||||
<strong className="text-primary-400">#{channel?.name}</strong>
|
||||
</>
|
||||
) : (
|
||||
"The invite link is invalid or expired"
|
||||
)}
|
||||
</span>
|
||||
<StyledButton disabled={isLoading || checkingToken || !isTokenValid} onClick={handleJoin}>
|
||||
Join
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitePrivate;
|
||||
export default InvitePrivate;
|
||||
|
||||
+4
-7
@@ -1,16 +1,13 @@
|
||||
import { ReactNode, Suspense, FC } from "react";
|
||||
import { FC, ReactNode, Suspense } from "react";
|
||||
|
||||
import Loading from "@/components/Loading";
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
const Lazy: FC<Props> = ({ children }) => {
|
||||
return (
|
||||
<Suspense fallback={<Loading fullscreen={true} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
return <Suspense fallback={<Loading fullscreen={true} />}>{children}</Suspense>;
|
||||
};
|
||||
|
||||
export default Lazy;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import Button from "@/components/styled/Button";
|
||||
|
||||
export default function MagicLinkLogin() {
|
||||
@@ -7,5 +8,9 @@ export default function MagicLinkLogin() {
|
||||
navigate("/send_magic_link");
|
||||
};
|
||||
|
||||
return <Button className="w-full" onClick={handleLogin}>Sign in with Magic Link</Button>;
|
||||
return (
|
||||
<Button className="w-full" onClick={handleLogin}>
|
||||
Sign in with Magic Link
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import MetaMaskOnboarding from "@metamask/onboarding";
|
||||
import { useLazyGetMetamaskNonceQuery } from "@/app/services/auth";
|
||||
import metamaskSvg from "@/assets/icons/metamask.svg?url";
|
||||
import { LoginCredential } from "@/types/auth";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MetaMaskOnboarding from "@metamask/onboarding";
|
||||
|
||||
import { useLazyGetMetamaskNonceQuery } from "@/app/services/auth";
|
||||
import { LoginCredential } from "@/types/auth";
|
||||
import { AuthType } from "@/types/common";
|
||||
import Button from "@/components/styled/Button";
|
||||
import metamaskSvg from "@/assets/icons/metamask.svg?url";
|
||||
|
||||
// import toast from "react-hot-toast";
|
||||
|
||||
export default function MetamaskLoginButton({
|
||||
@@ -14,7 +16,7 @@ export default function MetamaskLoginButton({
|
||||
type = "login"
|
||||
}: {
|
||||
login: (params: LoginCredential) => void;
|
||||
type?: AuthType
|
||||
type?: AuthType;
|
||||
}) {
|
||||
const { t } = useTranslation("auth");
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
@@ -93,7 +95,11 @@ export default function MetamaskLoginButton({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button className="flex ghost flex-center gap-2" disabled={requesting} onClick={handleMetamaskLogin}>
|
||||
<Button
|
||||
className="flex ghost flex-center gap-2"
|
||||
disabled={requesting}
|
||||
onClick={handleMetamaskLogin}
|
||||
>
|
||||
<img className="w-6 h-6" src={metamaskSvg} alt="meta mask icon" />
|
||||
{type == "login" ? t("login.metamask") : t("reg.metamask")}
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { FC, useState } from "react";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { OIDCConfig } from "@/types/auth";
|
||||
import { AuthType } from "@/types/common";
|
||||
import Modal from "@/components/Modal";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import OidcLoginEntry from "./OidcLoginEntry";
|
||||
import { OIDCConfig } from "@/types/auth";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthType } from "@/types/common";
|
||||
import Button from "@/components/styled/Button";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import OidcLoginEntry from "./OidcLoginEntry";
|
||||
|
||||
interface IProps {
|
||||
issuers?: OIDCConfig[];
|
||||
type?: AuthType
|
||||
type?: AuthType;
|
||||
}
|
||||
const OidcLoginButton: FC<IProps> = ({ issuers, type = "login" }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, FC } from "react";
|
||||
import { FC, useEffect } from "react";
|
||||
|
||||
import { useGetOpenidMutation } from "@/app/services/auth";
|
||||
import Button from "@/components/styled/Button";
|
||||
import { OIDCConfig } from "@/types/auth";
|
||||
import Button from "@/components/styled/Button";
|
||||
|
||||
const OidcLoginEntry: FC<{ issuer: OIDCConfig }> = ({ issuer }) => {
|
||||
const [getOpenId, { data, isLoading, isSuccess }] = useGetOpenidMutation();
|
||||
@@ -21,7 +22,11 @@ const OidcLoginEntry: FC<{ issuer: OIDCConfig }> = ({ issuer }) => {
|
||||
}, [data, isSuccess]);
|
||||
|
||||
return (
|
||||
<Button className="flex flex-center gap-3" disabled={isLoading || isSuccess} onClick={handleSolidLogin}>
|
||||
<Button
|
||||
className="flex flex-center gap-3"
|
||||
disabled={isLoading || isSuccess}
|
||||
onClick={handleSolidLogin}
|
||||
>
|
||||
{!!issuer.favicon && <img src={issuer.favicon} className="w-6 h-6" alt="icon" />}
|
||||
{isLoading ? `Loading...` : `Login with ${issuer.domain}`}
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,9 @@ export default function SignUpLink() {
|
||||
return (
|
||||
<div className="flex gap-1 mt-7 text-sm text-slate-500 dark:text-gray-100 justify-center">
|
||||
<span>{t("login.no_account")}</span>
|
||||
<a className="text-primary-400 cursor-pointer" onClick={handleSignUp}>{t("sign_up")}</a>
|
||||
<a className="text-primary-400 cursor-pointer" onClick={handleSignUp}>
|
||||
{t("sign_up")}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
import { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||
import GithubLoginButton from "@/components/GithubLoginButton";
|
||||
import GoogleLoginButton from "@/components/GoogleLoginButton";
|
||||
import useGithubAuthConfig from "@/hooks/useGithubAuthConfig";
|
||||
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
|
||||
import GoogleLoginButton from "@/components/GoogleLoginButton";
|
||||
import GithubLoginButton from "@/components/GithubLoginButton";
|
||||
import { useLoginMutation } from "../../app/services/auth";
|
||||
import { AuthType } from "../../types/common";
|
||||
import { LoginConfig } from "../../types/server";
|
||||
import MetamaskLoginButton from "./MetamaskLoginButton";
|
||||
import OidcLoginButton from "./OidcLoginButton";
|
||||
import { useLoginMutation } from '../../app/services/auth';
|
||||
import { LoginConfig } from '../../types/server';
|
||||
import { AuthType } from '../../types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type Props = {
|
||||
type?: AuthType
|
||||
}
|
||||
|
||||
const SocialLoginButtons = ({ type = "login" }: Props) => {
|
||||
const { t: ct } = useTranslation();
|
||||
const [login, { isSuccess }] = useLoginMutation();
|
||||
const { config: githubAuthConfig } = useGithubAuthConfig();
|
||||
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
|
||||
const { clientId } = useGoogleAuthConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.login"));
|
||||
// navigateTo("/");
|
||||
}
|
||||
}, [isSuccess]);
|
||||
if (!loginConfigSuccess) return null;
|
||||
const {
|
||||
github: enableGithubLogin,
|
||||
google: enableGoogleLogin,
|
||||
metamask: enableMetamaskLogin,
|
||||
oidc = [],
|
||||
} = loginConfig as LoginConfig;
|
||||
const googleLogin = enableGoogleLogin && !!clientId;
|
||||
return (
|
||||
<>
|
||||
{googleLogin && <GoogleLoginButton type={type} clientId={clientId} />}
|
||||
{enableGithubLogin && (
|
||||
<GithubLoginButton type={type} client_id={githubAuthConfig?.client_id} />
|
||||
)}
|
||||
{enableMetamaskLogin && <MetamaskLoginButton type={type} login={login} />}
|
||||
{oidc.length > 0 && <OidcLoginButton type={type} issuers={oidc} />}
|
||||
</>
|
||||
);
|
||||
type?: AuthType;
|
||||
};
|
||||
|
||||
export default SocialLoginButtons;
|
||||
const SocialLoginButtons = ({ type = "login" }: Props) => {
|
||||
const { t: ct } = useTranslation();
|
||||
const [login, { isSuccess }] = useLoginMutation();
|
||||
const { config: githubAuthConfig } = useGithubAuthConfig();
|
||||
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
|
||||
const { clientId } = useGoogleAuthConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.login"));
|
||||
// navigateTo("/");
|
||||
}
|
||||
}, [isSuccess]);
|
||||
if (!loginConfigSuccess) return null;
|
||||
const {
|
||||
github: enableGithubLogin,
|
||||
google: enableGoogleLogin,
|
||||
metamask: enableMetamaskLogin,
|
||||
oidc = []
|
||||
} = loginConfig as LoginConfig;
|
||||
const googleLogin = enableGoogleLogin && !!clientId;
|
||||
return (
|
||||
<>
|
||||
{googleLogin && <GoogleLoginButton type={type} clientId={clientId} />}
|
||||
{enableGithubLogin && (
|
||||
<GithubLoginButton type={type} client_id={githubAuthConfig?.client_id} />
|
||||
)}
|
||||
{enableMetamaskLogin && <MetamaskLoginButton type={type} login={login} />}
|
||||
{oidc.length > 0 && <OidcLoginButton type={type} issuers={oidc} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SocialLoginButtons;
|
||||
|
||||
+20
-15
@@ -1,23 +1,23 @@
|
||||
/* eslint-disable no-undef */
|
||||
import { useState, useEffect, FormEvent, ChangeEvent } from "react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import BASE_URL from "@/app/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
||||
|
||||
import Input from "@/components/styled/Input";
|
||||
import Button from "@/components/styled/Button";
|
||||
import MagicLinkLogin from "./MagicLinkLogin";
|
||||
import SignUpLink from "./SignUpLink";
|
||||
import BASE_URL from "@/app/config";
|
||||
import { useLoginMutation } from "@/app/services/auth";
|
||||
import { useGetLoginConfigQuery, useGetSMTPStatusQuery } from "@/app/services/server";
|
||||
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
|
||||
import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SocialLoginButtons from "./SocialLoginButtons";
|
||||
import Divider from "@/components/Divider";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Divider from "@/components/Divider";
|
||||
import Button from "@/components/styled/Button";
|
||||
import Input from "@/components/styled/Input";
|
||||
import useGoogleAuthConfig from "@/hooks/useGoogleAuthConfig";
|
||||
import MagicLinkLogin from "./MagicLinkLogin";
|
||||
import SignUpLink from "./SignUpLink";
|
||||
import SocialLoginButtons from "./SocialLoginButtons";
|
||||
|
||||
export default function LoginPage() {
|
||||
const serverName = useAppSelector(store => store.server.name);
|
||||
const serverName = useAppSelector((store) => store.server.name);
|
||||
const { t } = useTranslation("auth");
|
||||
const { t: ct } = useTranslation();
|
||||
const { data: enableSMTP, isLoading: loadingSMTPStatus } = useGetSMTPStatusQuery();
|
||||
@@ -129,8 +129,14 @@ export default function LoginPage() {
|
||||
<div className="flex-center h-screen dark:bg-gray-700">
|
||||
<div className="py-8 px-10 shadow-md rounded-xl">
|
||||
<div className="flex-center flex-col pb-6">
|
||||
<img src={`${BASE_URL}/resource/organization/logo`} alt="logo" className="w-14 h-14 mb-3 md:mb-7 rounded-full" />
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">{t("login.title", { name: serverName })}</h2>
|
||||
<img
|
||||
src={`${BASE_URL}/resource/organization/logo`}
|
||||
alt="logo"
|
||||
className="w-14 h-14 mb-3 md:mb-7 rounded-full"
|
||||
/>
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">
|
||||
{t("login.title", { name: serverName })}
|
||||
</h2>
|
||||
<span className="text-gray-400 dark:text-gray-100">{t("login.desc")}</span>
|
||||
</div>
|
||||
<form className="flex flex-col gap-5 w-80 md:min-w-[360px] " onSubmit={handleLogin}>
|
||||
@@ -160,7 +166,6 @@ export default function LoginPage() {
|
||||
</form>
|
||||
{hasDivider && <Divider content="OR" />}
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{enableMagicLink && <MagicLinkLogin />}
|
||||
|
||||
<SocialLoginButtons />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { useLoginMutation } from "@/app/services/auth";
|
||||
import toast from "react-hot-toast";
|
||||
import { setAuthData } from "@/app/slices/auth.data";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function OAuthPage() {
|
||||
const { t: ct } = useTranslation();
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import React, { useState } from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useWizard, Wizard } from 'react-use-wizard';
|
||||
|
||||
import WelcomePage from "./steps/welcome-page";
|
||||
import ServerName from "./steps/server-name";
|
||||
import AdminAccount from "./steps/admin-account";
|
||||
import WhoCanSignUp from "./steps/who-can-sign-up";
|
||||
import InviteLink from "./steps/invite-link";
|
||||
import DonePage from "./steps/done-page";
|
||||
import steps from "./steps";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWizard, Wizard } from "react-use-wizard";
|
||||
import clsx from "clsx";
|
||||
|
||||
import steps from "./steps";
|
||||
import AdminAccount from "./steps/admin-account";
|
||||
import DonePage from "./steps/done-page";
|
||||
import InviteLink from "./steps/invite-link";
|
||||
import ServerName from "./steps/server-name";
|
||||
import WelcomePage from "./steps/welcome-page";
|
||||
import WhoCanSignUp from "./steps/who-can-sign-up";
|
||||
|
||||
const Navigator = () => {
|
||||
const { activeStep, goToStep } = useWizard();
|
||||
@@ -22,7 +21,12 @@ const Navigator = () => {
|
||||
<div className="hidden md:flex absolute top-5 w-full justify-center gap-2 z-10">
|
||||
{steps.map((stepToRender, indexToRender) => {
|
||||
const clickable = canJumpTo.includes(stepToRender.name);
|
||||
const itemClass = clsx(`text-sm text-gray-600`, clickable && "cursor-pointer md:hover:text-gray-500", indexToRender === activeStep && "font-bold text-black", indexToRender >= activeStep && "text-gray-400");
|
||||
const itemClass = clsx(
|
||||
`text-sm text-gray-600`,
|
||||
clickable && "cursor-pointer md:hover:text-gray-500",
|
||||
indexToRender === activeStep && "font-bold text-black",
|
||||
indexToRender >= activeStep && "text-gray-400"
|
||||
);
|
||||
const nodeCls = `${itemClass}`;
|
||||
return (
|
||||
<React.Fragment key={indexToRender}>
|
||||
|
||||
@@ -1,53 +1,65 @@
|
||||
import { useState, useEffect, ChangeEvent } from 'react';
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from "@/app/services/server";
|
||||
import InfoIcon from '@/assets/icons/info.svg';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from "@/app/services/server";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import InfoIcon from "@/assets/icons/info.svg";
|
||||
|
||||
type Props = {
|
||||
refreshInviteLink: () => void
|
||||
}
|
||||
|
||||
const UpdateFrontendURL = ({ refreshInviteLink }: Props) => {
|
||||
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||
const { t: ct } = useTranslation();
|
||||
const [updateUrl, { isSuccess, isLoading }] = useUpdateFrontendUrlMutation();
|
||||
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
|
||||
const [frontUrl, setFrontUrl] = useState(location.origin);
|
||||
const handleUpdateUrl = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
setFrontUrl(evt.target.value);
|
||||
};
|
||||
const updateFrontUrl = () => {
|
||||
updateUrl(frontUrl);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (getUrlSuccess && data) {
|
||||
setFrontUrl(data);
|
||||
}
|
||||
}, [getUrlSuccess]);
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refreshInviteLink();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
return (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-8 border-2 border-solid border-primary-300 dark:border-primary-700 bg-primary-25 dark:bg-primary-900 rounded-lg px-2 py-3 flex justify-start gap-4">
|
||||
<InfoIcon />
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span className="text-sm text-primary-700 dark:text-primary-300 mb-1">{t("update_domain_tip")}</span>
|
||||
<div className="w-[300px] md:w-[400px] rounded flex gap-2">
|
||||
<StyledInput type={"url"} className="!shadow-none md:!bg-transparent" placeholder="Frontend URL" value={frontUrl} onChange={handleUpdateUrl} />
|
||||
<StyledButton disabled={!frontUrl || isLoading} onClick={updateFrontUrl} className="small ">
|
||||
{ct("action.update")}
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
refreshInviteLink: () => void;
|
||||
};
|
||||
|
||||
export default UpdateFrontendURL;
|
||||
const UpdateFrontendURL = ({ refreshInviteLink }: Props) => {
|
||||
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||
const { t: ct } = useTranslation();
|
||||
const [updateUrl, { isSuccess, isLoading }] = useUpdateFrontendUrlMutation();
|
||||
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
|
||||
const [frontUrl, setFrontUrl] = useState(location.origin);
|
||||
const handleUpdateUrl = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
setFrontUrl(evt.target.value);
|
||||
};
|
||||
const updateFrontUrl = () => {
|
||||
updateUrl(frontUrl);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (getUrlSuccess && data) {
|
||||
setFrontUrl(data);
|
||||
}
|
||||
}, [getUrlSuccess]);
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refreshInviteLink();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
return (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-8 border-2 border-solid border-primary-300 dark:border-primary-700 bg-primary-25 dark:bg-primary-900 rounded-lg px-2 py-3 flex justify-start gap-4">
|
||||
<InfoIcon />
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<span className="text-sm text-primary-700 dark:text-primary-300 mb-1">
|
||||
{t("update_domain_tip")}
|
||||
</span>
|
||||
<div className="w-[300px] md:w-[400px] rounded flex gap-2">
|
||||
<StyledInput
|
||||
type={"url"}
|
||||
className="!shadow-none md:!bg-transparent"
|
||||
placeholder="Frontend URL"
|
||||
value={frontUrl}
|
||||
onChange={handleUpdateUrl}
|
||||
/>
|
||||
<StyledButton
|
||||
disabled={!frontUrl || isLoading}
|
||||
onClick={updateFrontUrl}
|
||||
className="small "
|
||||
>
|
||||
{ct("action.update")}
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateFrontendURL;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useEffect, useState, FC, useRef } from "react";
|
||||
import { FC, useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import { useWizard } from "react-use-wizard";
|
||||
|
||||
import { useLoginMutation } from "@/app/services/auth";
|
||||
import {
|
||||
useCreateAdminMutation,
|
||||
useGetServerQuery,
|
||||
useUpdateServerMutation
|
||||
} from "@/app/services/server";
|
||||
import { useLoginMutation } from "@/app/services/auth";
|
||||
import { updateInitialized } from "@/app/slices/auth.data";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWizard } from "react-use-wizard";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
|
||||
type Props = {
|
||||
serverName: string;
|
||||
@@ -23,8 +24,9 @@ const AdminAccount: FC<Props> = ({ serverName }) => {
|
||||
const formRef = useRef<HTMLFormElement | undefined>();
|
||||
const loggedIn = useAppSelector((store) => !!store.authData.token);
|
||||
const dispatch = useDispatch();
|
||||
const [createAdmin, { isLoading: isSigningUp, isError: signUpError, isSuccess: signUpOk }] = useCreateAdminMutation();
|
||||
const [login, { isLoading: isLoggingIn, isError: loginError, }] = useLoginMutation();
|
||||
const [createAdmin, { isLoading: isSigningUp, isError: signUpError, isSuccess: signUpOk }] =
|
||||
useCreateAdminMutation();
|
||||
const [login, { isLoading: isLoggingIn, isError: loginError }] = useLoginMutation();
|
||||
const { data: serverData } = useGetServerQuery();
|
||||
const [updateServer, { isLoading: isUpdatingServer, isSuccess: isUpdatedServer }] =
|
||||
useUpdateServerMutation();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import PlayIcon from "@/assets/icons/play.svg?url";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
export default function DonePage({ serverName }: { serverName: string }) {
|
||||
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||
@@ -17,7 +17,10 @@ export default function DonePage({ serverName }: { serverName: string }) {
|
||||
<span className="font-bold" />
|
||||
</Trans>
|
||||
</span>
|
||||
<StyledButton className="!w-32 h-auto flex flex-col items-center py-3" onClick={() => navigate("/")}>
|
||||
<StyledButton
|
||||
className="!w-32 h-auto flex flex-col items-center py-3"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
<img className="mb-2" src={PlayIcon} alt="play icon" />
|
||||
<span className="text-sm">{t("enter")}</span>
|
||||
</StyledButton>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWizard } from "react-use-wizard";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import useInviteLink from "@/hooks/useInviteLink";
|
||||
|
||||
import ServerVersionChecker from "@/components/ServerVersionChecker";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import useInviteLink from "@/hooks/useInviteLink";
|
||||
import UpdateFrontendURL from "./UpdateFrontendURL";
|
||||
|
||||
export default function InviteLink() {
|
||||
@@ -19,8 +19,16 @@ export default function InviteLink() {
|
||||
<span className="text-sm mb-10 text-gray-400 dark:text-gray-600">{t("last_tip")}</span>
|
||||
<span className="text-sm text-gray-500 mb-2 font-semibold">{t("last_desc")}</span>
|
||||
<div className="w-full md:w-[400px] rounded shadow-md flex border border-solid border-gray-100">
|
||||
<StyledInput className="large !border-none !shadow-none" readOnly placeholder="Generating" value={link} />
|
||||
<StyledButton onClick={copyLink} className="ghost small border_less !px-2 md:hover:!text-primary-600">
|
||||
<StyledInput
|
||||
className="large !border-none !shadow-none"
|
||||
readOnly
|
||||
placeholder="Generating"
|
||||
value={link}
|
||||
/>
|
||||
<StyledButton
|
||||
onClick={copyLink}
|
||||
className="ghost small border_less !px-2 md:hover:!text-primary-600"
|
||||
>
|
||||
{linkCopied ? "Copied" : ct("action.copy")}
|
||||
</StyledButton>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FC } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWizard } from "react-use-wizard";
|
||||
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
|
||||
type Props = {
|
||||
serverName: string;
|
||||
setServerName: (name: string) => void;
|
||||
@@ -16,9 +17,7 @@ const ServerName: FC<Props> = ({ serverName, setServerName }) => {
|
||||
return (
|
||||
<div className="h-full flex-center flex-col text-center w-[360px] m-auto dark:text-gray-200">
|
||||
<span className="text-2xl mb-2 font-bold">{t("new_server")}</span>
|
||||
<span className="text-sm mb-6 text-gray-400 dark:text-gray-600">
|
||||
{t("server_desc")}
|
||||
</span>
|
||||
<span className="text-sm mb-6 text-gray-400 dark:text-gray-600">{t("server_desc")}</span>
|
||||
<StyledInput
|
||||
className="h-11 px-3.5 py-2.5 border-gray-300 rounded-lg shadow"
|
||||
placeholder={t("placeholder_server")}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import PlayIcon from "@/assets/icons/play.svg?url";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWizard } from "react-use-wizard";
|
||||
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import PlayIcon from "@/assets/icons/play.svg?url";
|
||||
|
||||
export default function WelcomePage() {
|
||||
const { t } = useTranslation("welcome", { keyPrefix: "onboarding" });
|
||||
@@ -10,10 +10,11 @@ export default function WelcomePage() {
|
||||
return (
|
||||
<div className="flex-center flex-col h-full text-center dark:text-gray-100">
|
||||
<span className="text-2xl mb-2 font-bold">{t("welcome")}</span>
|
||||
<span className="text-sm mb-6">
|
||||
{t("welcome_desc")}
|
||||
</span>
|
||||
<StyledButton className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm" onClick={nextStep}>
|
||||
<span className="text-sm mb-6">{t("welcome_desc")}</span>
|
||||
<StyledButton
|
||||
className="!w-32 h-auto flex flex-col gap-2 items-center py-3 text-sm"
|
||||
onClick={nextStep}
|
||||
>
|
||||
<img src={PlayIcon} alt="play icon" />
|
||||
<span>{t("start")}</span>
|
||||
</StyledButton>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import { useGetLoginConfigQuery, useUpdateLoginConfigMutation } from "@/app/services/server";
|
||||
import { WhoCanSignUp } from "@/types/server";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWizard } from "react-use-wizard";
|
||||
|
||||
import { useGetLoginConfigQuery, useUpdateLoginConfigMutation } from "@/app/services/server";
|
||||
import { WhoCanSignUp } from "@/types/server";
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
|
||||
export default function SignUpSetting() {
|
||||
const { t } = useTranslation("welcome");
|
||||
const { t: st } = useTranslation("setting");
|
||||
@@ -39,17 +40,18 @@ export default function SignUpSetting() {
|
||||
if (isSuccess) nextStep();
|
||||
}, [isSuccess]);
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-full px-2 flex-center flex-col text-center w-full md:w-[512px] m-auto dark:text-gray-100">
|
||||
<span className="font-bold text-2xl mb-2">{t("onboarding.invite_title")}</span>
|
||||
<span className="text-sm mb-6">{t("onboarding.invite_desc")}</span>
|
||||
{value && <StyledRadio
|
||||
options={[st("overview.sign_up.everyone"), st("overview.sign_up.invite")]}
|
||||
values={["EveryOne", "InvitationOnly"]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>}
|
||||
{value && (
|
||||
<StyledRadio
|
||||
options={[st("overview.sign_up.everyone"), st("overview.sign_up.invite")]}
|
||||
values={["EveryOne", "InvitationOnly"]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
)}
|
||||
<StyledButton
|
||||
className="w-32 mt-6"
|
||||
disabled={!value}
|
||||
@@ -57,7 +59,6 @@ export default function SignUpSetting() {
|
||||
// nextStep();
|
||||
if (loginConfig !== undefined) {
|
||||
if (loginConfig.who_can_sign_up !== value) {
|
||||
|
||||
updateLoginConfig({
|
||||
...loginConfig,
|
||||
who_can_sign_up: value
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
|
||||
export default function EmailNextTip() {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="font-bold text-3xl text-gray-800 dark:text-white mt-3">Magic link Sent</div>
|
||||
<p className="text-center text-gray-400 dark:text-gray-300 mb-6">Login to your email client, and continue next step</p>
|
||||
<p className="text-center text-gray-400 dark:text-gray-300 mb-6">
|
||||
Login to your email client, and continue next step
|
||||
</p>
|
||||
<p className="text-center text-gray-400 dark:text-gray-300">You can close this window now.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState, useEffect, FC, FormEvent, ChangeEvent } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ChangeEvent, FC, FormEvent, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
useCheckMagicTokenValidMutation,
|
||||
useLoginMutation,
|
||||
useRegisterMutation
|
||||
} from "@/app/services/auth";
|
||||
import { setAuthData } from "@/app/slices/auth.data";
|
||||
import Input from "@/components/styled/Input";
|
||||
import Button from "@/components/styled/Button";
|
||||
import { useLoginMutation, useCheckMagicTokenValidMutation } from "@/app/services/auth";
|
||||
import Input from "@/components/styled/Input";
|
||||
import ExpiredTip from "./ExpiredTip";
|
||||
import { useRegisterMutation } from "@/app/services/auth";
|
||||
import { useMagicToken } from "./index";
|
||||
|
||||
const RegWithUsername: FC = () => {
|
||||
@@ -100,7 +104,9 @@ const RegWithUsername: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-center flex-col pb-6 max-w-md">
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-100 mb-2">{t("reg.input_name")}</h2>
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-100 mb-2">
|
||||
{t("reg.input_name")}
|
||||
</h2>
|
||||
<span className="text-gray-400 text-center dark:text-gray-100">
|
||||
{t("reg.input_name_tip")}
|
||||
</span>
|
||||
|
||||
+52
-34
@@ -1,38 +1,43 @@
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import BASE_URL, { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
|
||||
import Input from "@/components/styled/Input";
|
||||
import Button from "@/components/styled/Button";
|
||||
import { useLazyCheckEmailQuery, useRegisterMutation, useSendRegMagicLinkMutation } from "@/app/services/auth";
|
||||
import EmailNextTip from "./EmailNextStepTip";
|
||||
import SignInLink from "./SignInLink";
|
||||
import Divider from "@/components/Divider";
|
||||
|
||||
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||
import SocialLoginButtons from "../login/SocialLoginButtons";
|
||||
import { setAuthData } from "@/app/slices/auth.data";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import BASE_URL, { KEY_LOCAL_MAGIC_TOKEN } from "@/app/config";
|
||||
import {
|
||||
useLazyCheckEmailQuery,
|
||||
useRegisterMutation,
|
||||
useSendRegMagicLinkMutation
|
||||
} from "@/app/services/auth";
|
||||
import { useGetLoginConfigQuery } from "@/app/services/server";
|
||||
import { setAuthData } from "@/app/slices/auth.data";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Divider from "@/components/Divider";
|
||||
import Button from "@/components/styled/Button";
|
||||
import Input from "@/components/styled/Input";
|
||||
import SocialLoginButtons from "../login/SocialLoginButtons";
|
||||
import EmailNextTip from "./EmailNextStepTip";
|
||||
import { useMagicToken } from "./index";
|
||||
import SignInLink from "./SignInLink";
|
||||
|
||||
interface AuthForm {
|
||||
name?: string,
|
||||
name?: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
export default function Register() {
|
||||
const serverName = useAppSelector(store => store.server.name);
|
||||
const serverName = useAppSelector((store) => store.server.name);
|
||||
const { t } = useTranslation("auth");
|
||||
const { t: ct } = useTranslation();
|
||||
const [sendRegMagicLink, { isLoading: signingUp, data, isSuccess }] =
|
||||
useSendRegMagicLinkMutation();
|
||||
const dispatch = useDispatch();
|
||||
const { token: magic_token } = useMagicToken();
|
||||
const [register, { isLoading: registering, data: regData, isSuccess: regSuccess }] = useRegisterMutation();
|
||||
const [register, { isLoading: registering, data: regData, isSuccess: regSuccess }] =
|
||||
useRegisterMutation();
|
||||
const [checkEmail, { isLoading: checkingEmail }] = useLazyCheckEmailQuery();
|
||||
const { data: loginConfig, isSuccess: loginConfigSuccess } = useGetLoginConfigQuery();
|
||||
|
||||
@@ -113,13 +118,13 @@ export default function Register() {
|
||||
});
|
||||
};
|
||||
if (!loginConfigSuccess) return null;
|
||||
const {
|
||||
who_can_sign_up: whoCanSignUp
|
||||
} = loginConfig;
|
||||
const { who_can_sign_up: whoCanSignUp } = loginConfig;
|
||||
// magic token 没有并且没有开放注册
|
||||
if (whoCanSignUp !== "EveryOne" && !magic_token)
|
||||
// todo: i18n
|
||||
return <span className="dark:text-white">Sign up method is updated to Invitation Link Only</span>;
|
||||
return (
|
||||
<span className="dark:text-white">Sign up method is updated to Invitation Link Only</span>
|
||||
);
|
||||
const { name, email, password, confirmPassword } = input;
|
||||
if (data?.mail_is_sent) return <EmailNextTip />;
|
||||
const isLoading = registering || signingUp || checkingEmail;
|
||||
@@ -127,23 +132,36 @@ export default function Register() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex-center flex-col pb-6">
|
||||
<img src={`${BASE_URL}/resource/organization/logo`} alt="logo" className="w-14 h-14 md:mb-7 rounded-full" />
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">{t("reg.title", { name: serverName })}</h2>
|
||||
<img
|
||||
src={`${BASE_URL}/resource/organization/logo`}
|
||||
alt="logo"
|
||||
className="w-14 h-14 md:mb-7 rounded-full"
|
||||
/>
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-white md:mb-2">
|
||||
{t("reg.title", { name: serverName })}
|
||||
</h2>
|
||||
<span className="hidden md:block text-gray-400 dark:text-gray-100">{t("reg.desc")}</span>
|
||||
</div>
|
||||
|
||||
<form className="flex flex-col gap-5 w-80 md:min-w-[360px]" onSubmit={handleReg} autoSave={"false"} autoComplete={"true"}>
|
||||
<form
|
||||
className="flex flex-col gap-5 w-80 md:min-w-[360px]"
|
||||
onSubmit={handleReg}
|
||||
autoSave={"false"}
|
||||
autoComplete={"true"}
|
||||
>
|
||||
{/* 不存在 magic token */}
|
||||
{!magic_token && <Input
|
||||
className="large"
|
||||
name="name"
|
||||
value={name}
|
||||
required
|
||||
type="name"
|
||||
placeholder={t("placeholder_name")}
|
||||
data-type="name"
|
||||
onChange={handleInput}
|
||||
/>}
|
||||
{!magic_token && (
|
||||
<Input
|
||||
className="large"
|
||||
name="name"
|
||||
value={name}
|
||||
required
|
||||
type="name"
|
||||
placeholder={t("placeholder_name")}
|
||||
data-type="name"
|
||||
onChange={handleInput}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
className="large"
|
||||
name="email"
|
||||
@@ -178,7 +196,7 @@ export default function Register() {
|
||||
placeholder={t("placeholder_confirm_pwd")}
|
||||
></Input>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? t('signing_up') : t("sign_up")}
|
||||
{isLoading ? t("signing_up") : t("sign_up")}
|
||||
</Button>
|
||||
</form>
|
||||
<Divider content="OR" />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavLink, useSearchParams } from 'react-router-dom';
|
||||
import { isMobile } from '../../utils';
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NavLink, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { isMobile } from "../../utils";
|
||||
|
||||
export default function SignInLink({ token }: { token?: string }) {
|
||||
const { t } = useTranslation("auth");
|
||||
@@ -10,14 +11,18 @@ export default function SignInLink({ token }: { token?: string }) {
|
||||
useEffect(() => {
|
||||
// 移动端访问,则跳转
|
||||
if (isMobile() && !!token && ctx == "app") {
|
||||
location.href = `https://join.voce.chat/download?link=${encodeURIComponent(`${location.origin}?magic_token=${token}`)}`;
|
||||
location.href = `https://join.voce.chat/download?link=${encodeURIComponent(
|
||||
`${location.origin}?magic_token=${token}`
|
||||
)}`;
|
||||
}
|
||||
}, [token, ctx]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 mt-7 text-sm text-slate-500 dark:text-gray-100 justify-center">
|
||||
<span>{t("reg.have_account")}</span>
|
||||
<NavLink to={"/login"} className="text-primary-400 cursor-pointer">{t("sign_in")}</NavLink>
|
||||
<NavLink to={"/login"} className="text-primary-400 cursor-pointer">
|
||||
{t("sign_in")}
|
||||
</NavLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet, useOutletContext, useSearchParams } from "react-router-dom";
|
||||
|
||||
type ContextType = { token: string };
|
||||
export default function RegContainer() {
|
||||
const [token, setToken] = useState("");
|
||||
@@ -20,4 +21,4 @@ export default function RegContainer() {
|
||||
|
||||
export function useMagicToken() {
|
||||
return useOutletContext<ContextType>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FC } from "react";
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
|
||||
import ChannelIcon from "@/components/ChannelIcon";
|
||||
import Search from "../Search";
|
||||
import useFilteredChannels from "@/hooks/useFilteredChannels";
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
import Search from "../Search";
|
||||
|
||||
type Props = {
|
||||
select: number;
|
||||
@@ -20,14 +21,23 @@ const Channel: FC<Props> = ({ select = 0, updateFilter }) => {
|
||||
<Search embed={true} value={input} updateSearchValue={updateInput} />
|
||||
</div>
|
||||
<ul className="w-full flex flex-col gap-4 p-2">
|
||||
<li className="relative cursor-pointer flex items-center gap-2" onClick={handleClick.bind(null, undefined)}>
|
||||
<li
|
||||
className="relative cursor-pointer flex items-center gap-2"
|
||||
onClick={handleClick.bind(null, undefined)}
|
||||
>
|
||||
<ChannelIcon />
|
||||
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">Any Channel</span>
|
||||
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">
|
||||
Any Channel
|
||||
</span>
|
||||
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
|
||||
</li>
|
||||
{channels.map(({ gid, is_public, name }) => {
|
||||
return (
|
||||
<li key={gid} className="relative cursor-pointer flex items-center gap-2" onClick={handleClick.bind(null, gid)}>
|
||||
<li
|
||||
key={gid}
|
||||
className="relative cursor-pointer flex items-center gap-2"
|
||||
onClick={handleClick.bind(null, gid)}
|
||||
>
|
||||
<ChannelIcon personal={!is_public} />
|
||||
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">{name}</span>
|
||||
{select == gid && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC } from "react";
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
|
||||
export const Dates = {
|
||||
today: {
|
||||
@@ -32,15 +32,24 @@ const DateFilter: FC<Props> = ({ select = "", updateFilter }) => {
|
||||
return (
|
||||
<div className="p-3 bg-white dark:bg-gray-800 min-w-[200px] overflow-auto rounded-lg flex flex-col items-start relative drop-shadow">
|
||||
<ul className="w-full flex flex-col gap-4">
|
||||
<li className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm" onClick={handleClick.bind(null, undefined)}>
|
||||
<li
|
||||
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
|
||||
onClick={handleClick.bind(null, undefined)}
|
||||
>
|
||||
Any Time
|
||||
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
|
||||
</li>
|
||||
{Object.entries(Dates).map(([_key, { title }]) => {
|
||||
return (
|
||||
<li key={title} className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm" onClick={handleClick.bind(null, _key)}>
|
||||
<li
|
||||
key={title}
|
||||
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
|
||||
onClick={handleClick.bind(null, _key)}
|
||||
>
|
||||
{title}
|
||||
{select == _key && <CheckSign className="absolute right-0 -top-1/2 -translate-y-1/2" />}
|
||||
{select == _key && (
|
||||
<CheckSign className="absolute right-0 -top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { FC } from "react";
|
||||
import Search from "../Search";
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
|
||||
import User from "@/components/User";
|
||||
import useFilteredUsers from "@/hooks/useFilteredUsers";
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
import Search from "../Search";
|
||||
|
||||
type Props = {
|
||||
select: number;
|
||||
@@ -20,7 +21,10 @@ const From: FC<Props> = ({ select = "", updateFilter }) => {
|
||||
<Search embed={true} value={input} updateSearchValue={updateInput} />
|
||||
</div>
|
||||
<ul className="w-full flex flex-col">
|
||||
<li className="relative cursor-pointer p-2.5 font-semibold text-sm text-gray-500" onClick={handleClick.bind(null, undefined)}>
|
||||
<li
|
||||
className="relative cursor-pointer p-2.5 font-semibold text-sm text-gray-500"
|
||||
onClick={handleClick.bind(null, undefined)}
|
||||
>
|
||||
Anyone
|
||||
{!select && <CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />}
|
||||
</li>
|
||||
@@ -28,7 +32,9 @@ const From: FC<Props> = ({ select = "", updateFilter }) => {
|
||||
return (
|
||||
<li key={uid} className="relative cursor-pointer" onClick={handleClick.bind(null, uid)}>
|
||||
<User uid={uid} interactive={true} />
|
||||
{select == uid && <CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />}
|
||||
{select == uid && (
|
||||
<CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import IconPdf from "@/assets/icons/file.pdf.svg";
|
||||
import IconAudio from "@/assets/icons/file.audio.svg";
|
||||
import IconVideo from "@/assets/icons/file.video.svg";
|
||||
import IconUnknown from "@/assets/icons/file.unknown.svg";
|
||||
import IconDoc from "@/assets/icons/file.doc.svg";
|
||||
import IconCode from "@/assets/icons/file.code.svg";
|
||||
import IconImage from "@/assets/icons/file.image.svg";
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
import { FC } from "react";
|
||||
|
||||
import CheckSign from "@/assets/icons/check.sign.svg";
|
||||
import IconAudio from "@/assets/icons/file.audio.svg";
|
||||
import IconCode from "@/assets/icons/file.code.svg";
|
||||
import IconDoc from "@/assets/icons/file.doc.svg";
|
||||
import IconImage from "@/assets/icons/file.image.svg";
|
||||
import IconPdf from "@/assets/icons/file.pdf.svg";
|
||||
import IconUnknown from "@/assets/icons/file.unknown.svg";
|
||||
import IconVideo from "@/assets/icons/file.video.svg";
|
||||
|
||||
export const FileTypes = {
|
||||
doc: {
|
||||
title: "Documents",
|
||||
@@ -50,15 +51,24 @@ const Type: FC<Props> = ({ select = "", updateFilter }) => {
|
||||
return (
|
||||
<div className="p-3 bg-white dark:bg-gray-800 min-w-[180px] overflow-auto shadow-md rounded-lg flex flex-col items-start relative">
|
||||
<ul className="w-full flex flex-col gap-4">
|
||||
<li className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm" onClick={handleClick.bind(null, undefined)}>
|
||||
<li
|
||||
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
|
||||
onClick={handleClick.bind(null, undefined)}
|
||||
>
|
||||
Any Type
|
||||
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
|
||||
</li>
|
||||
{Object.entries(FileTypes).map(([type, { title, icon }]) => {
|
||||
return (
|
||||
<li key={title} className="relative cursor-pointer flex items-center gap-2 text-sm text-gray-500 dark:text-gray-300 font-semibold" onClick={handleClick.bind(null, type)}>
|
||||
<li
|
||||
key={title}
|
||||
className="relative cursor-pointer flex items-center gap-2 text-sm text-gray-500 dark:text-gray-300 font-semibold"
|
||||
onClick={handleClick.bind(null, type)}
|
||||
>
|
||||
{icon} {title}
|
||||
{select == type && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
|
||||
{select == type && (
|
||||
<CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import ArrowDown from "@/assets/icons/arrow.down.svg";
|
||||
import FilterChannel from "./Channel";
|
||||
import FilterDate, { Dates } from "./Date";
|
||||
import FilterFrom from "./From";
|
||||
import FilterChannel from "./Channel";
|
||||
import FilterType, { FileTypes } from "./Type";
|
||||
import ArrowDown from "@/assets/icons/arrow.down.svg";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
|
||||
const getClass = (selected: boolean) => {
|
||||
return clsx(`cursor-pointer flex items-center gap-1 md:gap-2 shadow rounded-lg p-1 md:py-2 md:px-3 text-xs text-gray-900 dark:text-gray-200`, selected ? 'text-white bg-primary-400' : 'border border-solid border-gray-300 dark:border-gray-400 ');
|
||||
return clsx(
|
||||
`cursor-pointer flex items-center gap-1 md:gap-2 shadow rounded-lg p-1 md:py-2 md:px-3 text-xs text-gray-900 dark:text-gray-200`,
|
||||
selected
|
||||
? "text-white bg-primary-400"
|
||||
: "border border-solid border-gray-300 dark:border-gray-400 "
|
||||
);
|
||||
};
|
||||
export default function Filter({ filter, updateFilter }) {
|
||||
const { t } = useTranslation("file");
|
||||
@@ -48,7 +54,6 @@ export default function Filter({ filter, updateFilter }) {
|
||||
from: fromVisible
|
||||
} = filtersVisible;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tippy
|
||||
@@ -58,10 +63,7 @@ export default function Filter({ filter, updateFilter }) {
|
||||
placement="bottom-start"
|
||||
content={<FilterFrom select={filter.from} updateFilter={handleUpdateFilter} />}
|
||||
>
|
||||
<div
|
||||
className={getClass(from)}
|
||||
onClick={toggleFilterVisible.bind(null, { from: true })}
|
||||
>
|
||||
<div className={getClass(from)} onClick={toggleFilterVisible.bind(null, { from: true })}>
|
||||
{from && (
|
||||
<Avatar
|
||||
width={16}
|
||||
@@ -99,10 +101,7 @@ export default function Filter({ filter, updateFilter }) {
|
||||
placement="bottom-start"
|
||||
content={<FilterType select={filter.type} updateFilter={handleUpdateFilter} />}
|
||||
>
|
||||
<div
|
||||
className={getClass(type)}
|
||||
onClick={toggleFilterVisible.bind(null, { type: true })}
|
||||
>
|
||||
<div className={getClass(type)} onClick={toggleFilterVisible.bind(null, { type: true })}>
|
||||
<span className="txt">{type ? FileTypes[type].title : t("type")}</span>
|
||||
<ArrowDown className="dark:stroke-gray-100" />
|
||||
</div>
|
||||
@@ -114,10 +113,7 @@ export default function Filter({ filter, updateFilter }) {
|
||||
placement="bottom-start"
|
||||
content={<FilterDate select={filter.date} updateFilter={handleUpdateFilter} />}
|
||||
>
|
||||
<div
|
||||
className={getClass(date)}
|
||||
onClick={toggleFilterVisible.bind(null, { date: true })}
|
||||
>
|
||||
<div className={getClass(date)} onClick={toggleFilterVisible.bind(null, { date: true })}>
|
||||
<span className="txt">{date ? Dates[date].title : t("date")}</span>
|
||||
<ArrowDown className="dark:stroke-gray-100" />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import { ChangeEvent, FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import IconSearch from "@/assets/icons/search.svg";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
updateSearchValue?: (value: string) => void;
|
||||
@@ -17,9 +19,19 @@ const Search: FC<Props> = ({ value = "", updateSearchValue = null, embed = false
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(`hidden md:block relative w-full py-1.5 px-4 shadow`, embed && "py-2 shadow-none")}>
|
||||
<div
|
||||
className={clsx(
|
||||
`hidden md:block relative w-full py-1.5 px-4 shadow`,
|
||||
embed && "py-2 shadow-none"
|
||||
)}
|
||||
>
|
||||
<IconSearch className="absolute left-6 top-1/2 -translate-y-1/2" />
|
||||
<input value={value} onChange={handleChange} className="bg-black/5 dark:bg-black/20 rounded-full text-sm text-gray-500 py-2.5 pl-9" placeholder={`${t("action.search")}...`} />
|
||||
<input
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className="bg-black/5 dark:bg-black/20 rounded-full text-sm text-gray-500 py-2.5 pl-9"
|
||||
placeholder={`${t("action.search")}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { MouseEvent } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { updateFileListView } from "@/app/slices/ui";
|
||||
import IconList from "@/assets/icons/file.list.svg";
|
||||
import IconGrid from "@/assets/icons/file.grid.svg";
|
||||
import clsx from "clsx";
|
||||
|
||||
const getClass = (selected: boolean) => clsx(`cursor-pointer p-2 box-border flex-center`, selected && `border border-solid border-primary-400 shadow rounded-lg`);
|
||||
import { updateFileListView } from "@/app/slices/ui";
|
||||
import IconGrid from "@/assets/icons/file.grid.svg";
|
||||
import IconList from "@/assets/icons/file.list.svg";
|
||||
|
||||
const getClass = (selected: boolean) =>
|
||||
clsx(
|
||||
`cursor-pointer p-2 box-border flex-center`,
|
||||
selected && `border border-solid border-primary-400 shadow rounded-lg`
|
||||
);
|
||||
type Props = {
|
||||
view?: "item" | "grid"
|
||||
}
|
||||
view?: "item" | "grid";
|
||||
};
|
||||
export default function View({ view = "item" }: Props) {
|
||||
const dispatch = useDispatch();
|
||||
const handleChangeView = (evt: MouseEvent<HTMLLIElement>) => {
|
||||
@@ -18,7 +23,9 @@ export default function View({ view = "item" }: Props) {
|
||||
};
|
||||
const isGrid = view == "grid";
|
||||
return (
|
||||
<ul className={`hidden md:flex border border-solid dark:border-gray-400 shadow rounded-lg box-border`}>
|
||||
<ul
|
||||
className={`hidden md:flex border border-solid dark:border-gray-400 shadow rounded-lg box-border`}
|
||||
>
|
||||
<li className={getClass(!isGrid)} data-view={"item"} onClick={handleChangeView}>
|
||||
<IconList className={`${!isGrid ? "fill-primary-400" : ""} dark:fill-gray-400`} />
|
||||
</li>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// @ts-nocheck
|
||||
import { useState, useEffect, useRef, memo } from "react";
|
||||
import Masonry from "masonry-layout";
|
||||
import View from "./View";
|
||||
import Search from "./Search";
|
||||
import Filter from "./Filter";
|
||||
import FileBox from "@/components/FileBox";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import Masonry from "masonry-layout";
|
||||
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import FileBox from "@/components/FileBox";
|
||||
import Filter from "./Filter";
|
||||
import Search from "./Search";
|
||||
import View from "./View";
|
||||
|
||||
const checkFilter = (data, filter, channelMessage) => {
|
||||
let selected = true;
|
||||
const { mid, file_type, created_at, from_uid, properties } = data;
|
||||
@@ -89,10 +91,14 @@ function ResourceManagement({ fileMessages }) {
|
||||
<Filter filter={filter} updateFilter={updateFilter} />
|
||||
<View view={view} />
|
||||
</div>
|
||||
<div className={clsx(`w-full h-full px-4 overflow-scroll flex`,
|
||||
view == "item" && 'gap-2 flex-col',
|
||||
view == "grid" && "flex-wrap"
|
||||
)} ref={listContainerRef}>
|
||||
<div
|
||||
className={clsx(
|
||||
`w-full h-full px-4 overflow-scroll flex`,
|
||||
view == "item" && "gap-2 flex-col",
|
||||
view == "grid" && "flex-wrap"
|
||||
)}
|
||||
ref={listContainerRef}
|
||||
>
|
||||
{fileMessages.map((id) => {
|
||||
const data = message[id];
|
||||
if (!data) return null;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Button from "@/components/styled/Button";
|
||||
import { FC, MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Button from "@/components/styled/Button";
|
||||
|
||||
interface Props {
|
||||
email: string;
|
||||
reset?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -11,10 +12,10 @@ const SentTip: FC<Props> = ({ email, reset }) => {
|
||||
const { t } = useTranslation("auth");
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">{t("check_email")}</h2>
|
||||
<span className="text-center text-gray-500 mb-6">
|
||||
{t("check_email_desc", { email })}
|
||||
</span>
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">
|
||||
{t("check_email")}
|
||||
</h2>
|
||||
<span className="text-center text-gray-500 mb-6">{t("check_email_desc", { email })}</span>
|
||||
<Button onClick={reset} className="main flex">
|
||||
{t("use_different")}
|
||||
</Button>
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useState, useEffect, ChangeEvent, FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import BASE_URL from "@/app/config";
|
||||
import Input from "@/components/styled/Input";
|
||||
import Button from "@/components/styled/Button";
|
||||
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
|
||||
import SentTip from "./SentTip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SocialLoginButtons from "../login/SocialLoginButtons";
|
||||
import Divider from "@/components/Divider";
|
||||
import SignInLink from "../reg/SignInLink";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import BASE_URL from "@/app/config";
|
||||
import { useSendLoginMagicLinkMutation } from "@/app/services/auth";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import Divider from "@/components/Divider";
|
||||
import Button from "@/components/styled/Button";
|
||||
import Input from "@/components/styled/Input";
|
||||
import SocialLoginButtons from "../login/SocialLoginButtons";
|
||||
import SignInLink from "../reg/SignInLink";
|
||||
import SentTip from "./SentTip";
|
||||
|
||||
export default function SendMagicLinkPage() {
|
||||
const { t } = useTranslation("auth");
|
||||
const serverName = useAppSelector(store => store.server.name);
|
||||
const serverName = useAppSelector((store) => store.server.name);
|
||||
const [sent, setSent] = useState(false);
|
||||
const [sendMagicLink, { isSuccess, isLoading, error }] = useSendLoginMagicLinkMutation();
|
||||
const navigateTo = useNavigate();
|
||||
@@ -73,8 +74,14 @@ export default function SendMagicLinkPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={`${BASE_URL}/resource/organization/logo`} alt="logo" className="w-14 h-14 mb-7 rounded-full" />
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">{t("enter")} {serverName} </h2>
|
||||
<img
|
||||
src={`${BASE_URL}/resource/organization/logo`}
|
||||
alt="logo"
|
||||
className="w-14 h-14 mb-7 rounded-full"
|
||||
/>
|
||||
<h2 className="font-semibold text-2xl text-gray-800 dark:text-gray-200 mb-2">
|
||||
{t("enter")} {serverName}{" "}
|
||||
</h2>
|
||||
<span className="text-center text-gray-500 mb-6">{t("placeholder_email")}</span>
|
||||
</div>
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-5 w-[360px]">
|
||||
@@ -94,7 +101,9 @@ export default function SendMagicLinkPage() {
|
||||
</Button>
|
||||
</form>
|
||||
<Divider content="OR" />
|
||||
<Button onClick={handlePwdPath} className="w-full" >{t("login.password")}</Button>
|
||||
<Button onClick={handlePwdPath} className="w-full">
|
||||
{t("login.password")}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<SocialLoginButtons />
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import { hideAll } from "tippy.js";
|
||||
import Input from "@/components/styled/Input";
|
||||
import Toggle from "@/components/styled/Toggle";
|
||||
import Button from "@/components/styled/Button";
|
||||
|
||||
import {
|
||||
useGetThirdPartySecretQuery,
|
||||
useUpdateThirdPartySecretMutation
|
||||
} from "@/app/services/server";
|
||||
import useConfig from "@/hooks/useConfig";
|
||||
import { LoginConfig } from "@/types/server";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Button from "@/components/styled/Button";
|
||||
import Input from "@/components/styled/Input";
|
||||
import Toggle from "@/components/styled/Toggle";
|
||||
import useConfig from "@/hooks/useConfig";
|
||||
|
||||
export default function APIConfig() {
|
||||
const { t } = useTranslation("setting");
|
||||
@@ -40,7 +40,10 @@ export default function APIConfig() {
|
||||
checked={thirdParty}
|
||||
/>
|
||||
<div className="w-full flex flex-col items-start gap-2">
|
||||
<label htmlFor="secret" className="text-sm text-gray-500 dark:text-gray-100"> {t("third_app.key")}:</label>
|
||||
<label htmlFor="secret" className="text-sm text-gray-500 dark:text-gray-100">
|
||||
{" "}
|
||||
{t("third_app.key")}:
|
||||
</label>
|
||||
<Input disabled={!thirdParty} type="password" id="secret" value={updatedSecret || data} />
|
||||
</div>
|
||||
<Tippy
|
||||
@@ -49,9 +52,7 @@ export default function APIConfig() {
|
||||
trigger="click"
|
||||
content={
|
||||
<div className="p-3 rounded-lg border border-orange-400 border-solid flex flex-col gap-3 w-[250px] bg-white">
|
||||
<div className="text-orange-500 text-xs">
|
||||
{t("third_app.update_tip")}
|
||||
</div>
|
||||
<div className="text-orange-500 text-xs">{t("third_app.update_tip")}</div>
|
||||
<div className="flex justify-end gap-3 w-full">
|
||||
<Button onClick={() => hideAll()} className="cancel mini">
|
||||
{ct("action.cancel")}
|
||||
@@ -67,8 +68,13 @@ export default function APIConfig() {
|
||||
</Tippy>
|
||||
<div className="text-xs text-orange-400">
|
||||
{t("third_app.key_tip")}
|
||||
<a className="text-primary-500 font-bold" href="https://doc.voce.chat/login-with-other-account" target="_blank" rel="noopener noreferrer">
|
||||
🔗 {t("third_app.how_to")}
|
||||
<a
|
||||
className="text-primary-500 font-bold"
|
||||
href="https://doc.voce.chat/login-with-other-account"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🔗 {t("third_app.how_to")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,69 +1,86 @@
|
||||
// import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '../../app/store';
|
||||
import IconCopy from '@/assets/icons/copy.svg';
|
||||
import useCopy from '../../hooks/useCopy';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import IconCopy from "@/assets/icons/copy.svg";
|
||||
import { useAppSelector } from "../../app/store";
|
||||
import useCopy from "../../hooks/useCopy";
|
||||
|
||||
// type Props = {}
|
||||
const APIUrl = `${location.origin}/api/swagger`;
|
||||
const APIDocument = () => {
|
||||
const token = useAppSelector(store => store.authData.token);
|
||||
const { copy } = useCopy();
|
||||
const { t } = useTranslation("setting");
|
||||
const handleCopy = () => {
|
||||
copy(token);
|
||||
};
|
||||
return (
|
||||
<section className="flex flex-col justify-start items-start gap-4">
|
||||
<div className="font-semibold dark:text-white">
|
||||
{t("api_doc.desc")}
|
||||
const token = useAppSelector((store) => store.authData.token);
|
||||
const { copy } = useCopy();
|
||||
const { t } = useTranslation("setting");
|
||||
const handleCopy = () => {
|
||||
copy(token);
|
||||
};
|
||||
return (
|
||||
<section className="flex flex-col justify-start items-start gap-4">
|
||||
<div className="font-semibold dark:text-white">{t("api_doc.desc")}</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h2 className="text-gray-700 dark:text-white text-lg font-semibold flex gap-1">
|
||||
{t("api_doc.access")}
|
||||
<a
|
||||
href={APIUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary-600"
|
||||
>
|
||||
{APIUrl}
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<h2 className="text-gray-700 dark:text-white text-lg font-semibold">
|
||||
{t("api_doc.use_method")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-gray-700 dark:text-white ">{t("api_doc.step_1")}</h3>
|
||||
{/* <div className="flex flex-col gap-1"> */}
|
||||
<img
|
||||
className="border border-solid rounded-md border-gray-300 shadow-lg md:w-[50%]"
|
||||
src="https://s.voce.chat/web_client/assets/img/api.doc.step1.png"
|
||||
alt="step 1"
|
||||
/>
|
||||
{/* </div> */}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-gray-700 dark:text-white flex flex-col items-start gap-2">
|
||||
{t("api_doc.step_2")}{" "}
|
||||
<span className="text-gray-400 dark:text-white text-xs">
|
||||
({t("api_doc.step_2_desc")})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex flex-col border border-solid border-green-500 bg-green-100 rounded-md p-2 w-fit break-words text-sm relative">
|
||||
<p className="break-all md:max-w-4xl font-bold">
|
||||
{token}
|
||||
<IconCopy
|
||||
onClick={handleCopy}
|
||||
className="absolute right-2 bottom-2 cursor-pointer"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<h2 className='text-gray-700 dark:text-white text-lg font-semibold flex gap-1'>
|
||||
{t("api_doc.access")}
|
||||
<a href={APIUrl} target="_blank" rel="noopener noreferrer" className='underline text-primary-600'>
|
||||
{APIUrl}
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 w-full'>
|
||||
<h2 className='text-gray-700 dark:text-white text-lg font-semibold'>
|
||||
{t("api_doc.use_method")}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className='text-gray-700 dark:text-white '>
|
||||
{t("api_doc.step_1")}
|
||||
</h3>
|
||||
{/* <div className="flex flex-col gap-1"> */}
|
||||
<img className='border border-solid rounded-md border-gray-300 shadow-lg md:w-[50%]' src="https://s.voce.chat/web_client/assets/img/api.doc.step1.png" alt="step 1" />
|
||||
{/* </div> */}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className='text-gray-700 dark:text-white flex flex-col items-start gap-2'>
|
||||
{t("api_doc.step_2")} <span className='text-gray-400 dark:text-white text-xs'>
|
||||
({t("api_doc.step_2_desc")})
|
||||
</span>
|
||||
</h3>
|
||||
<div className='flex flex-col border border-solid border-green-500 bg-green-100 rounded-md p-2 w-fit break-words text-sm relative'>
|
||||
<p className="break-all md:max-w-4xl font-bold">
|
||||
{token}
|
||||
<IconCopy onClick={handleCopy} className="absolute right-2 bottom-2 cursor-pointer" />
|
||||
</p>
|
||||
</div>
|
||||
<img className='border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]' src="https://s.voce.chat/web_client/assets/img/api.doc.step2.jpg" alt="step 2" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className='text-gray-700 dark:text-white flex items-center gap-2'>
|
||||
{t("api_doc.last")}
|
||||
</h3>
|
||||
<img className='border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]' src="https://s.voce.chat/web_client/assets/img/api.doc.step3.png" alt="step 3" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
);
|
||||
<img
|
||||
className="border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]"
|
||||
src="https://s.voce.chat/web_client/assets/img/api.doc.step2.jpg"
|
||||
alt="step 2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-gray-700 dark:text-white flex items-center gap-2">
|
||||
{t("api_doc.last")}
|
||||
</h3>
|
||||
<img
|
||||
className="border border-solid rounded-md border-gray-300 shadow-lg md:w-[85%]"
|
||||
src="https://s.voce.chat/web_client/assets/img/api.doc.step3.png"
|
||||
alt="step 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default APIDocument;
|
||||
export default APIDocument;
|
||||
|
||||
@@ -1,82 +1,111 @@
|
||||
// import Tippy from '@tippyjs/react';
|
||||
import dayjs from 'dayjs';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetBotAPIKeysQuery } from '../../../app/services/user';
|
||||
import IconDelete from '@/assets/icons/delete.svg';
|
||||
import IconAdd from '@/assets/icons/add.svg';
|
||||
import CreateAPIKeyModal from './CreateAPIKeyModal';
|
||||
import DeleteAPIKeyModal from './DeleteAPIKeyModal';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import IconAdd from "@/assets/icons/add.svg";
|
||||
import IconDelete from "@/assets/icons/delete.svg";
|
||||
import { useGetBotAPIKeysQuery } from "../../../app/services/user";
|
||||
import CreateAPIKeyModal from "./CreateAPIKeyModal";
|
||||
import DeleteAPIKeyModal from "./DeleteAPIKeyModal";
|
||||
|
||||
type Props = {
|
||||
uid: number
|
||||
}
|
||||
type DeleteAPIKeyProps = { uid: number, kid: number }
|
||||
uid: number;
|
||||
};
|
||||
type DeleteAPIKeyProps = { uid: number; kid: number };
|
||||
const tdClass = "p-1 whitespace-nowrap text-xs text-gray-500 dark:text-gray-200 align-middle px-1";
|
||||
const BotAPIKeys = ({ uid }: Props) => {
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const [currentUid, setCurrentUid] = useState<number | undefined>();
|
||||
const [deleteApiKey, setDeleteApiKey] = useState<DeleteAPIKeyProps | undefined>();
|
||||
const { data, refetch } = useGetBotAPIKeysQuery(uid);
|
||||
const toggleCreateModal = (param?: number) => {
|
||||
if (!param) refetch();
|
||||
setCurrentUid(param);
|
||||
};
|
||||
const toggleDeleteModal = (param?: DeleteAPIKeyProps) => {
|
||||
if (!param) refetch();
|
||||
setDeleteApiKey(param);
|
||||
};
|
||||
if (!data) return null;
|
||||
const colWidths = ["w-20", "w-[166px]", "w-36", "w-15", "w-10"];
|
||||
return (
|
||||
<div className='flex flex-col gap-2 items-start'>
|
||||
<div className='border-t border-solid border-b border-gray-100 dark:border-gray-500 py-2 w-full'>
|
||||
<table className="min-w-full table-fixed font-mono">
|
||||
<thead >
|
||||
<tr >
|
||||
{[t("col_key_name"), t('col_key_value'), t('col_key_create_time'), t('col_key_last_used'), ""].map((title, idx) => <th key={title} scope="col" className={clsx(`text-xs text-gray-900 dark:text-gray-50 px-1 text-left pb-2`, colWidths[idx])}>
|
||||
{title}
|
||||
</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length > 0 ? data.map(ak => {
|
||||
const { id, name, key, created_at, last_used } = ak;
|
||||
return <tr key={id} className="group" >
|
||||
<td className={tdClass}>
|
||||
{name}
|
||||
</td>
|
||||
<td className={`${tdClass} w-40`}>
|
||||
{`${key.slice(0, 4)} ... ... ${key.slice(-6)}`}
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
{dayjs(created_at).format("YYYY-MM-DD HH:mm:ss")}
|
||||
</td>
|
||||
<td className={tdClass}>
|
||||
{last_used ? dayjs(last_used).format("YYYY-MM-DD HH:mm:ss") : "Unused"}
|
||||
</td>
|
||||
<td className={`${tdClass} invisible group-hover:visible`}>
|
||||
<button onClick={toggleDeleteModal.bind(null, { kid: id, uid })}>
|
||||
<IconDelete />
|
||||
</button>
|
||||
</td>
|
||||
</tr>;
|
||||
}) : <tr>
|
||||
<td colSpan={4} className='text-center text-xs text-gray-400 py-2'>
|
||||
{t("no_api_key")}
|
||||
</td>
|
||||
</tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<button onClick={toggleCreateModal.bind(null, uid)} className="text-green-600 text-xs py-0.5 flex items-center gap-1 m-auto my-2 bg-green-50 rounded-full px-2 ">
|
||||
<IconAdd className="!w-4 !h-4 fill-green-600" /> {t("add_api_key")}
|
||||
</button>
|
||||
</div>
|
||||
{currentUid && <CreateAPIKeyModal uid={currentUid} closeModal={toggleCreateModal.bind(null, undefined)} />}
|
||||
{deleteApiKey && <DeleteAPIKeyModal uid={deleteApiKey.uid} kid={deleteApiKey.kid} closeModal={toggleDeleteModal.bind(null, undefined)} />}
|
||||
</div>
|
||||
);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const [currentUid, setCurrentUid] = useState<number | undefined>();
|
||||
const [deleteApiKey, setDeleteApiKey] = useState<DeleteAPIKeyProps | undefined>();
|
||||
const { data, refetch } = useGetBotAPIKeysQuery(uid);
|
||||
const toggleCreateModal = (param?: number) => {
|
||||
if (!param) refetch();
|
||||
setCurrentUid(param);
|
||||
};
|
||||
const toggleDeleteModal = (param?: DeleteAPIKeyProps) => {
|
||||
if (!param) refetch();
|
||||
setDeleteApiKey(param);
|
||||
};
|
||||
if (!data) return null;
|
||||
const colWidths = ["w-20", "w-[166px]", "w-36", "w-15", "w-10"];
|
||||
return (
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<div className="border-t border-solid border-b border-gray-100 dark:border-gray-500 py-2 w-full">
|
||||
<table className="min-w-full table-fixed font-mono">
|
||||
<thead>
|
||||
<tr>
|
||||
{[
|
||||
t("col_key_name"),
|
||||
t("col_key_value"),
|
||||
t("col_key_create_time"),
|
||||
t("col_key_last_used"),
|
||||
""
|
||||
].map((title, idx) => (
|
||||
<th
|
||||
key={title}
|
||||
scope="col"
|
||||
className={clsx(
|
||||
`text-xs text-gray-900 dark:text-gray-50 px-1 text-left pb-2`,
|
||||
colWidths[idx]
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length > 0 ? (
|
||||
data.map((ak) => {
|
||||
const { id, name, key, created_at, last_used } = ak;
|
||||
return (
|
||||
<tr key={id} className="group">
|
||||
<td className={tdClass}>{name}</td>
|
||||
<td className={`${tdClass} w-40`}>
|
||||
{`${key.slice(0, 4)} ... ... ${key.slice(-6)}`}
|
||||
</td>
|
||||
<td className={tdClass}>{dayjs(created_at).format("YYYY-MM-DD HH:mm:ss")}</td>
|
||||
<td className={tdClass}>
|
||||
{last_used ? dayjs(last_used).format("YYYY-MM-DD HH:mm:ss") : "Unused"}
|
||||
</td>
|
||||
<td className={`${tdClass} invisible group-hover:visible`}>
|
||||
<button onClick={toggleDeleteModal.bind(null, { kid: id, uid })}>
|
||||
<IconDelete />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center text-xs text-gray-400 py-2">
|
||||
{t("no_api_key")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
onClick={toggleCreateModal.bind(null, uid)}
|
||||
className="text-green-600 text-xs py-0.5 flex items-center gap-1 m-auto my-2 bg-green-50 rounded-full px-2 "
|
||||
>
|
||||
<IconAdd className="!w-4 !h-4 fill-green-600" /> {t("add_api_key")}
|
||||
</button>
|
||||
</div>
|
||||
{currentUid && (
|
||||
<CreateAPIKeyModal uid={currentUid} closeModal={toggleCreateModal.bind(null, undefined)} />
|
||||
)}
|
||||
{deleteApiKey && (
|
||||
<DeleteAPIKeyModal
|
||||
uid={deleteApiKey.uid}
|
||||
kid={deleteApiKey.kid}
|
||||
closeModal={toggleDeleteModal.bind(null, undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BotAPIKeys;
|
||||
export default BotAPIKeys;
|
||||
|
||||
@@ -1,88 +1,96 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateBotAPIKeyMutation } from '../../../app/services/user';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Button from '../../../components/styled/Button';
|
||||
import Input from '../../../components/styled/Input';
|
||||
import StyledModal from '../../../components/styled/Modal';
|
||||
import useCopy from '../../../hooks/useCopy';
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCreateBotAPIKeyMutation } from "../../../app/services/user";
|
||||
import Modal from "../../../components/Modal";
|
||||
import Button from "../../../components/styled/Button";
|
||||
import Input from "../../../components/styled/Input";
|
||||
import StyledModal from "../../../components/styled/Modal";
|
||||
import useCopy from "../../../hooks/useCopy";
|
||||
|
||||
type Props = {
|
||||
uid: number,
|
||||
closeModal: () => void
|
||||
}
|
||||
uid: number;
|
||||
closeModal: () => void;
|
||||
};
|
||||
const CreateAPIKeyModal = ({ closeModal, uid }: Props) => {
|
||||
const { copy } = useCopy();
|
||||
const [createBotAPIKey, { error, isSuccess, isLoading, data = "" }] = useCreateBotAPIKeyMutation();
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleCreateBot = () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const formEle = formRef.current as HTMLFormElement;
|
||||
if (!formEle.checkValidity()) {
|
||||
formEle.reportValidity();
|
||||
return;
|
||||
}
|
||||
createBotAPIKey({
|
||||
uid,
|
||||
name: formEle.querySelector('input')?.value || ""
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
switch (error.status) {
|
||||
case 406:
|
||||
toast.error("Invalid Webhook URL!");
|
||||
break;
|
||||
case 409:
|
||||
toast.error("Name Already Exists!");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
const handleCopy = () => {
|
||||
copy(data);
|
||||
toast.success("API Key Copied!");
|
||||
closeModal();
|
||||
};
|
||||
const { copy } = useCopy();
|
||||
const [createBotAPIKey, { error, isSuccess, isLoading, data = "" }] =
|
||||
useCreateBotAPIKeyMutation();
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleCreateBot = () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const formEle = formRef.current as HTMLFormElement;
|
||||
if (!formEle.checkValidity()) {
|
||||
formEle.reportValidity();
|
||||
return;
|
||||
}
|
||||
createBotAPIKey({
|
||||
uid,
|
||||
name: formEle.querySelector("input")?.value || ""
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
switch (error.status) {
|
||||
case 406:
|
||||
toast.error("Invalid Webhook URL!");
|
||||
break;
|
||||
case 409:
|
||||
toast.error("Name Already Exists!");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
const handleCopy = () => {
|
||||
copy(data);
|
||||
toast.success("API Key Copied!");
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={t("create_key_title")}
|
||||
description={t("create_key_desc")}
|
||||
buttons={
|
||||
isSuccess ? <Button onClick={handleCopy}>
|
||||
{t("key_copy_and_close")}
|
||||
</Button> : <>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateBot}>{isLoading ? "..." : ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isSuccess ? <div className="flex flex-col gap-2 text-sm">
|
||||
<div className='border-green-600 bg-green-200/50 rounded border border-solid p-2 max-w-md w-full whitespace-pre-wrap break-all' >
|
||||
{data}
|
||||
</div> <div className='text-red-400'>
|
||||
⚠️ {t("create_key_warning")}
|
||||
</div>
|
||||
</div>
|
||||
: <form ref={formRef} className="w-full flex flex-col gap-2 items-center" action="/">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<label htmlFor={"name"} className="text-sm text-gray-500">Name</label>
|
||||
<Input name={"name"} required placeholder='Please input API Key name'></Input>
|
||||
</div>
|
||||
</form>}
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={t("create_key_title")}
|
||||
description={t("create_key_desc")}
|
||||
buttons={
|
||||
isSuccess ? (
|
||||
<Button onClick={handleCopy}>{t("key_copy_and_close")}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateBot}>{isLoading ? "..." : ct("action.done")}</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="border-green-600 bg-green-200/50 rounded border border-solid p-2 max-w-md w-full whitespace-pre-wrap break-all">
|
||||
{data}
|
||||
</div>{" "}
|
||||
<div className="text-red-400">⚠️ {t("create_key_warning")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<form ref={formRef} className="w-full flex flex-col gap-2 items-center" action="/">
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<label htmlFor={"name"} className="text-sm text-gray-500">
|
||||
Name
|
||||
</label>
|
||||
<Input name={"name"} required placeholder="Please input API Key name"></Input>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAPIKeyModal;
|
||||
export default CreateAPIKeyModal;
|
||||
|
||||
@@ -1,97 +1,101 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateUserMutation } from '../../../app/services/user';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Button from '../../../components/styled/Button';
|
||||
import Input from '../../../components/styled/Input';
|
||||
import StyledModal from '../../../components/styled/Modal';
|
||||
import { BASE_ORIGIN } from '../../../app/config';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BASE_ORIGIN } from "../../../app/config";
|
||||
import { useCreateUserMutation } from "../../../app/services/user";
|
||||
import Modal from "../../../components/Modal";
|
||||
import Button from "../../../components/styled/Button";
|
||||
import Input from "../../../components/styled/Input";
|
||||
import StyledModal from "../../../components/styled/Modal";
|
||||
|
||||
type Props = {
|
||||
closeModal: () => void
|
||||
}
|
||||
closeModal: () => void;
|
||||
};
|
||||
type CreateDTO = {
|
||||
name: string,
|
||||
webhook_url?: string
|
||||
}
|
||||
name: string;
|
||||
webhook_url?: string;
|
||||
};
|
||||
const CreateModal = ({ closeModal }: Props) => {
|
||||
const [createUser, { isSuccess, isLoading, error }] = useCreateUserMutation();
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleCreateBot = () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const formEle = formRef.current as HTMLFormElement;
|
||||
if (!formEle.checkValidity()) {
|
||||
formEle.reportValidity();
|
||||
return;
|
||||
const [createUser, { isSuccess, isLoading, error }] = useCreateUserMutation();
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleCreateBot = () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const formEle = formRef.current as HTMLFormElement;
|
||||
if (!formEle.checkValidity()) {
|
||||
formEle.reportValidity();
|
||||
return;
|
||||
}
|
||||
const myFormData = new FormData(formEle);
|
||||
const formDataObj: CreateDTO = { name: "" };
|
||||
myFormData.forEach((value, key) => {
|
||||
if (value) {
|
||||
formDataObj[key] = value;
|
||||
}
|
||||
});
|
||||
const hostname = new URL(BASE_ORIGIN).hostname;
|
||||
createUser({
|
||||
is_bot: true,
|
||||
is_admin: false,
|
||||
gender: 1,
|
||||
email: `bot_${new Date().getTime()}@${hostname}`,
|
||||
password: "",
|
||||
...formDataObj
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
switch (error.status) {
|
||||
case 406:
|
||||
toast.error("Invalid Webhook URL!");
|
||||
break;
|
||||
|
||||
}
|
||||
const myFormData = new FormData(formEle);
|
||||
const formDataObj: CreateDTO = { name: "" };
|
||||
myFormData.forEach((value, key) => {
|
||||
if (value) {
|
||||
formDataObj[key] = value;
|
||||
}
|
||||
});
|
||||
const hostname = new URL(BASE_ORIGIN).hostname;
|
||||
createUser({
|
||||
is_bot: true,
|
||||
is_admin: false,
|
||||
gender: 1,
|
||||
email: `bot_${new Date().getTime()}@${hostname}`,
|
||||
password: "",
|
||||
...formDataObj
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
switch (error.status) {
|
||||
case 406:
|
||||
toast.error("Invalid Webhook URL!");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [error]);
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success("Create Bot Successfully!");
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={t("create_title")}
|
||||
description={t("create_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateBot}>{isLoading ? "Creating" : ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
}, [error]);
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success("Create Bot Successfully!");
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={t("create_title")}
|
||||
description={t("create_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateBot}>{isLoading ? "Creating" : ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
|
||||
<div className="flex flex-col items-start gap-1 w-full">
|
||||
<label htmlFor={"name"} className="text-sm text-gray-500">Name</label>
|
||||
<Input name={"name"} required placeholder='Please input bot name'></Input>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 w-full">
|
||||
<label htmlFor={"webhook_url"} className="text-sm text-gray-500">Webhook URL (Optional)</label>
|
||||
<Input name={"webhook_url"} type="url" placeholder='Please input webhook url'></Input>
|
||||
</div>
|
||||
</form>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
>
|
||||
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
|
||||
<div className="flex flex-col items-start gap-1 w-full">
|
||||
<label htmlFor={"name"} className="text-sm text-gray-500">
|
||||
Name
|
||||
</label>
|
||||
<Input name={"name"} required placeholder="Please input bot name"></Input>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 w-full">
|
||||
<label htmlFor={"webhook_url"} className="text-sm text-gray-500">
|
||||
Webhook URL (Optional)
|
||||
</label>
|
||||
<Input name={"webhook_url"} type="url" placeholder="Please input webhook url"></Input>
|
||||
</div>
|
||||
</form>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateModal;
|
||||
export default CreateModal;
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLazyDeleteBotAPIKeyQuery } from '../../../app/services/user';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Button from '../../../components/styled/Button';
|
||||
import StyledModal from '../../../components/styled/Modal';
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useLazyDeleteBotAPIKeyQuery } from "../../../app/services/user";
|
||||
import Modal from "../../../components/Modal";
|
||||
import Button from "../../../components/styled/Button";
|
||||
import StyledModal from "../../../components/styled/Modal";
|
||||
|
||||
type Props = {
|
||||
uid: number,
|
||||
kid: number,
|
||||
closeModal: () => void
|
||||
}
|
||||
uid: number;
|
||||
kid: number;
|
||||
closeModal: () => void;
|
||||
};
|
||||
const DeleteAPIKeyModal = ({ closeModal, uid, kid }: Props) => {
|
||||
const [deleteKey, { isSuccess, isLoading }] = useLazyDeleteBotAPIKeyQuery();
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleDeleteBot = () => {
|
||||
deleteKey({ uid, kid });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.delete"));
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const [deleteKey, { isSuccess, isLoading }] = useLazyDeleteBotAPIKeyQuery();
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleDeleteBot = () => {
|
||||
deleteKey({ uid, kid });
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.delete"));
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={`${t("delete_key_title")} ${name}`}
|
||||
description={t("delete_key_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button className='danger' onClick={handleDeleteBot}>{isLoading ? "Deleting" : ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={`${t("delete_key_title")} ${name}`}
|
||||
description={t("delete_key_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button className="danger" onClick={handleDeleteBot}>
|
||||
{isLoading ? "Deleting" : ct("action.done")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
></StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAPIKeyModal;
|
||||
export default DeleteAPIKeyModal;
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLazyDeleteUserQuery } from '../../../app/services/user';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Button from '../../../components/styled/Button';
|
||||
import StyledModal from '../../../components/styled/Modal';
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useLazyDeleteUserQuery } from "../../../app/services/user";
|
||||
import Modal from "../../../components/Modal";
|
||||
import Button from "../../../components/styled/Button";
|
||||
import StyledModal from "../../../components/styled/Modal";
|
||||
|
||||
type Props = {
|
||||
uid: number,
|
||||
name: string,
|
||||
closeModal: () => void
|
||||
}
|
||||
uid: number;
|
||||
name: string;
|
||||
closeModal: () => void;
|
||||
};
|
||||
const DeleteModal = ({ closeModal, uid, name }: Props) => {
|
||||
const [deleteUser, { isSuccess, isLoading }] = useLazyDeleteUserQuery();
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleDeleteBot = () => {
|
||||
deleteUser(uid);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.delete"));
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const [deleteUser, { isSuccess, isLoading }] = useLazyDeleteUserQuery();
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleDeleteBot = () => {
|
||||
deleteUser(uid);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.delete"));
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={`${t("delete_title")} ${name}`}
|
||||
description={t("delete_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button className='danger' onClick={handleDeleteBot}>{isLoading ? "Deleting" : ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={`${t("delete_title")} ${name}`}
|
||||
description={t("delete_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button className="danger" onClick={handleDeleteBot}>
|
||||
{isLoading ? "Deleting" : ct("action.done")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
></StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteModal;
|
||||
export default DeleteModal;
|
||||
|
||||
@@ -1,86 +1,118 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Button from '../../../components/styled/Button';
|
||||
import Textarea from '../../../components/styled/Textarea';
|
||||
import StyledModal from '../../../components/styled/Modal';
|
||||
import { useLazyGetBotRelatedChannelsQuery, useSendMessageByBotMutation } from '../../../app/services/server';
|
||||
import clsx from 'clsx';
|
||||
import { MessageTypes } from '../../../app/config';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { MessageTypes } from "../../../app/config";
|
||||
import {
|
||||
useLazyGetBotRelatedChannelsQuery,
|
||||
useSendMessageByBotMutation
|
||||
} from "../../../app/services/server";
|
||||
import Modal from "../../../components/Modal";
|
||||
import Button from "../../../components/styled/Button";
|
||||
import StyledModal from "../../../components/styled/Modal";
|
||||
import Textarea from "../../../components/styled/Textarea";
|
||||
|
||||
type Props = {
|
||||
closeModal: () => void
|
||||
}
|
||||
closeModal: () => void;
|
||||
};
|
||||
const TestAPIKeyModal = ({ closeModal }: Props) => {
|
||||
const [currCid, setCurrCid] = useState<number | null>(null);
|
||||
const [msgType, setMsgType] = useState("text");
|
||||
const [getChannels, { data }] = useLazyGetBotRelatedChannelsQuery();
|
||||
const [sendMessage] = useSendMessageByBotMutation();
|
||||
const inputRef = useRef<HTMLTextAreaElement | undefined>();
|
||||
const msgInputRef = useRef<HTMLTextAreaElement | undefined>();
|
||||
const [key, setKey] = useState("");
|
||||
// const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
const handleSetKey = () => {
|
||||
const input = inputRef?.current;
|
||||
if (input && input.value) {
|
||||
setKey(input.value);
|
||||
}
|
||||
};
|
||||
const handleSetChannel = (cid: number) => {
|
||||
setCurrCid(cid);
|
||||
};
|
||||
const handleSetMsgType = (type: string) => {
|
||||
setMsgType(type);
|
||||
};
|
||||
const handleSend = () => {
|
||||
const input = msgInputRef?.current;
|
||||
if (input && input.value && currCid) {
|
||||
sendMessage({ cid: currCid, api_key: key, type: msgType, content: input.value });
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (key) {
|
||||
getChannels({ api_key: key });
|
||||
}
|
||||
}, [key]);
|
||||
const [currCid, setCurrCid] = useState<number | null>(null);
|
||||
const [msgType, setMsgType] = useState("text");
|
||||
const [getChannels, { data }] = useLazyGetBotRelatedChannelsQuery();
|
||||
const [sendMessage] = useSendMessageByBotMutation();
|
||||
const inputRef = useRef<HTMLTextAreaElement | undefined>();
|
||||
const msgInputRef = useRef<HTMLTextAreaElement | undefined>();
|
||||
const [key, setKey] = useState("");
|
||||
// const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
const handleSetKey = () => {
|
||||
const input = inputRef?.current;
|
||||
if (input && input.value) {
|
||||
setKey(input.value);
|
||||
}
|
||||
};
|
||||
const handleSetChannel = (cid: number) => {
|
||||
setCurrCid(cid);
|
||||
};
|
||||
const handleSetMsgType = (type: string) => {
|
||||
setMsgType(type);
|
||||
};
|
||||
const handleSend = () => {
|
||||
const input = msgInputRef?.current;
|
||||
if (input && input.value && currCid) {
|
||||
sendMessage({ cid: currCid, api_key: key, type: msgType, content: input.value });
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (key) {
|
||||
getChannels({ api_key: key });
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={key ? "" : `Input API Key`}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSetKey}>{ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{key ? (
|
||||
data ? (
|
||||
<ul className="divide-y-2">
|
||||
{data.map(({ gid, name, is_public }) => {
|
||||
return (
|
||||
<li
|
||||
key={gid}
|
||||
className={clsx(
|
||||
"py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50",
|
||||
gid == currCid ? "bg-slate-100" : ""
|
||||
)}
|
||||
onClick={handleSetChannel.bind(null, gid)}
|
||||
>
|
||||
# {name} {!is_public ? "🔒" : ""}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null
|
||||
) : (
|
||||
<Textarea rows={6} ref={inputRef} placeholder="Input API Key First" />
|
||||
)}
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={key ? "" : `Input API Key`}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSetKey} >{ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{key ? (data ? <ul className="divide-y-2">
|
||||
{data.map(({ gid, name, is_public }) => {
|
||||
return <li key={gid} className={clsx("py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50", gid == currCid ? 'bg-slate-100' : "")} onClick={handleSetChannel.bind(null, gid)}>
|
||||
# {name} {!is_public ? "🔒" : ""}
|
||||
</li>;
|
||||
})}
|
||||
</ul> : null)
|
||||
: <Textarea rows={6} ref={inputRef} placeholder='Input API Key First' />}
|
||||
|
||||
{currCid ? <div className='mt-4 flex flex-col items-start gap-2'>
|
||||
<Textarea ref={msgInputRef} placeholder='Input Something...' />
|
||||
<ul className='flex gap-1'>
|
||||
{Object.entries(MessageTypes).map(([key, value]) => {
|
||||
return <li onClick={handleSetMsgType.bind(null, key)} className={clsx("py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50", msgType == key ? 'bg-slate-100' : "")} key={key}>{value}</li>;
|
||||
})}
|
||||
</ul>
|
||||
<Button className='mini' onClick={handleSend} >Send</Button>
|
||||
</div> : null}
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
{currCid ? (
|
||||
<div className="mt-4 flex flex-col items-start gap-2">
|
||||
<Textarea ref={msgInputRef} placeholder="Input Something..." />
|
||||
<ul className="flex gap-1">
|
||||
{Object.entries(MessageTypes).map(([key, value]) => {
|
||||
return (
|
||||
<li
|
||||
onClick={handleSetMsgType.bind(null, key)}
|
||||
className={clsx(
|
||||
"py-1 px-2 text-gray-500 cursor-pointer md:hover:bg-slate-50",
|
||||
msgType == key ? "bg-slate-100" : ""
|
||||
)}
|
||||
key={key}
|
||||
>
|
||||
{value}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<Button className="mini" onClick={handleSend}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestAPIKeyModal;
|
||||
export default TestAPIKeyModal;
|
||||
|
||||
@@ -1,95 +1,122 @@
|
||||
import clsx from 'clsx';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { Orbit } from "@uiball/loaders";
|
||||
import { useGetUserByAdminQuery, useUpdateUserMutation } from '../../../app/services/user';
|
||||
import IconEdit from '@/assets/icons/edit.svg';
|
||||
import IconSave from '@/assets/icons/save.svg';
|
||||
import IconCancel from '@/assets/icons/close.circle.svg';
|
||||
import clsx from "clsx";
|
||||
|
||||
import IconCancel from "@/assets/icons/close.circle.svg";
|
||||
import IconEdit from "@/assets/icons/edit.svg";
|
||||
import IconSave from "@/assets/icons/save.svg";
|
||||
import { useGetUserByAdminQuery, useUpdateUserMutation } from "../../../app/services/user";
|
||||
|
||||
type Props = {
|
||||
uid: number
|
||||
}
|
||||
|
||||
const WebhookEdit = ({ uid }: Props) => {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [editable, setEditable] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const { data, isSuccess, refetch } = useGetUserByAdminQuery(uid);
|
||||
const [updateUser, { isSuccess: updateSuccess, isLoading: isUpdating }] = useUpdateUserMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess && data) {
|
||||
setUrl(data.webhook_url || "");
|
||||
}
|
||||
}, [data, isSuccess]);
|
||||
useEffect(() => {
|
||||
if (updateSuccess) {
|
||||
refetch();
|
||||
}
|
||||
}, [updateSuccess]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (editable && formRef) {
|
||||
const form = formRef.current;
|
||||
// 检查格式
|
||||
if (!form?.checkValidity()) {
|
||||
form?.reportValidity();
|
||||
return;
|
||||
}
|
||||
// 保存编辑
|
||||
const webhook_url = new FormData(form).get("webhook") as string;
|
||||
const resp = await updateUser({ id: uid, webhook_url });
|
||||
// console.log("ressssss", resp);
|
||||
if ("error" in resp) {
|
||||
switch (resp.error.status) {
|
||||
case 406:
|
||||
toast.error("Not Valid URL!");
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEditable(prev => !prev);
|
||||
};
|
||||
const handleEditable = () => {
|
||||
setEditable(true);
|
||||
};
|
||||
const handleCancelEdit = () => {
|
||||
setEditable(false);
|
||||
const form = formRef.current;
|
||||
if (form) {
|
||||
const input = form.querySelector("input");
|
||||
input!.value = data?.webhook_url || "";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{(url || editable || updateSuccess) ?
|
||||
<div className="flex gap-2">
|
||||
<form action="/" ref={formRef} onSubmit={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleEdit();
|
||||
}}>
|
||||
<input readOnly={!editable} required autoFocus type="url" name='webhook' defaultValue={url} className={clsx("text-sm text-gray-400 dark:text-gray-100 dark:bg-slate-900 px-2 py-1", editable ? "ring-1 ring-gray-500 bg-gray-50" : "bg-transparent")} />
|
||||
</form>
|
||||
<button type='button' disabled={isUpdating} onClick={handleEdit}>
|
||||
{isUpdating ? <Orbit size={16} /> : editable ?
|
||||
<IconSave className="stroke-gray-500 !w-5 !h-5" />
|
||||
|
||||
: <IconEdit className="fill-gray-500 !w-5 !h-5" />}
|
||||
</button>
|
||||
{editable && !isUpdating && <button type='button' disabled={isUpdating} onClick={handleCancelEdit}>
|
||||
<IconCancel className="!w-5 !h-5 fill-gray-500" />
|
||||
</button>}
|
||||
</div>
|
||||
:
|
||||
<button type='button' className="rounded-full bg-primary-50 text-green-600 text-xs py-0.5 px-2" onClick={handleEditable}>
|
||||
Set Webhook
|
||||
</button>}
|
||||
</div>
|
||||
);
|
||||
uid: number;
|
||||
};
|
||||
|
||||
export default WebhookEdit;
|
||||
const WebhookEdit = ({ uid }: Props) => {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [editable, setEditable] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
const { data, isSuccess, refetch } = useGetUserByAdminQuery(uid);
|
||||
const [updateUser, { isSuccess: updateSuccess, isLoading: isUpdating }] = useUpdateUserMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess && data) {
|
||||
setUrl(data.webhook_url || "");
|
||||
}
|
||||
}, [data, isSuccess]);
|
||||
useEffect(() => {
|
||||
if (updateSuccess) {
|
||||
refetch();
|
||||
}
|
||||
}, [updateSuccess]);
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (editable && formRef) {
|
||||
const form = formRef.current;
|
||||
// 检查格式
|
||||
if (!form?.checkValidity()) {
|
||||
form?.reportValidity();
|
||||
return;
|
||||
}
|
||||
// 保存编辑
|
||||
const webhook_url = new FormData(form).get("webhook") as string;
|
||||
const resp = await updateUser({ id: uid, webhook_url });
|
||||
// console.log("ressssss", resp);
|
||||
if ("error" in resp) {
|
||||
switch (resp.error.status) {
|
||||
case 406:
|
||||
toast.error("Not Valid URL!");
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
setEditable((prev) => !prev);
|
||||
};
|
||||
const handleEditable = () => {
|
||||
setEditable(true);
|
||||
};
|
||||
const handleCancelEdit = () => {
|
||||
setEditable(false);
|
||||
const form = formRef.current;
|
||||
if (form) {
|
||||
const input = form.querySelector("input");
|
||||
input!.value = data?.webhook_url || "";
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
{url || editable || updateSuccess ? (
|
||||
<div className="flex gap-2">
|
||||
<form
|
||||
action="/"
|
||||
ref={formRef}
|
||||
onSubmit={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleEdit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
readOnly={!editable}
|
||||
required
|
||||
autoFocus
|
||||
type="url"
|
||||
name="webhook"
|
||||
defaultValue={url}
|
||||
className={clsx(
|
||||
"text-sm text-gray-400 dark:text-gray-100 dark:bg-slate-900 px-2 py-1",
|
||||
editable ? "ring-1 ring-gray-500 bg-gray-50" : "bg-transparent"
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
<button type="button" disabled={isUpdating} onClick={handleEdit}>
|
||||
{isUpdating ? (
|
||||
<Orbit size={16} />
|
||||
) : editable ? (
|
||||
<IconSave className="stroke-gray-500 !w-5 !h-5" />
|
||||
) : (
|
||||
<IconEdit className="fill-gray-500 !w-5 !h-5" />
|
||||
)}
|
||||
</button>
|
||||
{editable && !isUpdating && (
|
||||
<button type="button" disabled={isUpdating} onClick={handleCancelEdit}>
|
||||
<IconCancel className="!w-5 !h-5 fill-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-primary-50 text-green-600 text-xs py-0.5 px-2"
|
||||
onClick={handleEditable}
|
||||
>
|
||||
Set Webhook
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhookEdit;
|
||||
|
||||
@@ -1,69 +1,74 @@
|
||||
import { useRef, useState, useEffect, ChangeEvent } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useUpdateUserMutation } from '../../../app/services/user';
|
||||
import Modal from '../../../components/Modal';
|
||||
import Button from '../../../components/styled/Button';
|
||||
import Input from '../../../components/styled/Input';
|
||||
import StyledModal from '../../../components/styled/Modal';
|
||||
import { ChangeEvent, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUpdateUserMutation } from "../../../app/services/user";
|
||||
import Modal from "../../../components/Modal";
|
||||
import Button from "../../../components/styled/Button";
|
||||
import Input from "../../../components/styled/Input";
|
||||
import StyledModal from "../../../components/styled/Modal";
|
||||
|
||||
type Props = {
|
||||
uid: number,
|
||||
webhook?: string,
|
||||
closeModal: () => void
|
||||
}
|
||||
uid: number;
|
||||
webhook?: string;
|
||||
closeModal: () => void;
|
||||
};
|
||||
const WebhookModal = ({ uid, webhook, closeModal }: Props) => {
|
||||
const [url, setUrl] = useState(webhook);
|
||||
const [updateUser, { isSuccess, isLoading }] = useUpdateUserMutation();
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleUrlChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(evt.target.value);
|
||||
};
|
||||
const handleUpdateWebhook = () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const formEle = formRef.current as HTMLFormElement;
|
||||
if (!formEle.checkValidity()) {
|
||||
formEle.reportValidity();
|
||||
return;
|
||||
}
|
||||
const myFormData = new FormData(formEle);
|
||||
const webhook_url = myFormData.get("webhook")?.toString() || "";
|
||||
updateUser({
|
||||
id: uid,
|
||||
webhook_url
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.update"));
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const [url, setUrl] = useState(webhook);
|
||||
const [updateUser, { isSuccess, isLoading }] = useUpdateUserMutation();
|
||||
const formRef = useRef(null);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "bot" });
|
||||
const { t: ct } = useTranslation();
|
||||
// const [input, setInput] = useState("");
|
||||
const handleUrlChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(evt.target.value);
|
||||
};
|
||||
const handleUpdateWebhook = () => {
|
||||
if (!formRef || !formRef.current) return;
|
||||
const formEle = formRef.current as HTMLFormElement;
|
||||
if (!formEle.checkValidity()) {
|
||||
formEle.reportValidity();
|
||||
return;
|
||||
}
|
||||
const myFormData = new FormData(formEle);
|
||||
const webhook_url = myFormData.get("webhook")?.toString() || "";
|
||||
updateUser({
|
||||
id: uid,
|
||||
webhook_url
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.update"));
|
||||
closeModal();
|
||||
}
|
||||
}, [isSuccess]);
|
||||
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={t("webhook_title")}
|
||||
description={t("webhook_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!url} onClick={handleUpdateWebhook}>{isLoading ? "Updating" : ct("action.done")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
|
||||
<label htmlFor={"webhook"} className="text-sm text-gray-500">Webhook URL</label>
|
||||
<Input name={"webhook"} value={url} onChange={handleUrlChange} type="url"></Input>
|
||||
</form>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal id="modal-modal">
|
||||
<StyledModal
|
||||
title={t("webhook_title")}
|
||||
description={t("webhook_desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal.bind(null, undefined)}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!url} onClick={handleUpdateWebhook}>
|
||||
{isLoading ? "Updating" : ct("action.done")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form ref={formRef} className="w-full flex flex-col gap-2" action="/">
|
||||
<label htmlFor={"webhook"} className="text-sm text-gray-500">
|
||||
Webhook URL
|
||||
</label>
|
||||
<Input name={"webhook"} value={url} onChange={handleUrlChange} type="url"></Input>
|
||||
</form>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebhookModal;
|
||||
export default WebhookModal;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUpdateAvatarByAdminMutation } from "@/app/services/user";
|
||||
@@ -7,12 +7,11 @@ import { useAppSelector } from "@/app/store";
|
||||
import AvatarUploader from "@/components/AvatarUploader";
|
||||
import Button from "@/components/styled/Button";
|
||||
import IconDelete from "@/assets/icons/delete.svg";
|
||||
import BotAPIKeys from "./BotAPIKeys";
|
||||
import CreateModal from "./CreateModal";
|
||||
import DeleteModal from "./DeleteModal";
|
||||
import WebhookEdit from "./WebhookEdit";
|
||||
import WebhookModal from "./WebhookModal";
|
||||
import DeleteModal from "./DeleteModal";
|
||||
import BotAPIKeys from "./BotAPIKeys";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
type TipProps = { title: string; desc: string };
|
||||
const Tip = ({ title, desc }: TipProps) => {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { ChangeEvent, FC, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import * as linkify from 'linkifyjs';
|
||||
import Modal from "@/components/Modal";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import Button from "@/components/styled/Button";
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
import Input from "@/components/styled/Input";
|
||||
|
||||
import { useGetLicensePaymentUrlMutation } from "@/app/services/server";
|
||||
import { getLicensePriceList } from "@/app/config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import dayjs from "dayjs";
|
||||
import { PriceSubscriptionDuration, PriceType } from "@/types/common";
|
||||
import Tippy from "@tippyjs/react";
|
||||
import dayjs from "dayjs";
|
||||
import * as linkify from "linkifyjs";
|
||||
|
||||
import { getLicensePriceList } from "@/app/config";
|
||||
import { useGetLicensePaymentUrlMutation } from "@/app/services/server";
|
||||
import { PriceSubscriptionDuration, PriceType } from "@/types/common";
|
||||
import Modal from "@/components/Modal";
|
||||
import Button from "@/components/styled/Button";
|
||||
import Input from "@/components/styled/Input";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
|
||||
// import { LicenseMetadata, RenewLicense } from "@/types/server";
|
||||
|
||||
interface Props {
|
||||
@@ -36,7 +37,7 @@ const getExpireDay = (sub_dur: PriceSubscriptionDuration) => {
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
};
|
||||
}
|
||||
return res.format("YYYY-MM-DD");
|
||||
};
|
||||
const LicensePriceList = getLicensePriceList();
|
||||
@@ -47,14 +48,21 @@ const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
|
||||
const [host, setHost] = useState(location.hostname);
|
||||
const [popUpVisible, setPopUpVisible] = useState(false);
|
||||
const [selectPrice, setSelectPrice] = useState(
|
||||
`${LicensePriceList[0].pid}|${LicensePriceList[0].limit}|${LicensePriceList[0].type}|${LicensePriceList[0].sub_dur || ""}`
|
||||
`${LicensePriceList[0].pid}|${LicensePriceList[0].limit}|${LicensePriceList[0].type}|${
|
||||
LicensePriceList[0].sub_dur || ""
|
||||
}`
|
||||
);
|
||||
const handleRenew = async () => {
|
||||
if (!linkify.test(host)) {
|
||||
toast.error("Invalid Host");
|
||||
return;
|
||||
}
|
||||
const [priceId, user_limit, type, sub_dur = "month"] = selectPrice.split("|") as [string, string, PriceType, PriceSubscriptionDuration];
|
||||
const [priceId, user_limit, type, sub_dur = "month"] = selectPrice.split("|") as [
|
||||
string,
|
||||
string,
|
||||
PriceType,
|
||||
PriceSubscriptionDuration
|
||||
];
|
||||
const metadata = {
|
||||
user_limit: Number(user_limit),
|
||||
expire: type == "subscription" ? getExpireDay(sub_dur) : getExpireDay("year"),
|
||||
@@ -87,7 +95,7 @@ const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
|
||||
setHost(evt.target.value);
|
||||
};
|
||||
const togglePopUpVisible = () => {
|
||||
setPopUpVisible(prev => !prev);
|
||||
setPopUpVisible((prev) => !prev);
|
||||
};
|
||||
const handleTalk = () => {
|
||||
window.open("https://calendly.com/hansu", "_blank");
|
||||
@@ -105,46 +113,65 @@ const LicensePriceListModal: FC<Props> = ({ closeModal }) => {
|
||||
<Button onClick={closeModal} className="ghost">
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
{isBooking ? <Button onClick={handleTalk}>
|
||||
Booking a meeting!
|
||||
</Button> : <Tippy
|
||||
visible={popUpVisible}
|
||||
interactive
|
||||
placement="top-end"
|
||||
offset={[0, -40]}
|
||||
trigger="click"
|
||||
content={
|
||||
<div className="p-3 rounded-lg border border-solid border-gray-200 dark:border-gray-900 flex flex-col items-start gap-3 w-[380px] bg-white dark:bg-gray-800 shadow shadow-gray-200 dark:shadow-gray-900 drop-shadow-xl">
|
||||
<div className="text-gray-500 text-sm">
|
||||
{t("license.tip_domain")}
|
||||
</div>
|
||||
<Input value={host} onChange={handleUpdateHost} />
|
||||
<div className="flex justify-between items-center w-full mt-4">
|
||||
<span className="text-xs text-orange-500"> {t("license.tip_port")}</span>
|
||||
<div className="flex gap-3">
|
||||
<Button className="mini cancel" onClick={togglePopUpVisible}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button className="mini" disabled={isLoading || isSuccess} onClick={handleRenew}>
|
||||
{isLoading ? "Initialize Payment URL" : isSuccess ? "Redirecting" : t("license.tip_confirm")}
|
||||
</Button>
|
||||
{isBooking ? (
|
||||
<Button onClick={handleTalk}>Booking a meeting!</Button>
|
||||
) : (
|
||||
<Tippy
|
||||
visible={popUpVisible}
|
||||
interactive
|
||||
placement="top-end"
|
||||
offset={[0, -40]}
|
||||
trigger="click"
|
||||
content={
|
||||
<div className="p-3 rounded-lg border border-solid border-gray-200 dark:border-gray-900 flex flex-col items-start gap-3 w-[380px] bg-white dark:bg-gray-800 shadow shadow-gray-200 dark:shadow-gray-900 drop-shadow-xl">
|
||||
<div className="text-gray-500 text-sm">{t("license.tip_domain")}</div>
|
||||
<Input value={host} onChange={handleUpdateHost} />
|
||||
<div className="flex justify-between items-center w-full mt-4">
|
||||
<span className="text-xs text-orange-500"> {t("license.tip_port")}</span>
|
||||
<div className="flex gap-3">
|
||||
<Button className="mini cancel" onClick={togglePopUpVisible}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="mini"
|
||||
disabled={isLoading || isSuccess}
|
||||
onClick={handleRenew}
|
||||
>
|
||||
{isLoading
|
||||
? "Initialize Payment URL"
|
||||
: isSuccess
|
||||
? "Redirecting"
|
||||
: t("license.tip_confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button onClick={togglePopUpVisible} className="text-sm text-white bg-primary-400 break-keep shadow rounded-lg px-3.5 py-2.5 md:hover:bg-primary-500 active:bg-primary-500 disabled:bg-gray-300"> {t("license.renew")}</button>
|
||||
{/* <Button >
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={togglePopUpVisible}
|
||||
className="text-sm text-white bg-primary-400 break-keep shadow rounded-lg px-3.5 py-2.5 md:hover:bg-primary-500 active:bg-primary-500 disabled:bg-gray-300"
|
||||
>
|
||||
{" "}
|
||||
{t("license.renew")}
|
||||
</button>
|
||||
{/* <Button >
|
||||
{t("license.renew")}
|
||||
</Button> */}
|
||||
</Tippy>}
|
||||
|
||||
</Tippy>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<StyledRadio
|
||||
options={LicensePriceList.map(({ title, desc, price }) => `${title} ${desc ? `[${desc}]` : ""}${price ? `[${price}]` : ""}`)}
|
||||
values={LicensePriceList.map(({ pid, limit, type = "payment", sub_dur = "month" }) => `${pid}|${limit}|${type}|${sub_dur}`)}
|
||||
options={LicensePriceList.map(
|
||||
({ title, desc, price }) =>
|
||||
`${title} ${desc ? `[${desc}]` : ""}${price ? `[${price}]` : ""}`
|
||||
)}
|
||||
values={LicensePriceList.map(
|
||||
({ pid, limit, type = "payment", sub_dur = "month" }) =>
|
||||
`${pid}|${limit}|${type}|${sub_dur}`
|
||||
)}
|
||||
value={selectPrice}
|
||||
onChange={(v) => {
|
||||
handlePriceSelect(v);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { ChangeEvent, FC, useState, useEffect } from "react";
|
||||
import { ChangeEvent, FC, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Modal from "@/components/Modal";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import Button from "@/components/styled/Button";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import Textarea from "@/components/styled/Textarea";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
interface Props {
|
||||
closeModal: () => void;
|
||||
updateLicense: (param: string) => Promise<any>;
|
||||
updated: boolean;
|
||||
updating: boolean
|
||||
updating: boolean;
|
||||
// domain: string;
|
||||
}
|
||||
|
||||
@@ -43,13 +44,18 @@ const UpdateLicenseModal: FC<Props> = ({ closeModal, updateLicense, updating, up
|
||||
<Button onClick={closeModal} className="ghost">
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button disabled={updating || updated || !value} onClick={handleRenew} >
|
||||
<Button disabled={updating || updated || !value} onClick={handleRenew}>
|
||||
{updating ? "Updating" : updated ? "Update Successfully" : t("license.update")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Textarea rows={18} placeholder={t("license.update_placeholder")} value={value} onChange={handleLicenseUpdate} />
|
||||
<Textarea
|
||||
rows={18}
|
||||
placeholder={t("license.update_placeholder")}
|
||||
value={value}
|
||||
onChange={handleLicenseUpdate}
|
||||
/>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
import { useState, HTMLAttributes } from "react";
|
||||
import { HTMLAttributes, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import Button from "@/components/styled/Button";
|
||||
import useLicense from "@/hooks/useLicense";
|
||||
import LicensePriceListModal from "./LicensePriceListModal";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UpdateLicenseModal from "./UpdateLicenseModal";
|
||||
// import Tippy from "@tippyjs/react";
|
||||
|
||||
interface ItemProps extends HTMLAttributes<HTMLSpanElement> { label: string, data?: string | number | string[], foldable?: boolean }
|
||||
interface ItemProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
label: string;
|
||||
data?: string | number | string[];
|
||||
foldable?: boolean;
|
||||
}
|
||||
const Item = ({ label, data, foldable = false, ...rest }: ItemProps) => {
|
||||
const infoClass = clsx("font-bold w-full cursor-pointer dark:text-green-500", foldable ? "truncate" : "whitespace-pre-wrap break-all");
|
||||
const infoClass = clsx(
|
||||
"font-bold w-full cursor-pointer dark:text-green-500",
|
||||
foldable ? "truncate" : "whitespace-pre-wrap break-all"
|
||||
);
|
||||
if (!data) return null;
|
||||
return <div className="whitespace-nowrap flex flex-col items-start justify-start text-lg">
|
||||
<span className="text-sm text-green-500">{label}</span>
|
||||
{Array.isArray(data) ? <ul className={infoClass}>
|
||||
{data.map((d) => {
|
||||
return <li key={d}>{d}</li>;
|
||||
})}
|
||||
</ul> : <span className={infoClass} {...rest}>
|
||||
{data}
|
||||
</span>}
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div className="whitespace-nowrap flex flex-col items-start justify-start text-lg">
|
||||
<span className="text-sm text-green-500">{label}</span>
|
||||
{Array.isArray(data) ? (
|
||||
<ul className={infoClass}>
|
||||
{data.map((d) => {
|
||||
return <li key={d}>{d}</li>;
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<span className={infoClass} {...rest}>
|
||||
{data}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default function License() {
|
||||
const { t, i18n } = useTranslation("setting");
|
||||
@@ -38,43 +50,71 @@ export default function License() {
|
||||
setUpdateVisible((prev) => !prev);
|
||||
};
|
||||
const handleLicenseValueToggle = () => {
|
||||
setBase58Fold(pre => !pre);
|
||||
setBase58Fold((pre) => !pre);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="max-w-3xl flex flex-col justify-start items-start gap-4">
|
||||
<div className={clsx('relative w-full p-3 rounded border-solid border flex flex-col gap-4 shadow', reachLimit ? "border-red-600 bg-red-200/50" : "border-green-600 bg-green-100 dark:bg-green-900")}>
|
||||
<div
|
||||
className={clsx(
|
||||
"relative w-full p-3 rounded border-solid border flex flex-col gap-4 shadow",
|
||||
reachLimit
|
||||
? "border-red-600 bg-red-200/50"
|
||||
: "border-green-600 bg-green-100 dark:bg-green-900"
|
||||
)}
|
||||
>
|
||||
<Item label={t("license.signed")} data={licenseInfo?.sign ? "Yes" : "Not Yet"} />
|
||||
<Item label={t("license.domain")} data={licenseInfo?.domains} />
|
||||
<Item label={t("license.user_limit")} data={(licenseInfo?.user_limit ?? 0) >= 999999 ? "No Limit" : licenseInfo?.user_limit} />
|
||||
<Item label={t("license.expire")} data={dayjs(licenseInfo?.expired_at).format("YYYY-MM-DD h:mm:ss A")} />
|
||||
<Item label={t("license.create")} data={dayjs(licenseInfo?.created_at).format("YYYY-MM-DD h:mm:ss A")} />
|
||||
<Item label={t("license.value")} data={licenseInfo?.base58} foldable={base58Fold} title={base58Fold ? "Click to see full text" : "Click to fold text"}
|
||||
onClick={handleLicenseValueToggle} />
|
||||
<Item
|
||||
label={t("license.user_limit")}
|
||||
data={(licenseInfo?.user_limit ?? 0) >= 999999 ? "No Limit" : licenseInfo?.user_limit}
|
||||
/>
|
||||
<Item
|
||||
label={t("license.expire")}
|
||||
data={dayjs(licenseInfo?.expired_at).format("YYYY-MM-DD h:mm:ss A")}
|
||||
/>
|
||||
<Item
|
||||
label={t("license.create")}
|
||||
data={dayjs(licenseInfo?.created_at).format("YYYY-MM-DD h:mm:ss A")}
|
||||
/>
|
||||
<Item
|
||||
label={t("license.value")}
|
||||
data={licenseInfo?.base58}
|
||||
foldable={base58Fold}
|
||||
title={base58Fold ? "Click to see full text" : "Click to fold text"}
|
||||
onClick={handleLicenseValueToggle}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={toggleModalVisible}>{t("license.renew")}</Button>
|
||||
<Button onClick={toggleUpdateModalVisible} className="ghost">{t("license.update")}</Button>
|
||||
<Button onClick={toggleUpdateModalVisible} className="ghost">
|
||||
{t("license.update")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 bg-primary-500 text-white rounded drop-shadow-xl p-5">
|
||||
<h2 className="text-2xl font-bold">{t("license.tip.title")} 🎁</h2>
|
||||
<p className="flex flex-col">
|
||||
<span>
|
||||
{t("license.tip.user_test")}
|
||||
</span>
|
||||
<span>{t("license.tip.user_test")}</span>
|
||||
<span>
|
||||
{t("license.tip.contact")}
|
||||
{i18n.language.startsWith("zh") ? "Privoce" : <a className="underline text-lg text-green-200" href="https://calendly.com/hansu/han-meeting" target="_blank" rel="noopener noreferrer">https://calendly.com/hansu/han-meeting</a>}
|
||||
{i18n.language.startsWith("zh") ? (
|
||||
"Privoce"
|
||||
) : (
|
||||
<a
|
||||
className="underline text-lg text-green-200"
|
||||
href="https://calendly.com/hansu/han-meeting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
https://calendly.com/hansu/han-meeting
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{modalVisible && (
|
||||
<LicensePriceListModal
|
||||
closeModal={toggleModalVisible}
|
||||
/>
|
||||
)}
|
||||
{modalVisible && <LicensePriceListModal closeModal={toggleModalVisible} />}
|
||||
{updateVisible && (
|
||||
<UpdateLicenseModal
|
||||
updated={upserted}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ChangeEvent, FC, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Modal from "@/components/Modal";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import Button from "@/components/styled/Button";
|
||||
import Checkbox from "@/components/styled/Checkbox";
|
||||
import useLogout from "@/hooks/useLogout";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Modal from "@/components/Modal";
|
||||
import Button from "@/components/styled/Button";
|
||||
import Checkbox from "@/components/styled/Checkbox";
|
||||
import StyledModal from "@/components/styled/Modal";
|
||||
import useLogout from "@/hooks/useLogout";
|
||||
|
||||
interface Props {
|
||||
closeModal: () => void;
|
||||
@@ -41,7 +41,9 @@ const LogoutConfirmModal: FC<Props> = ({ closeModal }) => {
|
||||
description={t("logout.desc")}
|
||||
buttons={
|
||||
<>
|
||||
<Button className="cancel" onClick={closeModal}>{ct("action.cancel")}</Button>
|
||||
<Button className="cancel" onClick={closeModal}>
|
||||
{ct("action.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleLogout} className="danger">
|
||||
{exiting ? "Logging out" : ct("action.logout")}
|
||||
</Button>
|
||||
@@ -52,7 +54,12 @@ const LogoutConfirmModal: FC<Props> = ({ closeModal }) => {
|
||||
<label htmlFor="clear_cb" className="cursor-pointer text-orange-500 mr-3">
|
||||
{t("logout.clear_local")}
|
||||
</label>
|
||||
<Checkbox className=" cursor-pointer" name="clear_cb" checked={clearLocal} onChange={handleCheck} />
|
||||
<Checkbox
|
||||
className=" cursor-pointer"
|
||||
name="clear_cb"
|
||||
checked={clearLocal}
|
||||
onChange={handleCheck}
|
||||
/>
|
||||
</div>
|
||||
</StyledModal>
|
||||
</Modal>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useEffect, useState, MouseEvent } from "react";
|
||||
import { MouseEvent, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUpdateAvatarMutation } from "@/app/services/user";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import AvatarUploader from "@/components/AvatarUploader";
|
||||
import Button from "@/components/styled/Button";
|
||||
import ProfileBasicEditModal from "./ProfileBasicEditModal";
|
||||
import RemoveAccountConfirmModal from "./RemoveAccountConfirmModal";
|
||||
import UpdatePasswordModal from "./UpdatePasswordModal";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type EditField = "name" | "email" | "";
|
||||
export default function MyAccount() {
|
||||
@@ -91,9 +92,7 @@ export default function MyAccount() {
|
||||
<span className="text-xs uppercase font-semibold">{t("password")}</span>
|
||||
<span className="text-sm">*********</span>
|
||||
</div>
|
||||
<Button onClick={togglePasswordModal}>
|
||||
{ct("action.edit")}
|
||||
</Button>
|
||||
<Button onClick={togglePasswordModal}>{ct("action.edit")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* uid 1 是初始账户,不能删 */}
|
||||
|
||||
@@ -1,40 +1,45 @@
|
||||
// import React from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from '../../../app/services/server';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import SettingBlock from './SettingBlock';
|
||||
import { ChatLayout } from '../../../types/server';
|
||||
import {
|
||||
useGetSystemCommonQuery,
|
||||
useUpdateSystemCommonMutation
|
||||
} from "../../../app/services/server";
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import { ChatLayout } from "../../../types/server";
|
||||
import SettingBlock from "./SettingBlock";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const Index = () => {
|
||||
const currStatus = useAppSelector(store => store.server.chat_layout_mode ?? "Left");
|
||||
const { t } = useTranslation("setting", { keyPrefix: "overview.chat_layout" });
|
||||
const { t: ct } = useTranslation();
|
||||
const { refetch } = useGetSystemCommonQuery();
|
||||
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refetch();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const handleChange = (newVal: ChatLayout) => {
|
||||
updateSetting({ chat_layout_mode: newVal });
|
||||
};
|
||||
// if (!loadSuccess) return null;
|
||||
return (
|
||||
<SettingBlock title={t("title")} desc={t('desc')} >
|
||||
<StyledRadio
|
||||
options={[t("left"), t("self_right")]}
|
||||
values={["Left", "SelfRight"]}
|
||||
value={currStatus}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
const currStatus = useAppSelector((store) => store.server.chat_layout_mode ?? "Left");
|
||||
const { t } = useTranslation("setting", { keyPrefix: "overview.chat_layout" });
|
||||
const { t: ct } = useTranslation();
|
||||
const { refetch } = useGetSystemCommonQuery();
|
||||
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refetch();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const handleChange = (newVal: ChatLayout) => {
|
||||
updateSetting({ chat_layout_mode: newVal });
|
||||
};
|
||||
// if (!loadSuccess) return null;
|
||||
return (
|
||||
<SettingBlock title={t("title")} desc={t("desc")}>
|
||||
<StyledRadio
|
||||
options={[t("left"), t("self_right")]}
|
||||
values={["Left", "SelfRight"]}
|
||||
value={currStatus}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default Index;
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
// import React from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from '../../../app/services/server';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import SettingBlock from './SettingBlock';
|
||||
import {
|
||||
useGetSystemCommonQuery,
|
||||
useUpdateSystemCommonMutation
|
||||
} from "../../../app/services/server";
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import SettingBlock from "./SettingBlock";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const Index = () => {
|
||||
const currStatus = useAppSelector(store => !!store.server.contact_verification_enable);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "overview.contact_verify" });
|
||||
const { t: ct } = useTranslation();
|
||||
const { refetch } = useGetSystemCommonQuery();
|
||||
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refetch();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const handleToggle = () => {
|
||||
updateSetting({ contact_verification_enable: !currStatus });
|
||||
};
|
||||
// if (!loadSuccess) return null;
|
||||
return (
|
||||
<SettingBlock title={t("title")} desc={t('desc')} >
|
||||
<StyledRadio
|
||||
options={[t("enable"), t("disable")]}
|
||||
values={["true", "false"]}
|
||||
value={`${currStatus}`}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
const currStatus = useAppSelector((store) => !!store.server.contact_verification_enable);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "overview.contact_verify" });
|
||||
const { t: ct } = useTranslation();
|
||||
const { refetch } = useGetSystemCommonQuery();
|
||||
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refetch();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const handleToggle = () => {
|
||||
updateSetting({ contact_verification_enable: !currStatus });
|
||||
};
|
||||
// if (!loadSuccess) return null;
|
||||
return (
|
||||
<SettingBlock title={t("title")} desc={t("desc")}>
|
||||
<StyledRadio
|
||||
options={[t("enable"), t("disable")]}
|
||||
values={["true", "false"]}
|
||||
value={`${currStatus}`}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default Index;
|
||||
|
||||
@@ -1,39 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Radio from '../../../components/styled/Radio';
|
||||
import { Theme } from '../../../types/common';
|
||||
import SettingBlock from './SettingBlock';
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Radio from "../../../components/styled/Radio";
|
||||
import { Theme } from "../../../types/common";
|
||||
import SettingBlock from "./SettingBlock";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const DarkMode = () => {
|
||||
const [theme, setTheme] = useState<Theme>(localStorage.theme || "auto");
|
||||
const { t } = useTranslation("setting");
|
||||
const handleThemeToggle = (v: Theme) => {
|
||||
setTheme(v);
|
||||
localStorage.theme = v;
|
||||
// reset
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.remove("light");
|
||||
if (v !== "auto") {
|
||||
document.documentElement.classList.add(v);
|
||||
} else {
|
||||
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.classList.add(isDark ? "dark" : 'light');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SettingBlock title={t("overview.theme.title")} desc={t("overview.theme.desc")}>
|
||||
<Radio
|
||||
options={[t("overview.theme.auto"), t("overview.theme.dark"), t("overview.theme.light")]}
|
||||
values={['auto', 'dark', 'light']}
|
||||
value={theme}
|
||||
onChange={(v) => {
|
||||
handleThemeToggle(v);
|
||||
}}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
const [theme, setTheme] = useState<Theme>(localStorage.theme || "auto");
|
||||
const { t } = useTranslation("setting");
|
||||
const handleThemeToggle = (v: Theme) => {
|
||||
setTheme(v);
|
||||
localStorage.theme = v;
|
||||
// reset
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.remove("light");
|
||||
if (v !== "auto") {
|
||||
document.documentElement.classList.add(v);
|
||||
} else {
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.classList.add(isDark ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<SettingBlock title={t("overview.theme.title")} desc={t("overview.theme.desc")}>
|
||||
<Radio
|
||||
options={[t("overview.theme.auto"), t("overview.theme.dark"), t("overview.theme.light")]}
|
||||
values={["auto", "dark", "light"]}
|
||||
value={theme}
|
||||
onChange={(v) => {
|
||||
handleThemeToggle(v);
|
||||
}}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default DarkMode;
|
||||
export default DarkMode;
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
// import React from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from '../../../app/services/server';
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import StyledButton from "@/components/styled/Button";
|
||||
import { ChangeEvent, useState, useEffect } from 'react';
|
||||
import SettingBlock from './SettingBlock';
|
||||
import StyledInput from "@/components/styled/Input";
|
||||
import { useGetFrontendUrlQuery, useUpdateFrontendUrlMutation } from "../../../app/services/server";
|
||||
import SettingBlock from "./SettingBlock";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const Index = () => {
|
||||
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
|
||||
const [url, setUrl] = useState(location.origin);
|
||||
const { t } = useTranslation("setting");
|
||||
const { t: ct } = useTranslation();
|
||||
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
// update
|
||||
setUrl(evt.target.value);
|
||||
};
|
||||
const handleUpdate = () => {
|
||||
updateUrl(url);
|
||||
};
|
||||
const [updateUrl, { isLoading, isSuccess }] = useUpdateFrontendUrlMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
useEffect(() => {
|
||||
if (getUrlSuccess && data) {
|
||||
setUrl(data);
|
||||
}
|
||||
}, [getUrlSuccess, data]);
|
||||
// if(!fetch)
|
||||
return (
|
||||
<SettingBlock title={t("overview.url.title")} desc={t("overview.url.desc")}>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<StyledInput placeholder='frontend url' value={url} onChange={handleChange} />
|
||||
<StyledButton disabled={!url || isLoading} className='small' onClick={handleUpdate}> {ct("action.update")}</StyledButton>
|
||||
</div>
|
||||
</SettingBlock>
|
||||
);
|
||||
const { data, isSuccess: getUrlSuccess } = useGetFrontendUrlQuery();
|
||||
const [url, setUrl] = useState(location.origin);
|
||||
const { t } = useTranslation("setting");
|
||||
const { t: ct } = useTranslation();
|
||||
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
|
||||
// update
|
||||
setUrl(evt.target.value);
|
||||
};
|
||||
const handleUpdate = () => {
|
||||
updateUrl(url);
|
||||
};
|
||||
const [updateUrl, { isLoading, isSuccess }] = useUpdateFrontendUrlMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
useEffect(() => {
|
||||
if (getUrlSuccess && data) {
|
||||
setUrl(data);
|
||||
}
|
||||
}, [getUrlSuccess, data]);
|
||||
// if(!fetch)
|
||||
return (
|
||||
<SettingBlock title={t("overview.url.title")} desc={t("overview.url.desc")}>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<StyledInput placeholder="frontend url" value={url} onChange={handleChange} />
|
||||
<StyledButton disabled={!url || isLoading} className="small" onClick={handleUpdate}>
|
||||
{" "}
|
||||
{ct("action.update")}
|
||||
</StyledButton>
|
||||
</div>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default Index;
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
// import React from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
import SettingBlock from './SettingBlock';
|
||||
import SettingBlock from "./SettingBlock";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const Index = () => {
|
||||
const { t, i18n } = useTranslation("setting");
|
||||
const handleGuestToggle = (v: "zh" | "en") => {
|
||||
i18n.changeLanguage(v);
|
||||
};
|
||||
return (
|
||||
<SettingBlock title={t("overview.lang.title")} desc={t("overview.lang.desc")}>
|
||||
<StyledRadio
|
||||
options={[t("overview.lang.en"), t("overview.lang.zh"), t("overview.lang.jp"), t("overview.lang.tr")]}
|
||||
values={["en", "zh", "jp", "tr"]}
|
||||
value={i18n.language.split("-")[0]}
|
||||
onChange={(v) => {
|
||||
console.log("wtff", v);
|
||||
const { t, i18n } = useTranslation("setting");
|
||||
const handleGuestToggle = (v: "zh" | "en") => {
|
||||
i18n.changeLanguage(v);
|
||||
};
|
||||
return (
|
||||
<SettingBlock title={t("overview.lang.title")} desc={t("overview.lang.desc")}>
|
||||
<StyledRadio
|
||||
options={[
|
||||
t("overview.lang.en"),
|
||||
t("overview.lang.zh"),
|
||||
t("overview.lang.jp"),
|
||||
t("overview.lang.tr")
|
||||
]}
|
||||
values={["en", "zh", "jp", "tr"]}
|
||||
value={i18n.language.split("-")[0]}
|
||||
onChange={(v) => {
|
||||
console.log("wtff", v);
|
||||
|
||||
handleGuestToggle(v);
|
||||
}}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
handleGuestToggle(v);
|
||||
}}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default Index;
|
||||
|
||||
@@ -1,39 +1,44 @@
|
||||
// import React from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetSystemCommonQuery, useUpdateSystemCommonMutation } from '../../../app/services/server';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import StyledRadio from "@/components/styled/Radio";
|
||||
import { useAppSelector } from '../../../app/store';
|
||||
import SettingBlock from './SettingBlock';
|
||||
import {
|
||||
useGetSystemCommonQuery,
|
||||
useUpdateSystemCommonMutation
|
||||
} from "../../../app/services/server";
|
||||
import { useAppSelector } from "../../../app/store";
|
||||
import SettingBlock from "./SettingBlock";
|
||||
|
||||
// type Props = {}
|
||||
|
||||
const Index = () => {
|
||||
const currStatus = useAppSelector(store => !!store.server.show_user_online_status);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "overview.online_status" });
|
||||
const { t: ct } = useTranslation();
|
||||
const { refetch } = useGetSystemCommonQuery();
|
||||
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refetch();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const handleToggle = () => {
|
||||
updateSetting({ show_user_online_status: !currStatus });
|
||||
};
|
||||
// if (!loadSuccess) return null;
|
||||
return (
|
||||
<SettingBlock title={t("title")} desc={t('desc')} >
|
||||
<StyledRadio
|
||||
options={[t("enable"), t("disable")]}
|
||||
values={["true", "false"]}
|
||||
value={`${currStatus}`}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
const currStatus = useAppSelector((store) => !!store.server.show_user_online_status);
|
||||
const { t } = useTranslation("setting", { keyPrefix: "overview.online_status" });
|
||||
const { t: ct } = useTranslation();
|
||||
const { refetch } = useGetSystemCommonQuery();
|
||||
const [updateSetting, { isSuccess }] = useUpdateSystemCommonMutation();
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
refetch();
|
||||
toast.success(ct("tip.update"));
|
||||
}
|
||||
}, [isSuccess]);
|
||||
const handleToggle = () => {
|
||||
updateSetting({ show_user_online_status: !currStatus });
|
||||
};
|
||||
// if (!loadSuccess) return null;
|
||||
return (
|
||||
<SettingBlock title={t("title")} desc={t("desc")}>
|
||||
<StyledRadio
|
||||
options={[t("enable"), t("disable")]}
|
||||
values={["true", "false"]}
|
||||
value={`${currStatus}`}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
</SettingBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default Index;
|
||||
export default Index;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { ChangeEvent, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useUpdateLogoMutation, useUpdateServerMutation } from "@/app/services/server";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
import LogoUploader from "@/components/AvatarUploader";
|
||||
import SaveTip from "@/components/SaveTip";
|
||||
import Input from "@/components/styled/Input";
|
||||
import Label from "@/components/styled/Label";
|
||||
import Textarea from "@/components/styled/Textarea";
|
||||
import SaveTip from "@/components/SaveTip";
|
||||
import { useAppSelector } from "@/app/store";
|
||||
|
||||
const Index = () => {
|
||||
const { t } = useTranslation("setting");
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
title: string,
|
||||
desc: string,
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const SettingBlock = ({ title, desc, children }: Props) => {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-100 font-semibold">{title}</p>
|
||||
<p className="flex justify-between w-full text-gray-400 mb-2 text-xs">
|
||||
{desc}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
title: string;
|
||||
desc: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default SettingBlock;
|
||||
const SettingBlock = ({ title, desc, children }: Props) => {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<p className="text-gray-600 dark:text-gray-100 font-semibold">{title}</p>
|
||||
<p className="flex justify-between w-full text-gray-400 mb-2 text-xs">{desc}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingBlock;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user