Add ability to search workspace and threads (#4120)
* Add ability to search workspace and threads * fix height * styling * update placeholder * Translations PR (#4122)
This commit is contained in:
parent
ae2fa8805c
commit
ea956f4d77
213
frontend/src/components/Sidebar/SearchBox/index.jsx
Normal file
213
frontend/src/components/Sidebar/SearchBox/index.jsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { Plus, MagnifyingGlass } from "@phosphor-icons/react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import paths from "@/utils/paths";
|
||||||
|
import Preloader from "@/components/Preloader";
|
||||||
|
import debounce from "lodash.debounce";
|
||||||
|
import Workspace from "@/models/workspace";
|
||||||
|
import { Tooltip } from "react-tooltip";
|
||||||
|
|
||||||
|
const DEFAULT_SEARCH_RESULTS = {
|
||||||
|
workspaces: [],
|
||||||
|
threads: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEARCH_RESULT_SELECTED = "search-result-selected";
|
||||||
|
export default function SearchBox({ user, showNewWsModal }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchResults, setSearchResults] = useState(DEFAULT_SEARCH_RESULTS);
|
||||||
|
const handleSearch = debounce(handleSearchDebounced, 500);
|
||||||
|
|
||||||
|
async function handleSearchDebounced(e) {
|
||||||
|
try {
|
||||||
|
const searchValue = e.target.value;
|
||||||
|
setSearchTerm(searchValue);
|
||||||
|
setLoading(true);
|
||||||
|
const searchResults =
|
||||||
|
await Workspace.searchWorkspaceOrThread(searchValue);
|
||||||
|
setSearchResults(searchResults);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
setSearchResults(DEFAULT_SEARCH_RESULTS);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
searchRef.current.value = "";
|
||||||
|
setSearchTerm("");
|
||||||
|
setLoading(false);
|
||||||
|
setSearchResults(DEFAULT_SEARCH_RESULTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener(SEARCH_RESULT_SELECTED, handleReset);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener(SEARCH_RESULT_SELECTED, handleReset);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-x-[5px] w-full items-center h-[32px]">
|
||||||
|
<div className="relative h-full w-full flex">
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
type="search"
|
||||||
|
placeholder={t("common.search")}
|
||||||
|
onChange={handleSearch}
|
||||||
|
onReset={handleReset}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
className="border-none w-full h-full rounded-lg bg-theme-sidebar-item-default pl-4 pr-1 placeholder:text-theme-settings-input-placeholder placeholder:pl-4 outline-none text-white search-input peer text-sm"
|
||||||
|
/>
|
||||||
|
<MagnifyingGlass
|
||||||
|
size={14}
|
||||||
|
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-theme-settings-input-placeholder peer-focus:invisible"
|
||||||
|
weight="bold"
|
||||||
|
hidden={!!searchTerm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ShortWidthNewWorkspaceButton
|
||||||
|
user={user}
|
||||||
|
showNewWsModal={showNewWsModal}
|
||||||
|
/>
|
||||||
|
<SearchResults
|
||||||
|
searchResults={searchResults}
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultWrapper({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="absolute right-0 top-[6.2%] w-full flex flex-col gap-y-[24px] h-auto bg-theme-modal-border light:bg-theme-bg-primary light:border-2 light:border-theme-modal-border rounded-lg p-[16px] z-10 max-h-[calc(100%-24px)] overflow-y-scroll no-scroll">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResults({ searchResults, searchTerm, loading }) {
|
||||||
|
if (!searchTerm || searchTerm.length < 3) return null;
|
||||||
|
if (loading)
|
||||||
|
return (
|
||||||
|
<SearchResultWrapper>
|
||||||
|
<div className="flex flex-col gap-y-[8px] h-[200px] justify-center items-center">
|
||||||
|
<Preloader size={5} />
|
||||||
|
<p className="text-theme-text-secondary text-xs font-semibold text-center">
|
||||||
|
Searching for "{searchTerm}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SearchResultWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
searchResults.workspaces.length === 0 &&
|
||||||
|
searchResults.threads.length === 0
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<SearchResultWrapper>
|
||||||
|
<div className="flex flex-col gap-y-[8px] h-[200px] justify-center items-center">
|
||||||
|
<p className="text-theme-text-secondary text-xs font-semibold text-center">
|
||||||
|
No results found for
|
||||||
|
<br />
|
||||||
|
<span className="text-theme-text-primary font-semibold text-sm">
|
||||||
|
"{searchTerm}"
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SearchResultWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchResultWrapper>
|
||||||
|
<SearchResultCategory
|
||||||
|
name="Workspaces"
|
||||||
|
items={searchResults.workspaces?.map((workspace) => ({
|
||||||
|
id: workspace.slug,
|
||||||
|
to: paths.workspace.chat(workspace.slug),
|
||||||
|
name: workspace.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<SearchResultCategory
|
||||||
|
name="Threads"
|
||||||
|
items={searchResults.threads?.map((thread) => ({
|
||||||
|
id: thread.slug,
|
||||||
|
to: paths.workspace.thread(thread.workspace.slug, thread.slug),
|
||||||
|
name: thread.name,
|
||||||
|
hint: thread.workspace.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</SearchResultWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultCategory({ items, name }) {
|
||||||
|
if (!items?.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-[8px]">
|
||||||
|
<p className="text-theme-text-secondary text-xs uppercase font-semibold px-[4px]">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-y-[6px]">
|
||||||
|
{items.map((item) => (
|
||||||
|
<SearchResultItem
|
||||||
|
key={item.id}
|
||||||
|
to={item.to}
|
||||||
|
name={item.name}
|
||||||
|
hint={item.hint}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchResultItem({ to, name, hint }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
reloadDocument={true}
|
||||||
|
onClick={() => window.dispatchEvent(new Event(SEARCH_RESULT_SELECTED))}
|
||||||
|
className="hover:bg-[#FFF]/10 light:hover:bg-[#000]/10 transition-all duration-300 rounded-sm px-[8px] py-[2px]"
|
||||||
|
>
|
||||||
|
<p className="text-theme-text-primary text-sm truncate w-[80%]">
|
||||||
|
{name}
|
||||||
|
{hint && (
|
||||||
|
<span className="text-theme-text-secondary text-xs ml-[4px]">
|
||||||
|
| {hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShortWidthNewWorkspaceButton({ user, showNewWsModal }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!!user && user?.role === "default") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
data-tooltip-id="new-workspace-tooltip"
|
||||||
|
data-tooltip-content={t("new-workspace.title")}
|
||||||
|
onClick={showNewWsModal}
|
||||||
|
className="border-none flex items-center justify-center bg-white rounded-lg p-[8px] hover:bg-white/80 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus size={16} weight="bold" className="text-black" />
|
||||||
|
</button>
|
||||||
|
<Tooltip
|
||||||
|
id="new-workspace-tooltip"
|
||||||
|
place="top"
|
||||||
|
delayShow={300}
|
||||||
|
className="tooltip !text-xs"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Plus, List } from "@phosphor-icons/react";
|
import { List, Plus } from "@phosphor-icons/react";
|
||||||
import NewWorkspaceModal, {
|
import NewWorkspaceModal, {
|
||||||
useNewWorkspaceModal,
|
useNewWorkspaceModal,
|
||||||
} from "../Modals/NewWorkspace";
|
} from "../Modals/NewWorkspace";
|
||||||
@ -12,6 +12,7 @@ import { Link } from "react-router-dom";
|
|||||||
import paths from "@/utils/paths";
|
import paths from "@/utils/paths";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSidebarToggle, ToggleSidebarButton } from "./SidebarToggle";
|
import { useSidebarToggle, ToggleSidebarButton } from "./SidebarToggle";
|
||||||
|
import SearchBox from "./SearchBox";
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
@ -58,20 +59,8 @@ export default function Sidebar() {
|
|||||||
<div className="flex flex-col h-full overflow-x-hidden">
|
<div className="flex flex-col h-full overflow-x-hidden">
|
||||||
<div className="flex-grow flex flex-col min-w-[235px]">
|
<div className="flex-grow flex flex-col min-w-[235px]">
|
||||||
<div className="relative h-[calc(100%-60px)] flex flex-col w-full justify-between pt-[10px] overflow-y-scroll no-scroll">
|
<div className="relative h-[calc(100%-60px)] flex flex-col w-full justify-between pt-[10px] overflow-y-scroll no-scroll">
|
||||||
<div className="flex flex-col gap-y-2 pb-[60px] overflow-y-scroll no-scroll">
|
<div className="flex flex-col gap-y-2 pb-[60px] gap-y-[14px] overflow-y-scroll no-scroll">
|
||||||
<div className="flex gap-x-2 items-center justify-between">
|
<SearchBox user={user} showNewWsModal={showNewWsModal} />
|
||||||
{(!user || user?.role !== "default") && (
|
|
||||||
<button
|
|
||||||
onClick={showNewWsModal}
|
|
||||||
className="light:bg-[#C2E7FE] light:hover:bg-[#7CD4FD] flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-2.5 mb-2 bg-white rounded-[8px] text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Plus size={18} weight="bold" />
|
|
||||||
<p className="text-sidebar text-sm font-semibold">
|
|
||||||
{t("new-workspace.title")}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ActiveWorkspaces />
|
<ActiveWorkspaces />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -177,19 +166,10 @@ export function SidebarMobileHeader() {
|
|||||||
<div className="h-full flex flex-col w-full justify-between pt-4 ">
|
<div className="h-full flex flex-col w-full justify-between pt-4 ">
|
||||||
<div className="h-auto md:sidebar-items">
|
<div className="h-auto md:sidebar-items">
|
||||||
<div className=" flex flex-col gap-y-4 overflow-y-scroll no-scroll pb-[60px]">
|
<div className=" flex flex-col gap-y-4 overflow-y-scroll no-scroll pb-[60px]">
|
||||||
<div className="flex gap-x-2 items-center justify-between">
|
<NewWorkspaceButton
|
||||||
{(!user || user?.role !== "default") && (
|
user={user}
|
||||||
<button
|
showNewWsModal={showNewWsModal}
|
||||||
onClick={showNewWsModal}
|
/>
|
||||||
className="flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-4 bg-white rounded-lg text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
|
|
||||||
>
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
<p className="text-sidebar text-sm font-semibold">
|
|
||||||
{t("new-workspace.title")}
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ActiveWorkspaces />
|
<ActiveWorkspaces />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -204,3 +184,22 @@ export function SidebarMobileHeader() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NewWorkspaceButton({ user, showNewWsModal }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (!!user && user?.role === "default") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-x-2 items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={showNewWsModal}
|
||||||
|
className="flex flex-grow w-[75%] h-[44px] gap-x-2 py-[5px] px-4 bg-white rounded-lg text-sidebar justify-center items-center hover:bg-opacity-80 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5" />
|
||||||
|
<p className="text-sidebar text-sm font-semibold">
|
||||||
|
{t("new-workspace.title")}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1023,6 +1023,10 @@ does not extend the close button beyond the viewport. */
|
|||||||
filter: grayscale(100%) invert(1) brightness(100) opacity(0.5);
|
filter: grayscale(100%) invert(1) brightness(100) opacity(0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] .search-input::-webkit-search-cancel-button {
|
||||||
|
filter: grayscale(100%) invert(0) brightness(0) opacity(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.animate-remove {
|
.animate-remove {
|
||||||
animation: fadeAndShrink 800ms forwards;
|
animation: fadeAndShrink 800ms forwards;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,6 +68,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "اختياري",
|
optional: "اختياري",
|
||||||
yes: "نعم",
|
yes: "نعم",
|
||||||
no: "لا",
|
no: "لا",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "إعدادات المثيل",
|
title: "إعدادات المثيل",
|
||||||
|
|||||||
@ -70,6 +70,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Valgfrit",
|
optional: "Valgfrit",
|
||||||
yes: "Ja",
|
yes: "Ja",
|
||||||
no: "Nej",
|
no: "Nej",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Instansindstillinger",
|
title: "Instansindstillinger",
|
||||||
|
|||||||
@ -70,6 +70,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Optional",
|
optional: "Optional",
|
||||||
yes: "Ja",
|
yes: "Ja",
|
||||||
no: "Nein",
|
no: "Nein",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Instanzeinstellungen",
|
title: "Instanzeinstellungen",
|
||||||
|
|||||||
@ -70,6 +70,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Optional",
|
optional: "Optional",
|
||||||
yes: "Yes",
|
yes: "Yes",
|
||||||
no: "No",
|
no: "No",
|
||||||
|
search: "Search",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Setting Sidebar menu items.
|
// Setting Sidebar menu items.
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Configuración de instancia",
|
title: "Configuración de instancia",
|
||||||
|
|||||||
@ -68,6 +68,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Valikuline",
|
optional: "Valikuline",
|
||||||
yes: "Jah",
|
yes: "Jah",
|
||||||
no: "Ei",
|
no: "Ei",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Instantsi seaded",
|
title: "Instantsi seaded",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "تنظیمات سامانه",
|
title: "تنظیمات سامانه",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Paramètres de l'instance",
|
title: "Paramètres de l'instance",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "הגדרות מופע",
|
title: "הגדרות מופע",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Impostazioni istanza",
|
title: "Impostazioni istanza",
|
||||||
|
|||||||
@ -69,6 +69,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "任意",
|
optional: "任意",
|
||||||
yes: "はい",
|
yes: "はい",
|
||||||
no: "いいえ",
|
no: "いいえ",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "インスタンス設定",
|
title: "インスタンス設定",
|
||||||
|
|||||||
@ -68,6 +68,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "선택 사항",
|
optional: "선택 사항",
|
||||||
yes: "예",
|
yes: "예",
|
||||||
no: "아니오",
|
no: "아니오",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "인스턴스 설정",
|
title: "인스턴스 설정",
|
||||||
|
|||||||
@ -69,6 +69,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Neobligāti",
|
optional: "Neobligāti",
|
||||||
yes: "Jā",
|
yes: "Jā",
|
||||||
no: "Nē",
|
no: "Nē",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Instances iestatījumi",
|
title: "Instances iestatījumi",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Instelling Instanties",
|
title: "Instelling Instanties",
|
||||||
|
|||||||
@ -70,6 +70,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Opcjonalnie",
|
optional: "Opcjonalnie",
|
||||||
yes: "Tak",
|
yes: "Tak",
|
||||||
no: "Nie",
|
no: "Nie",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Ustawienia instancji",
|
title: "Ustawienia instancji",
|
||||||
|
|||||||
@ -68,6 +68,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Opcional",
|
optional: "Opcional",
|
||||||
yes: "Sim",
|
yes: "Sim",
|
||||||
no: "Não",
|
no: "Não",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Configurações da Instância",
|
title: "Configurações da Instância",
|
||||||
|
|||||||
@ -69,6 +69,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "Необязательный",
|
optional: "Необязательный",
|
||||||
yes: "Да",
|
yes: "Да",
|
||||||
no: "Нет",
|
no: "Нет",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Настройки экземпляра",
|
title: "Настройки экземпляра",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Instance Ayarları",
|
title: "Instance Ayarları",
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "Cài đặt hệ thống",
|
title: "Cài đặt hệ thống",
|
||||||
|
|||||||
@ -65,6 +65,7 @@ const TRANSLATIONS = {
|
|||||||
optional: "可选",
|
optional: "可选",
|
||||||
yes: "是",
|
yes: "是",
|
||||||
no: "否",
|
no: "否",
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "设置",
|
title: "设置",
|
||||||
|
|||||||
@ -65,6 +65,7 @@ const TRANSLATIONS = {
|
|||||||
optional: null,
|
optional: null,
|
||||||
yes: null,
|
yes: null,
|
||||||
no: null,
|
no: null,
|
||||||
|
search: null,
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: "系統設定",
|
title: "系統設定",
|
||||||
|
|||||||
@ -508,6 +508,25 @@ const Workspace = {
|
|||||||
return orderedWorkspaces;
|
return orderedWorkspaces;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for workspaces and threads
|
||||||
|
* @param {string} searchTerm
|
||||||
|
* @returns {Promise<{workspaces: [{slug: string, name: string}], threads: [{slug: string, name: string, workspace: {slug: string, name: string}}]}}>}
|
||||||
|
*/
|
||||||
|
searchWorkspaceOrThread: async function (searchTerm) {
|
||||||
|
const response = await fetch(`${API_BASE}/workspace/search`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: baseHeaders(),
|
||||||
|
body: JSON.stringify({ searchTerm }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return { workspaces: [], threads: [] };
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
threads: WorkspaceThread,
|
threads: WorkspaceThread,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const { WorkspaceThread } = require("../models/workspaceThread");
|
|||||||
const truncate = require("truncate");
|
const truncate = require("truncate");
|
||||||
const { purgeDocument } = require("../utils/files/purgeDocument");
|
const { purgeDocument } = require("../utils/files/purgeDocument");
|
||||||
const { getModelTag } = require("./utils");
|
const { getModelTag } = require("./utils");
|
||||||
|
const { searchWorkspaceAndThreads } = require("../utils/helpers/search");
|
||||||
|
|
||||||
function workspaceEndpoints(app) {
|
function workspaceEndpoints(app) {
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
@ -1037,6 +1038,28 @@ function workspaceEndpoints(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for workspaces and threads by thread name or workspace name.
|
||||||
|
* Only returns assets owned by the user (if multi-user mode is enabled).
|
||||||
|
*/
|
||||||
|
app.post(
|
||||||
|
"/workspace/search",
|
||||||
|
[validatedRequest, flexUserRoleValid([ROLES.all])],
|
||||||
|
async (request, response) => {
|
||||||
|
try {
|
||||||
|
const { searchTerm } = reqBody(request);
|
||||||
|
const searchResults = await searchWorkspaceAndThreads(
|
||||||
|
searchTerm,
|
||||||
|
response.locals?.user
|
||||||
|
);
|
||||||
|
response.status(200).json(searchResults);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error searching for workspaces:", error);
|
||||||
|
response.sendStatus(500).end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { workspaceEndpoints };
|
module.exports = { workspaceEndpoints };
|
||||||
|
|||||||
@ -100,12 +100,18 @@ const WorkspaceThread = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
where: async function (clause = {}, limit = null, orderBy = null) {
|
where: async function (
|
||||||
|
clause = {},
|
||||||
|
limit = null,
|
||||||
|
orderBy = null,
|
||||||
|
include = null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const results = await prisma.workspace_threads.findMany({
|
const results = await prisma.workspace_threads.findMany({
|
||||||
where: clause,
|
where: clause,
|
||||||
...(limit !== null ? { take: limit } : {}),
|
...(limit !== null ? { take: limit } : {}),
|
||||||
...(orderBy !== null ? { orderBy } : {}),
|
...(orderBy !== null ? { orderBy } : {}),
|
||||||
|
...(include !== null ? { include } : {}),
|
||||||
});
|
});
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -52,6 +52,7 @@
|
|||||||
"elevenlabs": "^0.5.0",
|
"elevenlabs": "^0.5.0",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"extract-json-from-string": "^1.0.1",
|
"extract-json-from-string": "^1.0.1",
|
||||||
|
"fast-levenshtein": "^3.0.0",
|
||||||
"graphql": "^16.7.1",
|
"graphql": "^16.7.1",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"joi-password-complexity": "^5.2.0",
|
"joi-password-complexity": "^5.2.0",
|
||||||
@ -99,4 +100,4 @@
|
|||||||
"nodemon": "^2.0.22",
|
"nodemon": "^2.0.22",
|
||||||
"prettier": "^3.0.3"
|
"prettier": "^3.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
94
server/utils/helpers/search.js
Normal file
94
server/utils/helpers/search.js
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
const { Workspace } = require("../../models/workspace");
|
||||||
|
const { WorkspaceThread } = require("../../models/workspaceThread");
|
||||||
|
const fastLevenshtein = require("fast-levenshtein");
|
||||||
|
|
||||||
|
// allow a pretty loose levenshtein distance for the search
|
||||||
|
// since we would rather show a few more results than less
|
||||||
|
const FAST_LEVENSHTEIN_DISTANCE = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for workspaces and threads based on a search term with optional user context.
|
||||||
|
* For each type of item we are looking at the `name` field.
|
||||||
|
* - If the normalized name, starts with, includes, or ends with the search term => match
|
||||||
|
* - If the normalized name is within 2 levenshtein distance of the search term => match
|
||||||
|
* @param {string} searchTerm - The search term to search for.
|
||||||
|
* @param {Object} user - The user to search for.
|
||||||
|
* @returns {Promise<{workspaces: Array<{slug: string, name: string}>, threads: Array<{slug: string, name: string, workspace: {slug: string, name: string}}>}>} - The search results.
|
||||||
|
*/
|
||||||
|
async function searchWorkspaceAndThreads(searchTerm, user = null) {
|
||||||
|
searchTerm = String(searchTerm).trim(); // Ensure searchTerm is a string and trimmed.
|
||||||
|
|
||||||
|
if (!searchTerm || searchTerm.length < 3)
|
||||||
|
return { workspaces: [], threads: [] };
|
||||||
|
searchTerm = searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
// To prevent duplicates in O(1) time, we use sets which will be
|
||||||
|
// STRINGIFIED results of matching workspaces or threads. We then
|
||||||
|
// parse them back into objects at the end.
|
||||||
|
const results = {
|
||||||
|
workspaces: new Set(),
|
||||||
|
threads: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function searchWorkspaces() {
|
||||||
|
const workspaces = !!user
|
||||||
|
? await Workspace.whereWithUser(user)
|
||||||
|
: await Workspace.where();
|
||||||
|
|
||||||
|
for (const workspace of workspaces) {
|
||||||
|
const wsName = workspace.name.toLowerCase();
|
||||||
|
if (
|
||||||
|
wsName.startsWith(searchTerm) ||
|
||||||
|
wsName.includes(searchTerm) ||
|
||||||
|
wsName.endsWith(searchTerm) ||
|
||||||
|
fastLevenshtein.get(wsName, searchTerm) <= FAST_LEVENSHTEIN_DISTANCE
|
||||||
|
)
|
||||||
|
results.workspaces.add(
|
||||||
|
JSON.stringify({ slug: workspace.slug, name: workspace.name })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchThreads() {
|
||||||
|
const threads = !!user
|
||||||
|
? await WorkspaceThread.where(
|
||||||
|
{ user_id: user.id },
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ workspace: { select: { slug: true, name: true } } }
|
||||||
|
)
|
||||||
|
: await WorkspaceThread.where(undefined, undefined, undefined, {
|
||||||
|
workspace: { select: { slug: true, name: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const thread of threads) {
|
||||||
|
const threadName = thread.name.toLowerCase();
|
||||||
|
if (
|
||||||
|
threadName.startsWith(searchTerm) ||
|
||||||
|
threadName.includes(searchTerm) ||
|
||||||
|
threadName.endsWith(searchTerm) ||
|
||||||
|
fastLevenshtein.get(threadName, searchTerm) <= FAST_LEVENSHTEIN_DISTANCE
|
||||||
|
)
|
||||||
|
results.threads.add(
|
||||||
|
JSON.stringify({
|
||||||
|
slug: thread.slug,
|
||||||
|
name: thread.name,
|
||||||
|
workspace: {
|
||||||
|
slug: thread.workspace.slug,
|
||||||
|
name: thread.workspace.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run both searches in parallel - this modifies the results set in place.
|
||||||
|
await Promise.all([searchWorkspaces(), searchThreads()]);
|
||||||
|
|
||||||
|
// Parse the results back into objects.
|
||||||
|
const workspaces = Array.from(results.workspaces).map(JSON.parse);
|
||||||
|
const threads = Array.from(results.threads).map(JSON.parse);
|
||||||
|
return { workspaces, threads };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { searchWorkspaceAndThreads };
|
||||||
@ -4467,6 +4467,13 @@ fast-levenshtein@^2.0.6:
|
|||||||
resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
|
resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
|
||||||
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
|
||||||
|
|
||||||
|
fast-levenshtein@^3.0.0:
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz#37b899ae47e1090e40e3fd2318e4d5f0142ca912"
|
||||||
|
integrity sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==
|
||||||
|
dependencies:
|
||||||
|
fastest-levenshtein "^1.0.7"
|
||||||
|
|
||||||
fast-xml-parser@4.2.5:
|
fast-xml-parser@4.2.5:
|
||||||
version "4.2.5"
|
version "4.2.5"
|
||||||
resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz"
|
resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz"
|
||||||
@ -4488,6 +4495,11 @@ fast-xml-parser@^4.3.5:
|
|||||||
dependencies:
|
dependencies:
|
||||||
strnum "^1.0.5"
|
strnum "^1.0.5"
|
||||||
|
|
||||||
|
fastest-levenshtein@^1.0.7:
|
||||||
|
version "1.0.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5"
|
||||||
|
integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==
|
||||||
|
|
||||||
fastq@^1.6.0:
|
fastq@^1.6.0:
|
||||||
version "1.17.1"
|
version "1.17.1"
|
||||||
resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz"
|
resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user