Browse Source

feat: add mask screen

Yidadaa 1 year ago
parent
commit
aeb986243c

+ 3 - 3
app/components/home.module.scss

@@ -272,16 +272,16 @@
   }
 
   .sidebar-tail {
-    flex-direction: column;
+    flex-direction: column-reverse;
     align-items: center;
 
     .sidebar-actions {
-      flex-direction: column;
+      flex-direction: column-reverse;
       align-items: center;
 
       .sidebar-action {
         margin-right: 0;
-        margin-bottom: 15px;
+        margin-top: 15px;
       }
     }
   }

+ 20 - 28
app/components/home.tsx

@@ -12,7 +12,7 @@ import LoadingIcon from "../icons/three-dots.svg";
 import { getCSSVar, useMobileScreen } from "../utils";
 
 import dynamic from "next/dynamic";
-import { Path } from "../constant";
+import { Path, SlotID } from "../constant";
 import { ErrorBoundary } from "./error";
 
 import {
@@ -23,6 +23,7 @@ import {
 } from "react-router-dom";
 import { SideBar } from "./sidebar";
 import { useAppConfig } from "../store/config";
+import { NewChat } from "./new-chat";
 
 export function Loading(props: { noLogo?: boolean }) {
   return (
@@ -82,39 +83,29 @@ const useHasHydrated = () => {
   return hasHydrated;
 };
 
-function WideScreen() {
+function Screen() {
   const config = useAppConfig();
-
-  return (
-    <div
-      className={`${
-        config.tightBorder ? styles["tight-container"] : styles.container
-      }`}
-    >
-      <SideBar />
-
-      <div className={styles["window-content"]}>
-        <Routes>
-          <Route path={Path.Home} element={<Chat />} />
-          <Route path={Path.Chat} element={<Chat />} />
-          <Route path={Path.Settings} element={<Settings />} />
-        </Routes>
-      </div>
-    </div>
-  );
-}
-
-function MobileScreen() {
   const location = useLocation();
   const isHome = location.pathname === Path.Home;
+  const isMobileScreen = useMobileScreen();
 
   return (
-    <div className={styles.container}>
+    <div
+      className={
+        styles.container +
+        ` ${
+          config.tightBorder && !isMobileScreen
+            ? styles["tight-container"]
+            : styles.container
+        }`
+      }
+    >
       <SideBar className={isHome ? styles["sidebar-show"] : ""} />
 
-      <div className={styles["window-content"]}>
+      <div className={styles["window-content"]} id={SlotID.AppBody}>
         <Routes>
-          <Route path={Path.Home} element={null} />
+          <Route path={Path.Home} element={<Chat />} />
+          <Route path={Path.NewChat} element={<NewChat />} />
           <Route path={Path.Chat} element={<Chat />} />
           <Route path={Path.Settings} element={<Settings />} />
         </Routes>
@@ -124,7 +115,6 @@ function MobileScreen() {
 }
 
 export function Home() {
-  const isMobileScreen = useMobileScreen();
   useSwitchTheme();
 
   if (!useHasHydrated()) {
@@ -133,7 +123,9 @@ export function Home() {
 
   return (
     <ErrorBoundary>
-      <Router>{isMobileScreen ? <MobileScreen /> : <WideScreen />}</Router>
+      <Router>
+        <Screen />
+      </Router>
     </ErrorBoundary>
   );
 }

+ 100 - 0
app/components/new-chat.module.scss

@@ -0,0 +1,100 @@
+.new-chat {
+  height: 100%;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  padding-top: 80px;
+
+  .mask-cards {
+    display: flex;
+    margin-bottom: 20px;
+
+    .mask-card {
+      padding: 20px 10px;
+      border: var(--border-in-light);
+      box-shadow: var(--card-shadow);
+      border-radius: 14px;
+      background-color: var(--white);
+      transform: scale(1);
+
+      &:first-child {
+        transform: rotate(-15deg) translateY(5px);
+      }
+
+      &:last-child {
+        transform: rotate(15deg) translateY(5px);
+      }
+    }
+  }
+
+  .title {
+    font-size: 32px;
+    font-weight: bolder;
+    animation: slide-in ease 0.3s;
+  }
+
+  .sub-title {
+    animation: slide-in ease 0.3s;
+  }
+
+  .search-bar {
+    margin-top: 20px;
+  }
+
+  .masks {
+    flex-grow: 1;
+    width: 100%;
+    overflow: hidden;
+    align-items: center;
+    padding-top: 20px;
+
+    animation: slide-in ease 0.3s;
+
+    .mask-row {
+      margin-bottom: 10px;
+      display: flex;
+      justify-content: center;
+
+      @for $i from 1 to 10 {
+        &:nth-child(#{$i * 2}) {
+          margin-left: 50px;
+        }
+      }
+
+      .mask {
+        display: flex;
+        align-items: center;
+        padding: 10px 16px;
+        border: var(--border-in-light);
+        box-shadow: var(--card-shadow);
+        background-color: var(--white);
+        border-radius: 10px;
+        margin-right: 10px;
+        width: 100px;
+        transform: scale(1);
+        cursor: pointer;
+        transition: all ease 0.3s;
+
+        &:hover {
+          transform: translateY(-5px) scale(1.1);
+          z-index: 999;
+          border-color: var(--primary);
+        }
+
+        .mask-avatar {
+          display: flex;
+          min-width: 18px;
+          min-height: 18px;
+          background-color: #eee;
+          border-radius: 20px;
+        }
+
+        .mask-name {
+          margin-left: 10px;
+        }
+      }
+    }
+  }
+}

+ 92 - 0
app/components/new-chat.tsx

@@ -0,0 +1,92 @@
+import { useEffect, useRef } from "react";
+import { SlotID } from "../constant";
+import { EmojiAvatar } from "./emoji";
+import styles from "./new-chat.module.scss";
+
+function getIntersectionArea(aRect: DOMRect, bRect: DOMRect) {
+  const xmin = Math.max(aRect.x, bRect.x);
+  const xmax = Math.min(aRect.x + aRect.width, bRect.x + bRect.width);
+  const ymin = Math.max(aRect.y, bRect.y);
+  const ymax = Math.min(aRect.y + aRect.height, bRect.y + bRect.height);
+  const width = xmax - xmin;
+  const height = ymax - ymin;
+  const intersectionArea = width < 0 || height < 0 ? 0 : width * height;
+  return intersectionArea;
+}
+
+function Mask(props: { avatar: string; name: string }) {
+  const domRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const changeOpacity = () => {
+      const dom = domRef.current;
+      const parent = document.getElementById(SlotID.AppBody);
+      if (!parent || !dom) return;
+
+      const domRect = dom.getBoundingClientRect();
+      const parentRect = parent.getBoundingClientRect();
+      const intersectionArea = getIntersectionArea(domRect, parentRect);
+      const domArea = domRect.width * domRect.height;
+      const ratio = intersectionArea / domArea;
+      const opacity = ratio > 0.9 ? 1 : 0.4;
+      dom.style.opacity = opacity.toString();
+    };
+
+    setTimeout(changeOpacity, 30);
+
+    window.addEventListener("resize", changeOpacity);
+
+    return () => window.removeEventListener("resize", changeOpacity);
+  }, [domRef]);
+
+  return (
+    <div className={styles["mask"]} ref={domRef}>
+      <div className={styles["mask-avatar"]}>
+        <EmojiAvatar avatar={props.avatar} />
+      </div>
+      <div className={styles["mask-name"] + " one-line"}>{props.name}</div>
+    </div>
+  );
+}
+
+export function NewChat() {
+  const masks = new Array(20).fill(0).map(() =>
+    new Array(10).fill(0).map((_, i) => ({
+      avatar: "1f" + (Math.round(Math.random() * 50) + 600).toString(),
+      name: ["撩妹达人", "编程高手", "情感大师", "健康医生", "数码通"][
+        Math.floor(Math.random() * 4)
+      ],
+    })),
+  );
+
+  return (
+    <div className={styles["new-chat"]}>
+      <div className={styles["mask-cards"]}>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="1f606" size={24} />
+        </div>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="1f916" size={24} />
+        </div>
+        <div className={styles["mask-card"]}>
+          <EmojiAvatar avatar="1f479" size={24} />
+        </div>
+      </div>
+
+      <div className={styles["title"]}>挑选一个面具</div>
+      <div className={styles["sub-title"]}>现在开始,与面具背后的思维碰撞</div>
+
+      <input className={styles["search-bar"]} placeholder="搜索" type="text" />
+
+      <div className={styles["masks"]}>
+        {masks.map((masks, i) => (
+          <div key={i} className={styles["mask-row"]}>
+            {masks.map((mask, index) => (
+              <Mask key={index} {...mask} />
+            ))}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}

+ 1 - 1
app/components/sidebar.tsx

@@ -134,7 +134,7 @@ export function SideBar(props: { className?: string }) {
             icon={<AddIcon />}
             text={shouldNarrow ? undefined : Locale.Home.NewChat}
             onClick={() => {
-              chatStore.newSession();
+              navigate(Path.NewChat);
             }}
             shadow
           />

+ 5 - 0
app/constant.ts

@@ -11,6 +11,11 @@ export enum Path {
   Home = "/",
   Chat = "/chat",
   Settings = "/settings",
+  NewChat = "/new-chat",
+}
+
+export enum SlotID {
+  AppBody = "app-body",
 }
 
 export const MAX_SIDEBAR_WIDTH = 500;

+ 3 - 2
app/locales/cn.ts

@@ -3,7 +3,8 @@ import { SubmitKey } from "../store/config";
 const cn = {
   WIP: "该功能仍在开发中……",
   Error: {
-    Unauthorized: "现在是未授权状态,请点击左下角设置按钮输入访问密码。",
+    Unauthorized:
+      "现在是未授权状态,请点击左下角[设置](/#/settings)按钮输入访问密码。",
   },
   ChatItem: {
     ChatItemCount: (count: number) => `${count} 条对话`,
@@ -141,7 +142,7 @@ const cn = {
     Model: "模型 (model)",
     Temperature: {
       Title: "随机性 (temperature)",
-      SubTitle: "值越大,回复越随机,大于 1 的值可能会导致乱码",
+      SubTitle: "值越大,回复越随机",
     },
     MaxTokens: {
       Title: "单次回复限制 (max_tokens)",

+ 1 - 1
app/locales/index.ts

@@ -19,7 +19,7 @@ export const AllLangs = [
   "jp",
   "de",
 ] as const;
-type Lang = (typeof AllLangs)[number];
+export type Lang = (typeof AllLangs)[number];
 
 const LANG_KEY = "lang";
 

+ 3 - 0
app/masks.ts

@@ -0,0 +1,3 @@
+import { Mask } from "./store/mask";
+
+export const BUILT_IN_MASKS: Mask[] = [];

+ 81 - 0
app/store/mask.ts

@@ -0,0 +1,81 @@
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+import { getLang, Lang } from "../locales";
+import { Message } from "./chat";
+import { ModelConfig, useAppConfig } from "./config";
+
+export const MASK_KEY = "mask-store";
+
+export type Mask = {
+  id: number;
+  avatar: string;
+  name: string;
+  context: Message[];
+  config: ModelConfig;
+  lang: Lang;
+};
+
+export const DEFAULT_MASK_STATE = {
+  masks: {} as Record<number, Mask>,
+  globalMaskId: 0,
+};
+
+export type MaskState = typeof DEFAULT_MASK_STATE;
+type MaskStore = MaskState & {
+  create: (mask: Partial<Mask>) => Mask;
+  update: (id: number, updater: (mask: Mask) => void) => void;
+  delete: (id: number) => void;
+  search: (text: string) => Mask[];
+  getAll: () => Mask[];
+};
+
+export const useMaskStore = create<MaskStore>()(
+  persist(
+    (set, get) => ({
+      ...DEFAULT_MASK_STATE,
+
+      create(mask) {
+        set(() => ({ globalMaskId: get().globalMaskId + 1 }));
+        const id = get().globalMaskId;
+        const masks = get().masks;
+        masks[id] = {
+          id,
+          avatar: "1f916",
+          name: "",
+          config: useAppConfig.getState().modelConfig,
+          context: [],
+          lang: getLang(),
+          ...mask,
+        };
+
+        set(() => ({ masks }));
+
+        return masks[id];
+      },
+      update(id, updater) {
+        const masks = get().masks;
+        const mask = masks[id];
+        if (!mask) return;
+        const updateMask = { ...mask };
+        updater(updateMask);
+        masks[id] = updateMask;
+        set(() => ({ masks }));
+      },
+      delete(id) {
+        const masks = get().masks;
+        delete masks[id];
+        set(() => ({ masks }));
+      },
+      getAll() {
+        return Object.values(get().masks).sort((a, b) => a.id - b.id);
+      },
+      search(text) {
+        return Object.values(get().masks);
+      },
+    }),
+    {
+      name: MASK_KEY,
+      version: 2,
+    },
+  ),
+);

+ 6 - 0
app/styles/globals.scss

@@ -336,3 +336,9 @@ pre {
   box-shadow: var(--card-shadow);
   border-radius: 10px;
 }
+
+.one-line {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}