feat: chat panel UE done

This commit is contained in:
butterfly
2024-04-18 12:27:44 +08:00
parent 51a1d9f92a
commit b3559f99a2
39 changed files with 953 additions and 447 deletions

View File

@@ -0,0 +1,118 @@
import { isValidElement } from "react";
type IconMap = {
active?: JSX.Element;
inactive?: JSX.Element;
mobileActive?: JSX.Element;
mobileInactive?: JSX.Element;
};
interface Action {
id: string;
title?: string;
icons: JSX.Element | IconMap;
className?: string;
onClick?: () => void;
}
type Groups = {
normal: string[][];
mobile: string[][];
};
export interface ActionsBarProps {
actionsShema: Action[];
onSelect?: (id: string) => void;
selected?: string;
groups: string[][] | Groups;
className?: string;
inMobile?: boolean;
}
export default function ActionsBar(props: ActionsBarProps) {
const { actionsShema, onSelect, selected, groups, className, inMobile } =
props;
const handlerClick =
(action: Action) => (e: { preventDefault: () => void }) => {
e.preventDefault();
if (action.onClick) {
action.onClick();
}
if (selected !== action.id) {
onSelect?.(action.id);
}
};
const internalGroup = Array.isArray(groups)
? groups
: inMobile
? groups.mobile
: groups.normal;
const content = internalGroup.reduce((res, group, ind, arr) => {
res.push(
...group.map((i) => {
const action = actionsShema.find((a) => a.id === i);
if (!action) {
return <></>;
}
const { icons } = action;
let activeIcon, inactiveIcon, mobileActiveIcon, mobileInactiveIcon;
if (isValidElement(icons)) {
activeIcon = icons;
inactiveIcon = icons;
mobileActiveIcon = icons;
mobileInactiveIcon = icons;
} else {
activeIcon = (icons as IconMap).active;
inactiveIcon = (icons as IconMap).inactive;
mobileActiveIcon = (icons as IconMap).mobileActive;
mobileInactiveIcon = (icons as IconMap).mobileInactive;
}
if (inMobile) {
return (
<div
key={action.id}
className={` shrink-1 grow-0 basis-[${
(100 - 1) / arr.length
}%] flex flex-col items-center justify-center gap-0.5
${
selected === action.id
? "text-blue-700"
: "text-gray-400"
}
`}
onClick={handlerClick(action)}
>
{selected === action.id ? mobileActiveIcon : mobileInactiveIcon}
<div className=" leading-3 text-sm-mobile-tab h-3 font-common w-[100%]">
{action.title || " "}
</div>
</div>
);
}
return (
<div
key={action.id}
className={`p-3 ${
selected === action.id ? "bg-blue-900" : "bg-transparent"
} rounded-md items-center ${action.className}`}
onClick={handlerClick(action)}
>
{selected === action.id ? activeIcon : inactiveIcon}
</div>
);
}),
);
if (ind < arr.length - 1) {
res.push(<div key={String(ind)} className=" flex-1"></div>);
}
return res;
}, [] as JSX.Element[]);
return <div className={`flex items-center ${className} `}>{content}</div>;
}

View File

@@ -0,0 +1,60 @@
import * as React from "react";
export type ButtonType = "primary" | "danger" | null;
export default function IconButton(props: {
onClick?: () => void;
icon?: JSX.Element;
type?: ButtonType;
text?: string;
bordered?: boolean;
shadow?: boolean;
className?: string;
title?: string;
disabled?: boolean;
tabIndex?: number;
autoFocus?: boolean;
}) {
const {
onClick,
icon,
type,
text,
bordered,
shadow,
className,
title,
disabled,
tabIndex,
autoFocus,
} = props;
return (
<button
className={`
${className ?? ""}
py-2 px-3 flex items-center justify-center gap-1 rounded-action-btn shadow-btn transition-all duration-300 select-none
${
type === "primary"
? `${disabled ? "bg-blue-300" : "bg-blue-600"}`
: `${disabled ? "bg-gray-100" : "bg-gray-300"}`
}
${disabled ? "cursor-not-allowed" : "cursor-pointer"}
${type === "primary" ? `text-white` : `text-gray-500`}
`}
onClick={onClick}
title={title}
disabled={disabled}
role="button"
tabIndex={tabIndex}
autoFocus={autoFocus}
>
{text && (
<div className={`text-common text-sm-title leading-4 line-clamp-1`}>
{text}
</div>
)}
{icon && <div className={`flex items-center justify-center`}>{icon}</div>}
</button>
);
}

View File

@@ -1,8 +0,0 @@
.loading-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
}

View File

@@ -1,12 +1,33 @@
import useMobileScreen from "@/app/hooks/useMobileScreen";
import BotIcon from "@/app/icons/bot.svg";
import LoadingIcon from "@/app/icons/three-dots.svg";
import styles from "./index.module.scss";
import { getCSSVar } from "@/app/utils";
export default function Loading({
noLogo,
useSkeleton = true,
}: {
noLogo?: boolean;
useSkeleton?: boolean;
}) {
let theme;
if (typeof window !== "undefined") {
theme = getCSSVar("--chat-panel-bg");
}
const isMobileScreen = useMobileScreen();
export default function Loading(props: { noLogo?: boolean }) {
return (
<div className={styles["loading-content"] + " no-dark"}>
{!props.noLogo && <BotIcon />}
<div
className={`flex flex-col justify-center items-center w-[100%] ${
isMobileScreen
? "h-[100%]"
: `my-2.5 ml-1 mr-2.5 rounded-md h-[calc(100%-1.25rem)]`
}`}
style={{ background: useSkeleton ? theme : "" }}
>
{!noLogo && <BotIcon />}
<LoadingIcon />
</div>
);

View File

@@ -1,4 +1,24 @@
import { useState } from "react";
import { getCSSVar } from "@/app/utils";
import { useMemo, useState } from "react";
const ArrowIcon = ({ color }: { color: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="6"
viewBox="0 0 16 6"
fill="none"
>
<path
d="M16 0H0C1.28058 0 2.50871 0.508709 3.41421 1.41421L6.91 4.91C7.51199 5.51199 8.48801 5.51199 9.09 4.91L12.5858 1.41421C13.4913 0.508708 14.7194 0 16 0Z"
fill={color}
/>
</svg>
);
};
const baseZIndex = 100;
export default function Popover(props: {
content?: JSX.Element | string;
@@ -10,6 +30,7 @@ export default function Popover(props: {
trigger?: "hover" | "click";
placement?: "t" | "lt" | "rt" | "lb" | "rb" | "b";
noArrow?: boolean;
bgcolor?: string;
}) {
const {
content,
@@ -21,6 +42,7 @@ export default function Popover(props: {
trigger = "hover",
placement = "t",
noArrow = false,
bgcolor,
} = props;
const [internalShow, setShow] = useState(false);
@@ -28,14 +50,15 @@ export default function Popover(props: {
const mergedShow = show ?? internalShow;
let placementClassName;
let arrowClassName =
"rotate-45 w-[8.5px] h-[8.5px] left-[50%] translate-x-[calc(-50%)] bg-black rounded-[1px] ";
let arrowClassName = "absolute left-[50%] translate-x-[calc(-50%)]";
// "absolute rotate-45 w-[8.5px] h-[8.5px] left-[50%] translate-x-[calc(-50%)] bg-black rounded-[1px] ";
arrowClassName += " ";
switch (placement) {
case "b":
placementClassName =
"bottom-[calc(-100%-0.5rem)] left-[50%] translate-x-[calc(-50%)]";
arrowClassName += "bottom-[-5px] ";
"top-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
break;
// case 'l':
// placementClassName = '';
@@ -44,28 +67,28 @@ export default function Popover(props: {
// placementClassName = '';
// break;
case "rb":
placementClassName = "bottom-[calc(-100%-0.5rem)]";
arrowClassName += "bottom-[-5px] ";
placementClassName = "top-[calc(100%+0.5rem)] translate-x-[calc(-2%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
break;
case "lt":
placementClassName =
"top-[calc(-100%-0.5rem)] left-[100%] translate-x-[calc(-100%)]";
arrowClassName += "top-[-5px] ";
"bottom-[calc(100%+0.5rem)] left-[100%] translate-x-[calc(-98%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
break;
case "lb":
placementClassName =
"bottom-[calc(-100%-0.5rem)] left-[100%] translate-x-[calc(-100%)]";
arrowClassName += "bottom-[-5px] ";
"top-[calc(100%+0.5rem)] left-[100%] translate-x-[calc(-98%)]";
arrowClassName += "top-[calc(100%+0.5rem)] translate-y-[calc(-100%)]";
break;
case "rt":
placementClassName = "top-[calc(-100%-0.5rem)]";
arrowClassName += "top-[-5px] ";
placementClassName = "bottom-[calc(100%+0.5rem)] translate-x-[calc(-2%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
break;
case "t":
default:
placementClassName =
"top-[calc(-100%-0.5rem)] left-[50%] translate-x-[calc(-50%)]";
arrowClassName += "top-[-5px] ";
"bottom-[calc(100%+0.5rem)] left-[50%] translate-x-[calc(-50%)]";
arrowClassName += "bottom-[calc(100%+0.5rem)] translate-y-[calc(100%)]";
}
const popoverCommonClass = "absolute p-2 box-border";
@@ -74,6 +97,10 @@ export default function Popover(props: {
arrowClassName = "hidden";
}
const internalBgColor = useMemo(() => {
return bgcolor ?? getCSSVar("--tip-popover-color");
}, [bgcolor]);
if (trigger === "click") {
return (
<div
@@ -88,13 +115,27 @@ export default function Popover(props: {
{mergedShow && (
<>
{!noArrow && (
<div className={`absolute ${arrowClassName}`}>&nbsp;</div>
<div className={`${arrowClassName}`}>
<ArrowIcon color={internalBgColor} />
</div>
)}
<div
className={`${popoverCommonClass} ${placementClassName} ${popoverClassName}`}
style={{ zIndex: baseZIndex + 1 }}
>
{content}
</div>
<div
className=" fixed w-[100%] h-[100%] top-0 left-0 right-0 bottom-0"
style={{ zIndex: baseZIndex }}
onClick={(e) => {
e.preventDefault();
onShow?.(!mergedShow);
setShow(!mergedShow);
}}
>
&nbsp;
</div>
</>
)}
</div>
@@ -105,8 +146,8 @@ export default function Popover(props: {
<div className={`group relative ${className}`}>
{children}
{!noArrow && (
<div className={`hidden group-hover:block absolute ${arrowClassName}`}>
&nbsp;
<div className={`hidden group-hover:block ${arrowClassName}`}>
<ArrowIcon color={internalBgColor} />
</div>
)}
<div

View File

@@ -1,6 +1,6 @@
import { useLocation } from "react-router-dom";
import { useMemo, ReactNode, useLayoutEffect } from "react";
import { DEFAULT_SIDEBAR_WIDTH, Path, SlotID } from "@/app/constant";
import { useMemo, ReactNode } from "react";
import { Path, SlotID } from "@/app/constant";
import { getLang } from "@/app/locales";
import useMobileScreen from "@/app/hooks/useMobileScreen";
@@ -29,7 +29,7 @@ export default function Screen(props: ScreenProps) {
useListenWinResize();
let containerClassName = "flex h-[100%] w-[100%]";
let pageClassName = "flex-1 h-[100%]";
let pageClassName = "flex-1 h-[100%] w-page";
let sidebarClassName = "basis-sidebar h-[100%]";
if (isMobileScreen) {

View File

@@ -0,0 +1,27 @@
import ImgDeleteIcon from "@/app/icons/imgDeleteIcon.svg";
export interface ThumbnailProps {
image: string;
deleteImage: () => void;
}
export default function Thumbnail(props: ThumbnailProps) {
const { image, deleteImage } = props;
return (
<div
className={` h-thumbnail w-thumbnail cursor-default border-1 border-black border-opacity-10 rounded-action-btn flex-0 bg-cover bg-center`}
style={{ backgroundImage: `url("${image}")` }}
>
<div
className={` w-[100%] h-[100%] opacity-0 transition-all duration-200 rounded-action-btn hover:opacity-100 hover:bg-thumbnail-mask`}
>
<div
className={`cursor-pointer flex items-center justify-center float-right`}
onClick={deleteImage}
>
<ImgDeleteIcon />
</div>
</div>
</div>
);
}

View File

@@ -45,7 +45,7 @@ const Settings = dynamic(async () => (await import("./settings")).Settings, {
loading: () => <Loading noLogo />,
});
const Chat = dynamic(async () => await import("@/app/containers/Chat"), {
const Chat = dynamic(async () => (await import("./chat")).Chat, {
loading: () => <Loading noLogo />,
});
@@ -151,10 +151,7 @@ function Screen() {
<>
<SideBar className={isHome ? styles["sidebar-show"] : ""} />
<div
className={`flex flex-col h-[100%] w-[--window-content-width]`}
id={SlotID.AppBody}
>
<div className={styles["window-content"]} id={SlotID.AppBody}>
<ErrorBoundary>
<Routes>
<Route path={Path.Home} element={<Chat />} />