feature: engine selector, without nextui
This commit is contained in:
parent
4c9c83311b
commit
bfcf76fdc2
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}
|
{s.suggestion}
|
||||||
<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">
|
||||||
|
@ -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
148
components/picker.tsx
Normal 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";
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
@ -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",
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
16
server.ts
16
server.ts
@ -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();
|
||||||
|
Loading…
Reference in New Issue
Block a user