feature: engine selector, without nextui

This commit is contained in:
alikia2x 2024-07-13 14:37:47 +08:00
parent 4c9c83311b
commit bfcf76fdc2
11 changed files with 198 additions and 137 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -1,21 +1,22 @@
import React, { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@nextui-org/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { settingsAtom } from "lib/state/settings"; import { settingsAtom } from "lib/state/settings";
import { engineTranslation } from "lib/onesearch/translatedEngineList"; import { engineTranslation } from "lib/onesearch/translatedEngineList";
import { settingsType } from "global"; import { settingsType } from "global";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import Picker, { PickedItem } from "./picker";
export default function EngineSelector(props: { className: string }) { export default function EngineSelector(props: { className: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const settings: settingsType = useAtomValue(settingsAtom); const settings: settingsType = useAtomValue(settingsAtom);
const items = settings.searchEngines; const engines = settings.searchEngines;
const currentEngine: string = settings.currentSearchEngine; const currentEngine: string = settings.currentSearchEngine;
const displayEngine = getName(currentEngine); const [selected, setSelected] = useState(currentEngine);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedKeys, setSelectedKeys] = useState(new Set([currentEngine]) as any);
const selectedValue = React.useMemo(() => Array.from(selectedKeys).join(", "), [selectedKeys]);
const setSettings = useSetAtom(settingsAtom); const setSettings = useSetAtom(settingsAtom);
let engineList: PickedItem = {};
for (const engineKey of Object.keys(engines)) {
engineList[engineKey] = getName(engineKey);
}
function getName(engineKey: string) { function getName(engineKey: string) {
return engineTranslation.includes(engineKey) ? t(`search.engine.${engineKey}`) : engineKey; return engineTranslation.includes(engineKey) ? t(`search.engine.${engineKey}`) : engineKey;
@ -30,34 +31,20 @@ export default function EngineSelector(props: { className: string }) {
}; };
}); });
} }
if (selectedValue !== currentEngine) { if (selected !== currentEngine) {
setEngine(selectedValue); setEngine(selected);
} }
}, [currentEngine, selectedValue, setSettings]); }, [currentEngine, selected, setSettings]);
return ( return (
<div className={props.className}> <Picker
<Dropdown> selectionItems={engineList}
<DropdownTrigger> selected={selected}
<Button variant="bordered" className="capitalize"> selectionOnChange={(selected) => {
{displayEngine} setSelected(selected);
</Button> }}
</DropdownTrigger> displayContent={getName(selected)}
<DropdownMenu className={props.className}
aria-label={t("search.engine-aria")} />
variant="light"
disallowEmptySelection
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
{Object.keys(items).map((item) => (
<DropdownItem key={item} suppressHydrationWarning>
{getName(item)}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
</div>
); );
} }

View File

@ -23,11 +23,11 @@ export default function OneSearch() {
const lastRequestTimeRef = useRef(0); const lastRequestTimeRef = useRef(0);
const selected = useAtomValue(selectedSuggestionAtom); const selected = useAtomValue(selectedSuggestionAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const devMode = true; const devMode = false;
const query = useAtomValue(queryAtom); const query = useAtomValue(queryAtom);
const engineName = getSearchEngineName(); const engineName = getSearchEngineName();
const engine = settings.currentSearchEngine; const engine = settings.currentSearchEngine;
const { t } = useTranslation("Search"); const { t } = useTranslation();
const lang = i18next.language; const lang = i18next.language;
useEffect(() => { useEffect(() => {
@ -127,7 +127,7 @@ export default function OneSearch() {
<PlainSearch key={i} query={s.suggestion} selected={i == selected}> <PlainSearch key={i} query={s.suggestion} selected={i == selected}>
{s.suggestion}&nbsp; {s.suggestion}&nbsp;
<span className="text-zinc-700 dark:text-zinc-400 text-sm"> <span className="text-zinc-700 dark:text-zinc-400 text-sm">
{t("search-help-text", { engine: engineName })} {t("search.search-help-text", { engine: engineName })}
</span> </span>
{devMode && ( {devMode && (
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2"> <span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">

View File

@ -1,8 +1,10 @@
export default function SuggestionBox(props: { children?: React.ReactNode }) { export default function SuggestionBox(props: { children?: React.ReactNode }) {
return ( return (
<div className={`relative bg-zinc-100 dark:bg-zinc-800 w-11/12 sm:w-[700px] h-auto max-h-[calc(100vh-20rem)] <div
overflow-y-auto left-1/2 translate-x-[-50%] top-72 z-20 rounded-md overflow-hidden duration-250 className={`relative bg-zinc-100 dark:bg-zinc-800 w-11/12 sm:w-[700px] h-auto max-h-[calc(100vh-20rem)]
${props.children ? "opacity-100" : "opacity-0"}`}> overflow-y-auto left-1/2 translate-x-[-50%] top-72 z-20 rounded-md overflow-hidden duration-250 dark:text-white
${props.children ? "opacity-100" : "opacity-0"}`}
>
{props.children} {props.children}
</div> </div>
); );

148
components/picker.tsx Normal file
View File

@ -0,0 +1,148 @@
import { HTMLAttributes, RefObject, useEffect, useRef, useState } from "react";
import { selectedOnChange } from "./selectorItem";
import { createPortal } from "react-dom";
import { Icon } from "@iconify/react";
import React from "react";
export type selectionType = string;
interface PickerProps extends HTMLAttributes<HTMLDivElement> {
selected: selectionType;
selectionOnChange: selectedOnChange;
displayContent: string;
selectionItems: PickedItem;
}
export interface PickedItem {
[key: string]: selectionType;
}
export default function Picker(props: PickerProps) {
const itemListRef: RefObject<HTMLDivElement> = useRef(null);
const buttonRef: RefObject<HTMLButtonElement> = useRef(null);
const [displayList, setDisplayList] = useState(false);
const updatePosition = () => {
if (itemListRef.current == null || buttonRef.current == null) {
return;
}
const buttonRect = buttonRef.current.getBoundingClientRect();
const listWidth = itemListRef.current.getBoundingClientRect().width;
// Align to center
itemListRef.current.style.left = buttonRect.x + buttonRect.width / 2 - listWidth / 2 + "px";
itemListRef.current.style.top = buttonRect.y + buttonRect.height + 16 + "px";
};
useEffect(() => {
updatePosition();
const handleResize = () => {
updatePosition();
};
window.addEventListener("resize", handleResize);
// Cleanup event listener on component unmount
return () => {
window.removeEventListener("resize", handleResize);
};
}, [itemListRef, buttonRef]);
function toggleDisplay(targetState?: boolean) {
function hideList() {
if (itemListRef.current) {
itemListRef.current.style.opacity = "0%";
itemListRef.current.style.transform = "scaleX(.85) scaleY(.85)";
}
setTimeout(() => {
setDisplayList(false);
}, 200);
}
function showList() {
setDisplayList(true);
setTimeout(() => {
updatePosition();
if (itemListRef.current) {
itemListRef.current.style.opacity = "100%";
itemListRef.current.style.transform = "scaleX(1) scaleY(1)";
}
}, 20);
}
if (targetState === true) {
showList();
} else if (targetState === false) {
hideList();
} else if (displayList === true) {
hideList();
} else {
showList();
}
}
const { displayContent, selectionOnChange, selectionItems, selected, ...rest } = props;
return (
<div {...rest}>
<button
className="relative border-2 border-gray-500 dark:border-gray-300
rounded-xl dark:text-white px-4 py-2"
ref={buttonRef}
onClick={() => {
toggleDisplay();
}}
>
{displayContent}
</button>
{displayList && (
<PickerList
ref={itemListRef}
selected={selected}
selectionOnChange={selectionOnChange}
selectionItems={selectionItems}
toggleDisplay={toggleDisplay}
/>
)}
</div>
);
}
interface PickerListProps {
selected: selectionType;
selectionOnChange: selectedOnChange;
selectionItems: PickedItem;
toggleDisplay: Function;
}
const PickerList = React.forwardRef<HTMLDivElement, PickerListProps>((props, ref) => {
const { selected, selectionOnChange, selectionItems } = props;
return createPortal(
<div
ref={ref}
className="absolute w-fit text-white opacity-0 duration-200
bg-gray-300 dark:bg-zinc-700 px-2 py-2 rounded-xl text-align-left scale-[.85]"
style={{ transformOrigin: "top center" }}
>
{Object.keys(selectionItems).map((key: string, index) => {
return (
<div
key={index}
className="relative py-2 w-full min-w-32 pl-2 cursor-pointer rounded-lg
hover:bg-zinc-400 dark:hover:bg-zinc-600 flex justify-between items-center"
onClick={() => {
selectionOnChange(key);
props.toggleDisplay();
}}
>
<span>{selectionItems[key]}</span>
<div className="relative w-16"></div>
{key === selected && (
<Icon className="relative right-2" icon="carbon:checkmark" />
)}
</div>
);
})}
</div>,
document.body
);
});
PickerList.displayName = "PickerList";

View File

@ -1,85 +0,0 @@
import { HTMLAttributes, ReactNode, RefObject, useEffect, useRef } from "react";
import { selectedOnChange } from "./selectorItem";
import { createPortal } from "react-dom";
export type selectionType = string | number | boolean;
interface PickerPropsChildrenStyle extends HTMLAttributes<HTMLDivElement> {
selected: selectionType;
selectionOnChange: selectedOnChange;
displayContent: string;
children: ReactNode;
}
interface PickerPropsParamStyle extends HTMLAttributes<HTMLDivElement> {
selected: selectionType;
selectionOnChange: selectedOnChange;
displayContent: string;
selectionItems: PickedItem;
}
interface PickedItem {
[key: string]: selectionType;
}
export default function Picker(props: PickerPropsChildrenStyle | PickerPropsParamStyle) {
const itemListRef: RefObject<HTMLDivElement> = useRef(null);
const buttonRef: RefObject<HTMLButtonElement> = useRef(null);
const updatePosition = () => {
if (itemListRef.current == null || buttonRef.current == null) {
return;
}
const buttonRect = buttonRef.current.getBoundingClientRect();
const listWidth = itemListRef.current.getBoundingClientRect().width;
// Align to center
itemListRef.current.style.left = buttonRect.x + buttonRect.width / 2 - listWidth / 2 + "px";
itemListRef.current.style.top = buttonRect.y + buttonRect.height + 16 + "px";
};
useEffect(() => {
updatePosition();
const handleResize = () => {
updatePosition();
};
window.addEventListener('resize', handleResize);
// Cleanup event listener on component unmount
return () => {
window.removeEventListener('resize', handleResize);
};
}, [itemListRef, buttonRef]);
if ("selectionItems" in props) {
const { selectionItems, displayContent, selectionOnChange, ...rest } = props;
return (
<div {...rest}>
<button
className="relative border-2 border-gray-500 dark:border-gray-300 rounded-xl dark:text-white
px-4 py-2"
ref={buttonRef}
>
{displayContent}
</button>
{createPortal(
<div ref={itemListRef} className="absolute w-fit text-white">
{Object.keys(selectionItems).map((key: string, index) => {
return <div key={index}>{selectionItems[key]}</div>;
})}
</div>,
document.body
)}
</div>
);
} else {
return (
<div {...props}>
<button className="relative" ref={buttonRef}>
{props.displayContent}
</button>
{createPortal(<div ref={itemListRef}>{props.children}</div>, document.body)}
</div>
);
}
}

View File

@ -1,5 +1,5 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { selectionType } from "./selector"; import { selectionType } from "./picker";
export type selectedOnChange = (target: selectionType) => void; export type selectedOnChange = (target: selectionType) => void;

View File

@ -12,6 +12,6 @@ export default function(){
} }
function getName(engineKey: string) { function getName(engineKey: string) {
const { t } = useTranslation("Search"); const { t } = useTranslation();
return engineTranslation.includes(engineKey) ? t(`engine.${engineKey}`) : engineKey; return engineTranslation.includes(engineKey) ? t(`search.engine.${engineKey}`) : engineKey;
} }

View File

@ -10,6 +10,7 @@
"preview": "NODE_ENV=production bun server.ts" "preview": "NODE_ENV=production bun server.ts"
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^5.0.1",
"@nextui-org/react": "^2.4.2", "@nextui-org/react": "^2.4.2",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"cac": "^6.7.14", "cac": "^6.7.14",

View File

@ -6,7 +6,6 @@ import { settingsAtom } from "lib/state/settings";
import { bgFocusAtom } from "lib/state/background"; import { bgFocusAtom } from "lib/state/background";
import EngineSelector from "components/engineSelector"; import EngineSelector from "components/engineSelector";
import OneSearch from "components/onesearch/onesearch"; import OneSearch from "components/onesearch/onesearch";
import Picker from "components/selector";
export default function Homepage() { export default function Homepage() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
@ -16,20 +15,13 @@ export default function Homepage() {
<div className="h-full fixed overflow-hidden w-full bg-black"> <div className="h-full fixed overflow-hidden w-full bg-black">
<Background /> <Background />
<EngineSelector <EngineSelector
className="absolute top-20 lg:top-44 short:top-0 translate-x-[-50%] translate-y-[-0.2rem] className="absolute top-20 lg:top-44 short:top-0 translate-x-[-50%] translate-y-[-0.2rem]
left-1/2 w-11/12 sm:w-[700px] text:black text-right left-1/2 w-11/12 sm:w-[700px] text:black text-right
dark:text-white text-3xl text-shadow-lg z-10" dark:text-white text-shadow-lg z-10"
/> />
<Search onFocus={() => setBgFocus(true)} /> <Search onFocus={() => setBgFocus(true)} />
<Time showSecond={settings.timeShowSecond} /> <Time showSecond={settings.timeShowSecond} />
<OneSearch /> <OneSearch />
<Picker
selectionItems={{ "1": "Item1", "2": "Item2" }}
selected="2"
selectionOnChange={() => {}}
displayContent="Item1"
className="absolute w-fit h-8 top-20 lg:top-44 short:top-0 -translate-x-1/2 -translate-y-1 left-1/2 z-20"
/>
</div> </div>
); );
} }

View File

@ -4,6 +4,7 @@ import ViteExpress from "vite-express";
import pjson from "./package.json"; import pjson from "./package.json";
import { networkInterfaces } from "os"; import { networkInterfaces } from "os";
import cac from "cac"; import cac from "cac";
import { completeGoogle } from "search-engine-autocomplete";
const start = new Date(); const start = new Date();
const cli = cac(); const cli = cac();
@ -37,6 +38,21 @@ if (parsed.options.host!==undefined && typeof parsed.options.host == "boolean" &
app.get("/message", (_, res) => res.send("Hello from express!")); app.get("/message", (_, res) => res.send("Hello from express!"));
app.get('/api/suggestion', async (req, res) => {
const query = req.query.q as string;
const t = parseInt(req.query.t as string || "0") || null;
let language = req.query.l as string || 'en-US';
try {
const data = await completeGoogle(query, language);
//logger.info({ type: "onesearch_search_autocomplete", query: query, data: data });
res.json({ ...data, time: t });
} catch (error) {
//logger.error({ type: "onesearch_search_autocomplete_error", error: error.message });
res.status(500).json({ error: 'Internal Server Error' });
}
});
async function helloMessage() { async function helloMessage() {
const { base } = await ViteExpress.getViteConfig(); const { base } = await ViteExpress.getViteConfig();
//console.clear(); //console.clear();