Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
2c1f4f28bf | ||
858a18bf4e | |||
![]() |
2ad65d177d | ||
![]() |
8d39e2833c | ||
![]() |
2265344952 | ||
![]() |
bfcf76fdc2 | ||
4c9c83311b | |||
![]() |
6960ec5b35 | ||
da709d2988 | |||
bfc8f08802 | |||
697da861ea | |||
65d4e76fa7 | |||
844333e623 | |||
![]() |
13e79e2869 | ||
![]() |
421e4fcdb8 | ||
![]() |
669ad510ed | ||
![]() |
5519d313ee | ||
![]() |
e7f6f69dfe | ||
![]() |
f85162622d | ||
![]() |
24f23b3573 | ||
![]() |
ff0d05542d | ||
![]() |
969ed948b5 | ||
![]() |
e3ec2bb897 | ||
![]() |
6fe4fc28c1 | ||
![]() |
1faee002a9 | ||
![]() |
38b144a6f4 | ||
![]() |
86d4015ae5 | ||
![]() |
b46f1adddb | ||
![]() |
1d7aaf6a8a | ||
![]() |
136450f93d | ||
![]() |
a9cf5630fe | ||
![]() |
a136c0ab9b | ||
![]() |
50c526c095 | ||
![]() |
7dd7b70db8 | ||
![]() |
f1d22f9d91 | ||
![]() |
04d72d3ca8 | ||
![]() |
8e65767ac1 | ||
![]() |
ced6bffc28 | ||
![]() |
67b5f47440 | ||
![]() |
7d412ad439 | ||
![]() |
5ca8d32778 | ||
![]() |
1ab4f1ce63 | ||
![]() |
6bcf445848 | ||
![]() |
8223e696f9 | ||
![]() |
8a9ef8fcb4 | ||
![]() |
b3255de375 | ||
![]() |
09a7941eca | ||
![]() |
090154115a | ||
![]() |
0aef1e1641 | ||
![]() |
c1930d6bd7 | ||
![]() |
24de7bcaa3 | ||
![]() |
62a0e85554 | ||
![]() |
1f5a36832c | ||
![]() |
09d099a625 | ||
![]() |
a5f5112fc1 | ||
![]() |
8c005b3769 | ||
![]() |
9a623164e4 | ||
![]() |
304ea14003 |
34
.gitignore
vendored
@ -1,3 +1,28 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
@ -34,4 +59,11 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.syncignore
|
.syncignore
|
||||||
|
|
||||||
|
# log
|
||||||
|
app.log
|
||||||
|
|
||||||
|
# doc
|
||||||
|
doc/.vitepress/dist
|
||||||
|
doc/.vitepress/cache
|
@ -3,7 +3,6 @@
|
|||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"printWidth": 120,
|
"printWidth": 100,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BIN
app/favicon.ico
Before Width: | Height: | Size: 25 KiB |
@ -1,33 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--foreground-rgb: 0, 0, 0;
|
|
||||||
--background-start-rgb: 214, 219, 220;
|
|
||||||
--background-end-rgb: 255, 255, 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--foreground-rgb: 255, 255, 255;
|
|
||||||
--background-start-rgb: 0, 0, 0;
|
|
||||||
--background-end-rgb: 0, 0, 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: rgb(var(--foreground-rgb));
|
|
||||||
background: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent,
|
|
||||||
rgb(var(--background-end-rgb))
|
|
||||||
)
|
|
||||||
rgb(var(--background-start-rgb));
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.text-balance {
|
|
||||||
text-wrap: balance;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import type { Metadata } from "next";
|
|
||||||
import { Inter } from "next/font/google";
|
|
||||||
import "./global.css";
|
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Create Next App",
|
|
||||||
description: "Generated by create next app"
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({
|
|
||||||
children
|
|
||||||
}: Readonly<{
|
|
||||||
children: React.ReactNode;
|
|
||||||
}>) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body className={inter.className}>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
12
app/page.tsx
@ -1,12 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { RecoilRoot } from "recoil";
|
|
||||||
import Homepage from "../components";
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<RecoilRoot>
|
|
||||||
<Homepage />
|
|
||||||
</RecoilRoot>
|
|
||||||
);
|
|
||||||
}
|
|
22
backend/route.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Express } from "express";
|
||||||
|
import { completeGoogle } from "search-engine-autocomplete";
|
||||||
|
|
||||||
|
export function configureBackendRoutes(app: Express) {
|
||||||
|
app.get('/api/v1/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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.get("/api/v1/ping", async (_, res) => {
|
||||||
|
res.status(200).json({message: "pong"});
|
||||||
|
})
|
||||||
|
}
|
@ -1,41 +1,20 @@
|
|||||||
import Image from "next/image";
|
import { useAtom } from "jotai";
|
||||||
import { useRecoilValue } from "recoil";
|
import { bgFocusAtom } from "../lib/state/background";
|
||||||
import { settingsState } from "./state/settings";
|
import BackgroundContainer from "./backgroundContainer";
|
||||||
import validUrl from "valid-url";
|
import useDarkMode from "lib/darkModeHook";
|
||||||
import validateColor from "validate-color";
|
|
||||||
|
|
||||||
function Background(props: {
|
export default function Background() {
|
||||||
isFocus: boolean;
|
const [isFocus, setFocus] = useAtom(bgFocusAtom);
|
||||||
src: string;
|
|
||||||
onClick: () => void;
|
const darkMode = useDarkMode();
|
||||||
}) {
|
|
||||||
const settings = useRecoilValue(settingsState);
|
return (
|
||||||
if (validateColor(props.src)) {
|
<div>
|
||||||
return (
|
{darkMode ? (
|
||||||
<div
|
<BackgroundContainer src="rgb(23,25,29)" isFocus={isFocus} onClick={() => setFocus(false)} darkMode={darkMode}/>
|
||||||
className="w-full h-full fixed object-cover inset-0 duration-200 z-0"
|
) : (
|
||||||
style={{ backgroundColor: props.src }}
|
<BackgroundContainer src="white" isFocus={isFocus} onClick={() => setFocus(false)} darkMode={darkMode}/>
|
||||||
onClick={props.onClick}
|
)}
|
||||||
></div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (validUrl.isWebUri(props.src)) {
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src={props.src}
|
|
||||||
className={
|
|
||||||
"w-full h-full fixed object-cover inset-0 duration-200 z-0 " +
|
|
||||||
(props.isFocus
|
|
||||||
? settings.bgBlur
|
|
||||||
? "blur-lg scale-110"
|
|
||||||
: "brightness-50 scale-105"
|
|
||||||
: "")
|
|
||||||
}
|
|
||||||
alt="background"
|
|
||||||
onClick={props.onClick}
|
|
||||||
fill={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Background;
|
|
||||||
|
50
components/backgroundContainer.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
import validUrl from "valid-url";
|
||||||
|
import validateColor from "validate-color";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
|
||||||
|
export default function BackgroundContainer(props: {
|
||||||
|
isFocus: boolean;
|
||||||
|
src: string;
|
||||||
|
darkMode: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
if (validateColor(props.src)) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full fixed object-cover inset-0 duration-200 z-0"
|
||||||
|
style={{ backgroundColor: props.src }}
|
||||||
|
onClick={props.onClick}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
} else if (validUrl.isWebUri(props.src)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={props.src}
|
||||||
|
className={
|
||||||
|
"w-full h-full fixed object-cover inset-0 duration-200 z-0 " +
|
||||||
|
(props.isFocus ? (settings.bgBlur ? "blur-lg scale-110" : "brightness-50 scale-105") : "")
|
||||||
|
}
|
||||||
|
alt="background"
|
||||||
|
onClick={props.onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (props.darkMode) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full fixed object-cover inset-0 duration-200 z-0 bg-[rgb(23,25,29)]"
|
||||||
|
onClick={props.onClick}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full h-full fixed object-cover inset-0 duration-200 z-0 bg-white"
|
||||||
|
onClick={props.onClick}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
50
components/engineSelector.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
import { engineTranslation } from "lib/onesearch/translatedEngineList";
|
||||||
|
import { settingsType } from "global";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import Picker, { PickedItem } from "./picker";
|
||||||
|
|
||||||
|
export default function EngineSelector(props: { className: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const settings: settingsType = useAtomValue(settingsAtom);
|
||||||
|
const engines = settings.searchEngines;
|
||||||
|
const currentEngine: string = settings.currentSearchEngine;
|
||||||
|
const [selected, setSelected] = useState(currentEngine);
|
||||||
|
const setSettings = useSetAtom(settingsAtom);
|
||||||
|
let engineList: PickedItem = {};
|
||||||
|
for (const engineKey of Object.keys(engines)) {
|
||||||
|
engineList[engineKey] = getName(engineKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getName(engineKey: string) {
|
||||||
|
return engineTranslation.includes(engineKey) ? t(`search.engine.${engineKey}`) : engineKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function setEngine(engine: string) {
|
||||||
|
setSettings((oldSettings: settingsType) => {
|
||||||
|
return {
|
||||||
|
...oldSettings,
|
||||||
|
currentSearchEngine: engine
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (selected !== currentEngine) {
|
||||||
|
setEngine(selected);
|
||||||
|
}
|
||||||
|
}, [currentEngine, selected, setSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Picker
|
||||||
|
selectionItems={engineList}
|
||||||
|
selected={selected}
|
||||||
|
selectionOnChange={(selected) => {
|
||||||
|
setSelected(selected);
|
||||||
|
}}
|
||||||
|
displayContent={getName(selected)}
|
||||||
|
className={props.className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
import { useRecoilState } from "recoil";
|
|
||||||
import Background from "./background";
|
|
||||||
import Search from "./search/search";
|
|
||||||
import { bgFocusState } from "./state/background";
|
|
||||||
|
|
||||||
export default function Homepage() {
|
|
||||||
const [isFocus, setFocus] = useRecoilState(bgFocusState);
|
|
||||||
return (
|
|
||||||
<div className="h-full fixed overflow-hidden w-full bg-black">
|
|
||||||
<Background
|
|
||||||
src="rgb(23,25,29)"
|
|
||||||
isFocus={isFocus}
|
|
||||||
onClick={() => setFocus(false)}
|
|
||||||
/>
|
|
||||||
<Search
|
|
||||||
onFocus={() => {
|
|
||||||
setFocus(true);
|
|
||||||
console.log("focus");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
26
components/onesearch/handleEnter.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { settingsType, suggestionItem } from "global";
|
||||||
|
import copyToClipboard from "lib/copy";
|
||||||
|
import { normalizeURL } from "lib/normalizeURL";
|
||||||
|
import search from "lib/search";
|
||||||
|
|
||||||
|
export default function (
|
||||||
|
index: number,
|
||||||
|
suggestion: suggestionItem[],
|
||||||
|
query: string,
|
||||||
|
settings: settingsType,
|
||||||
|
searchBoxRef: React.RefObject<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
const selected = suggestion[index];
|
||||||
|
const engine = settings.searchEngines[settings.currentSearchEngine];
|
||||||
|
const newTab = settings.searchInNewTab;
|
||||||
|
let clipboard: any;
|
||||||
|
if (selected.type === "QUERY" || selected.type === "default") {
|
||||||
|
search(selected.suggestion, engine, newTab);
|
||||||
|
} else if (selected.type === "NAVIGATION" || selected.type === "default-link") {
|
||||||
|
window.open(normalizeURL(selected.suggestion));
|
||||||
|
} else if (selected.type === "text") {
|
||||||
|
console.log("????");
|
||||||
|
copyToClipboard(selected.suggestion);
|
||||||
|
searchBoxRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
42
components/onesearch/handleNLUResult.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { suggestionItem } from "global";
|
||||||
|
import { findClosestDateIndex } from "lib/weather/getCurrentWeather";
|
||||||
|
import { getLocationNative } from "lib/weather/getLocation";
|
||||||
|
import { getWeather } from "lib/weather/getWeather";
|
||||||
|
import { WMOCodeTable } from "lib/weather/wmocode";
|
||||||
|
|
||||||
|
type UpdateSuggestionFunction = (data: suggestionItem[]) => void;
|
||||||
|
|
||||||
|
export function handleNLUResult(result: any, updateSuggestion: UpdateSuggestionFunction){
|
||||||
|
if (result.intent == "weather.summary") {
|
||||||
|
getLocationNative((data: GeolocationCoordinates | GeolocationPositionError) => {
|
||||||
|
console.log(data);
|
||||||
|
if (data instanceof GeolocationCoordinates) {
|
||||||
|
getWeather(data.latitude, data.longitude).then((weather) => {
|
||||||
|
console.log(weather["hourly"]);
|
||||||
|
let hourIndex = findClosestDateIndex(
|
||||||
|
weather["hourly"]["time"],
|
||||||
|
weather["utc_offset_seconds"]
|
||||||
|
);
|
||||||
|
let temp = weather["hourly"]["apparent_temperature"][hourIndex];
|
||||||
|
let weatherCode = weather["hourly"]["weather_code"][hourIndex];
|
||||||
|
console.log(temp, weatherCode, hourIndex);
|
||||||
|
updateSuggestion([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
suggestion: `Weather: ${temp}${weather["hourly_units"]["apparent_temperature"]}, ${WMOCodeTable[weatherCode]["day"].description}`,
|
||||||
|
relevance: 3000 * result.score
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (result.intent !== "None") {
|
||||||
|
updateSuggestion([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
suggestion: result.intent,
|
||||||
|
relevance: 2200 * result.score
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
30
components/onesearch/link.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { normalizeURL } from "lib/normalizeURL";
|
||||||
|
|
||||||
|
export default function Link(props: { children: React.ReactNode; query: string; selected: boolean }) {
|
||||||
|
if (props.selected) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full h-10 leading-10 bg-zinc-300 dark:bg-zinc-700
|
||||||
|
px-5 z-10 cursor-pointer duration-100`}
|
||||||
|
onClick={() => {
|
||||||
|
window.open(normalizeURL(props.query));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full h-10 leading-10 bg-zinc-100 hover:bg-zinc-300
|
||||||
|
dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}
|
||||||
|
onClick={() => {
|
||||||
|
window.open(normalizeURL(props.query));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
178
components/onesearch/onesearch.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import SuggestionBox from "./suggestionBox";
|
||||||
|
import { queryAtom } from "lib/state/query";
|
||||||
|
import { suggestionItem, suggestionsResponse } from "global";
|
||||||
|
import getSearchEngineName from "lib/onesearch/getSearchEngineName";
|
||||||
|
import PlainSearch from "./plainSearch";
|
||||||
|
import { suggestionAtom } from "lib/state/suggestion";
|
||||||
|
import validLink from "lib/url/validLink";
|
||||||
|
import Link from "./link";
|
||||||
|
import { selectedSuggestionAtom } from "lib/state/suggestionSelection";
|
||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
import PlainText from "./plainText";
|
||||||
|
import { sendError } from "lib/telemetering/sendError";
|
||||||
|
import { NLU } from "lib/nlp/load";
|
||||||
|
import { handleNLUResult } from "./handleNLUResult";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import i18next from "i18next";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function OneSearch() {
|
||||||
|
const [suggestion, setFinalSuggetsion] = useAtom(suggestionAtom);
|
||||||
|
const [manager, setManager] = useState(null);
|
||||||
|
const lastRequestTimeRef = useRef(0);
|
||||||
|
const selected = useAtomValue(selectedSuggestionAtom);
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const devMode = false;
|
||||||
|
const query = useAtomValue(queryAtom);
|
||||||
|
const engineName = getSearchEngineName();
|
||||||
|
const engine = settings.currentSearchEngine;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lang = i18next.language;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const time = new Date().getTime().toString();
|
||||||
|
if (query.trim() === "" || query.length > 120) {
|
||||||
|
cleanSuggestion("QUERY", "NAVIGATION");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`/api/v1/suggestion?q=${query}&l=${lang}&t=${time}&engine=${engine}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data: suggestionsResponse) => {
|
||||||
|
try {
|
||||||
|
const suggestionToUpdate: suggestionItem[] = data.suggestions;
|
||||||
|
if (data.time > lastRequestTimeRef.current) {
|
||||||
|
cleanSuggestion("NAVIGATION", "QUERY");
|
||||||
|
lastRequestTimeRef.current = data.time;
|
||||||
|
updateSuggestion(suggestionToUpdate);
|
||||||
|
}
|
||||||
|
} catch (error: Error | unknown) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
sendError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// Handle fetch error
|
||||||
|
sendError(error);
|
||||||
|
});
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
function updateSuggestion(data: suggestionItem[]) {
|
||||||
|
setFinalSuggetsion((cur: suggestionItem[]) => {
|
||||||
|
const types: string[] = [];
|
||||||
|
for (const sug of data) {
|
||||||
|
if (!types.includes(sug.type)) types.push(sug.type);
|
||||||
|
}
|
||||||
|
for (const type of types) {
|
||||||
|
cur = cur.filter((item) => {
|
||||||
|
return item.type !== type;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cur.concat(data).sort((a, b) => {
|
||||||
|
return b.relevance - a.relevance;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSuggestion(...types: string[]) {
|
||||||
|
setFinalSuggetsion((suggestion: suggestionItem[]) => {
|
||||||
|
return suggestion.filter((item) => {
|
||||||
|
return !types.includes(item.type);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const NLUModel = new NLU();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
NLUModel.init().then((nlu) => {
|
||||||
|
setManager(nlu.manager);
|
||||||
|
console.log(nlu.manager);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cleanSuggestion("default-link", "default", "text");
|
||||||
|
if (validLink(query)) {
|
||||||
|
updateSuggestion([
|
||||||
|
{ type: "default-link", suggestion: query, relevance: 3000, prompt: <span>Go to: </span> },
|
||||||
|
{ type: "default", suggestion: query, relevance: 1600 }
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
updateSuggestion([
|
||||||
|
{
|
||||||
|
type: "default",
|
||||||
|
suggestion: query,
|
||||||
|
relevance: 2000
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager != null) {
|
||||||
|
// @ts-ignore
|
||||||
|
manager.process(query).then((result) => {
|
||||||
|
console.log(result);
|
||||||
|
handleNLUResult(result, updateSuggestion);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [query, engineName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SuggestionBox>
|
||||||
|
{suggestion.map((s, i) => {
|
||||||
|
if (s.suggestion.trim() === "") return;
|
||||||
|
if (s.type === "default") {
|
||||||
|
return (
|
||||||
|
<PlainSearch key={i} query={s.suggestion} selected={i == selected}>
|
||||||
|
{s.suggestion}
|
||||||
|
<span className="text-zinc-700 dark:text-zinc-400 text-sm">
|
||||||
|
{t("search.search-help-text", { engine: engineName })}
|
||||||
|
</span>
|
||||||
|
{devMode && (
|
||||||
|
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
|
||||||
|
{s.relevance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PlainSearch>
|
||||||
|
);
|
||||||
|
} else if (s.type === "QUERY") {
|
||||||
|
return (
|
||||||
|
<PlainSearch key={i} query={s.suggestion} selected={i == selected}>
|
||||||
|
{s.suggestion}
|
||||||
|
{devMode && (
|
||||||
|
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
|
||||||
|
{s.relevance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PlainSearch>
|
||||||
|
);
|
||||||
|
} else if (s.type === "NAVIGATION" || s.type === "default-link") {
|
||||||
|
return (
|
||||||
|
<Link key={i} query={s.suggestion} selected={i == selected}>
|
||||||
|
{s.prompt && <span className="text-zinc-700 dark:text-zinc-400">{s.prompt}</span>}
|
||||||
|
{s.suggestion}
|
||||||
|
{devMode && (
|
||||||
|
<span className="absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
|
||||||
|
{s.relevance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else if (s.type === "text") {
|
||||||
|
return (
|
||||||
|
<PlainText key={i} selected={i == selected}>
|
||||||
|
{s.prompt && <span className="text-zinc-700 dark:text-zinc-400">{s.prompt}</span>}
|
||||||
|
<p>{s.suggestion}</p>
|
||||||
|
{devMode && (
|
||||||
|
<span className="bottom-0 absolute text-zinc-700 dark:text-zinc-400 text-sm leading-10 h-10 right-2">
|
||||||
|
{s.relevance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</PlainText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</SuggestionBox>
|
||||||
|
);
|
||||||
|
}
|
35
components/onesearch/plainSearch.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import search from "lib/search";
|
||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
|
||||||
|
export default function PlainSearch(props: { children: React.ReactNode; query: string; selected: boolean }) {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const engine = settings.searchEngines[settings.currentSearchEngine];
|
||||||
|
const newTab = settings.searchInNewTab;
|
||||||
|
if (props.selected) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-full h-10 leading-10 bg-zinc-300 dark:bg-zinc-700
|
||||||
|
px-5 z-10 cursor-pointer duration-100 truncate`}
|
||||||
|
onClick={() => {
|
||||||
|
search(props.query, engine, newTab);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-full h-10 leading-10 bg-zinc-100 hover:bg-zinc-300
|
||||||
|
dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100 truncate`}
|
||||||
|
onClick={() => {
|
||||||
|
search(props.query, engine, newTab);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
21
components/onesearch/plainText.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
export default function PlainText(props: { children: React.ReactNode; selected: boolean }) {
|
||||||
|
if (props.selected) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-full h-auto leading-6 break-all py-[0.6rem] bg-zinc-300 dark:bg-zinc-700
|
||||||
|
px-5 z-10 cursor-pointer duration-100`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-full h-auto leading-6 break-all py-[0.6rem] bg-zinc-100 hover:bg-zinc-300
|
||||||
|
dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
6
components/onesearch/suggestion.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default function Suggestion(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: `<p>${props.children}</p>` as string }} className={`relative w-full h-10 leading-10 bg-zinc-100 hover:bg-zinc-300 dark:bg-zinc-800 hover:dark:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
11
components/onesearch/suggestionBox.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export default function SuggestionBox(props: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative bg-zinc-100 dark:bg-zinc-800 w-11/12 sm:w-[700px] h-auto max-h-[calc(100vh-20rem)]
|
||||||
|
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}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
178
components/picker.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
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 listRect = itemListRef.current.getBoundingClientRect();
|
||||||
|
// Align to center
|
||||||
|
itemListRef.current.style.left =
|
||||||
|
Math.max(
|
||||||
|
Math.min(
|
||||||
|
buttonRect.x + buttonRect.width / 2 - listRect.width / 2,
|
||||||
|
window.screen.width - listRect.width - 16
|
||||||
|
),
|
||||||
|
0
|
||||||
|
) + "px";
|
||||||
|
if (window.screen.height - buttonRect.top < 192) {
|
||||||
|
itemListRef.current.style.transformOrigin = "bottom center";
|
||||||
|
itemListRef.current.style.top = buttonRect.top - listRect.height - 16 + "px";
|
||||||
|
} else {
|
||||||
|
itemListRef.current.style.top = buttonRect.y + buttonRect.height + 16 + "px";
|
||||||
|
}
|
||||||
|
if (listRect.top + listRect.height > window.screen.height - 16) {
|
||||||
|
itemListRef.current.style.height = window.screen.height - listRect.top - 12 + "px";
|
||||||
|
} else {
|
||||||
|
itemListRef.current.style.height = "fit-content";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.transitionDuration = "200ms";
|
||||||
|
itemListRef.current.style.opacity = "0%";
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
setDisplayList(false);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
function showList() {
|
||||||
|
setDisplayList(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!itemListRef.current || !buttonRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatePosition();
|
||||||
|
if (window.screen.height - buttonRef.current.getBoundingClientRect().top < 128) {
|
||||||
|
itemListRef.current.style.transformOrigin = "bottom center";
|
||||||
|
}
|
||||||
|
itemListRef.current.style.transitionDuration = "100ms";
|
||||||
|
itemListRef.current.style.opacity = "100%";
|
||||||
|
updatePosition();
|
||||||
|
const listRect = itemListRef.current.getBoundingClientRect();
|
||||||
|
if (listRect.top < 8) {
|
||||||
|
itemListRef.current.style.height = window.screen.height - 8 + "px";
|
||||||
|
itemListRef.current.style.top = "8px";
|
||||||
|
}
|
||||||
|
}, 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, toggleDisplay } = props;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="absolute w-screen h-screen" onClick={()=>{toggleDisplay(false)}}>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="overflow-y-auto fixed w-fit text-black dark:text-white opacity-0 duration-200
|
||||||
|
bg-zinc-50 shadow-lg border-1 border-zinc-200 dark:border-zinc-600
|
||||||
|
dark:bg-zinc-800 px-2 py-2 rounded-xl text-align-left"
|
||||||
|
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-200 dark:hover:bg-zinc-700 flex justify-between items-center"
|
||||||
|
onClick={() => {
|
||||||
|
selectionOnChange(key);
|
||||||
|
toggleDisplay(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{selectionItems[key]}</span>
|
||||||
|
<div className="relative w-16"></div>
|
||||||
|
{key === selected && (
|
||||||
|
<Icon className="relative right-2" icon="carbon:checkmark" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PickerList.displayName = "PickerList";
|
89
components/search.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { KeyboardEvent, useRef } from "react";
|
||||||
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
import { queryAtom } from "lib/state/query";
|
||||||
|
import { selectedSuggestionAtom } from "lib/state/suggestionSelection";
|
||||||
|
import handleEnter from "lib/onesearch/handleEnter";
|
||||||
|
import { suggestionAtom } from "lib/state/suggestion";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function Search(props: { onFocus: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const [query, setQuery] = useAtom(queryAtom);
|
||||||
|
const [selectedSuggestion, setSelected] = useAtom(selectedSuggestionAtom);
|
||||||
|
const suggestions = useAtomValue(suggestionAtom);
|
||||||
|
const searchBoxRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const style = "default";
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key == "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEnter(selectedSuggestion, suggestions, query, settings, searchBoxRef);
|
||||||
|
return;
|
||||||
|
} else if (e.key == "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const len = suggestions.length;
|
||||||
|
setSelected((selectedSuggestion - 1 + len) % len);
|
||||||
|
} else if (e.key == "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
const len = suggestions.length;
|
||||||
|
setSelected((selectedSuggestion + 1) % len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === "default") {
|
||||||
|
return (
|
||||||
|
// 祖传样式,勿动
|
||||||
|
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
|
||||||
|
<input
|
||||||
|
className="absolute z-1 w-11/12 sm:w-[700px] h-10 rounded-lg left-1/2 translate-x-[-50%]
|
||||||
|
text-center outline-none border-2 focus:border-2 duration-200 pr-2 shadow-md focus:shadow-none
|
||||||
|
dark:shadow-zinc-800 dark:shadow-md bg-white dark:bg-[rgb(23,25,29)]
|
||||||
|
dark:border-neutral-500 dark:focus:border-neutral-300 placeholder:text-slate-500
|
||||||
|
dark:placeholder:text-slate-400 text-slate-900 dark:text-white"
|
||||||
|
id="searchBox"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search.placeholder")}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
onKeyDown={handleKeydown}
|
||||||
|
onChange={(e) =>
|
||||||
|
setQuery(() => {
|
||||||
|
return e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
ref={searchBoxRef}
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (style == "image") {
|
||||||
|
return (
|
||||||
|
// 祖传样式,勿动
|
||||||
|
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
|
||||||
|
<input
|
||||||
|
className={
|
||||||
|
`absolute z-1 w-2/3 sm:w-80 md:w-[400px] focus:w-11/12 focus:sm:w-[700px] hover:w-11/12
|
||||||
|
hover:sm:w-[700px] h-10 rounded-3xl left-1/2 translate-x-[-50%] text-center outline-none
|
||||||
|
border-solid border-0 duration-200 pr-2 shadow-md focus:shadow-none` +
|
||||||
|
(settings.bgBlur
|
||||||
|
? `bg-[rgba(255,255,255,0.5)] dark:bg-[rgba(24,24,24,0.75)] backdrop-blur-xl
|
||||||
|
placeholder:text-slate-500 dark:placeholder:text-slate-400 text-slate-900 dark:text-white`
|
||||||
|
: `bg-[rgba(235,235,235,0.9)] dark:bg-[rgba(20,20,20,0.9)] placeholder:text-slate-500
|
||||||
|
text-slate-800 dark:text-white`)
|
||||||
|
}
|
||||||
|
id="searchBox"
|
||||||
|
type="text"
|
||||||
|
placeholder="placeholder"
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
ref={searchBoxRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,48 +0,0 @@
|
|||||||
import { atom, useRecoilValue } from "recoil";
|
|
||||||
import { settingsState } from "../state/settings";
|
|
||||||
|
|
||||||
export default function Search(props: {
|
|
||||||
onFocus: () => void;
|
|
||||||
}) {
|
|
||||||
const settings = useRecoilValue(settingsState);
|
|
||||||
let style = "default";
|
|
||||||
if (style === "default") {
|
|
||||||
return (
|
|
||||||
// 祖传样式,勿动
|
|
||||||
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
|
|
||||||
<input
|
|
||||||
className="absolute z-1 w-11/12 sm:w-[700px] h-10 rounded-lg left-1/2 translate-x-[-50%]
|
|
||||||
text-center outline-none border-[1px] focus:border-2 duration-200 pr-2 shadow-lg bg-white dark:bg-[rgb(23,25,29)]
|
|
||||||
dark:border-neutral-500 dark:focus:border-neutral-300 placeholder:text-slate-500
|
|
||||||
dark:placeholder:text-slate-400 text-slate-900 dark:text-white"
|
|
||||||
id="searchBox"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search"
|
|
||||||
onFocus={props.onFocus}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else if (style == "image") {
|
|
||||||
return (
|
|
||||||
// 祖传样式,勿动
|
|
||||||
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
|
|
||||||
<input
|
|
||||||
className={
|
|
||||||
`absolute z-1 w-2/3 sm:w-80 md:w-[400px] focus:w-11/12 focus:sm:w-[700px] hover:w-11/12
|
|
||||||
hover:sm:w-[700px] h-10 rounded-3xl left-1/2 translate-x-[-50%] text-center outline-none
|
|
||||||
border-solid border-0 duration-200 pr-2 shadow-lg` +
|
|
||||||
(settings.bgBlur
|
|
||||||
? `bg-[rgba(255,255,255,0.5)] dark:bg-[rgba(24,24,24,0.75)] backdrop-blur-xl
|
|
||||||
placeholder:text-slate-500 dark:placeholder:text-slate-400 text-slate-900 dark:text-white`
|
|
||||||
: `bg-[rgba(235,235,235,0.9)] dark:bg-[rgba(20,20,20,0.9)] placeholder:text-slate-500
|
|
||||||
text-slate-800 dark:text-white`)
|
|
||||||
}
|
|
||||||
id="searchBox"
|
|
||||||
type="text"
|
|
||||||
placeholder="Search"
|
|
||||||
onFocus={props.onFocus}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
12
components/selectorItem.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { selectionType } from "./picker";
|
||||||
|
|
||||||
|
export type selectedOnChange = (target: selectionType) => void;
|
||||||
|
|
||||||
|
export default function SelectionItem(props: {key: selectionType, children: ReactNode, onChange: selectedOnChange}){
|
||||||
|
return (
|
||||||
|
<div onClick={() => props.onChange(props.key)}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,10 +0,0 @@
|
|||||||
import { atom, selector } from "recoil";
|
|
||||||
|
|
||||||
const bgFocusState = atom({
|
|
||||||
key: "isBackgroundFocus",
|
|
||||||
default: false
|
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
|
||||||
bgFocusState,
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
import { atom, selector } from "recoil";
|
|
||||||
|
|
||||||
const settingsState = atom({
|
|
||||||
key: "settings",
|
|
||||||
default: {
|
|
||||||
version: 1,
|
|
||||||
elementBackdrop: true,
|
|
||||||
bgBlur: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
|
||||||
settingsState,
|
|
||||||
}
|
|
48
components/time.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Time(props: {
|
||||||
|
showSecond: boolean
|
||||||
|
}) {
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCurrentTime(new Date());
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = () => {
|
||||||
|
const hours = currentTime.getHours().toString().padStart(2, "0");
|
||||||
|
const minutes = currentTime.getMinutes().toString().padStart(2, "0");
|
||||||
|
const seconds = currentTime.getSeconds().toString().padStart(2, "0");
|
||||||
|
|
||||||
|
if (props.showSecond) {
|
||||||
|
return `${hours}:${minutes}:${seconds}`;
|
||||||
|
} else {
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-20 lg:top-44 short:top-0 translate-x-[-50%]
|
||||||
|
left-1/2 w-11/12 sm:w-[700px] text:black
|
||||||
|
dark:text-white text-3xl text-left text-shadow-lg"
|
||||||
|
>
|
||||||
|
{formatTime()}{" "}
|
||||||
|
<span className="text-lg leading-9 relative">
|
||||||
|
{new Intl.DateTimeFormat(navigator.language, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
}).format(currentTime)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
34
global.d.ts
vendored
@ -1,3 +1,31 @@
|
|||||||
type settings = {
|
import { Suggestion } from "search-engine-autocomplete";
|
||||||
bgBlur: boolean
|
|
||||||
};
|
interface settingsType extends object{
|
||||||
|
"version": number,
|
||||||
|
"elementBackdrop": boolean,
|
||||||
|
"bgBlur": boolean,
|
||||||
|
"timeShowSecond": boolean,
|
||||||
|
"currentSearchEngine": string,
|
||||||
|
"searchInNewTab": boolean,
|
||||||
|
"searchEngines": {
|
||||||
|
[key: string]: string
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface suggestionsResponse extends object{
|
||||||
|
suggestions: Suggestion[],
|
||||||
|
query: string,
|
||||||
|
verbatimRelevance: number,
|
||||||
|
time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type suggestionItem = {
|
||||||
|
suggestion: string,
|
||||||
|
type: string,
|
||||||
|
relativeRelevance?: number,
|
||||||
|
relevance: number,
|
||||||
|
prompt?: string | React.ReactElement,
|
||||||
|
intention?: string | null,
|
||||||
|
probability?: number,
|
||||||
|
confidence?: number,
|
||||||
|
}
|
5
i18n/ar.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "ابحث أو اكتب عنوان URL"
|
||||||
|
}
|
||||||
|
}
|
5
i18n/de.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "Suche oder gib eine URL ein"
|
||||||
|
}
|
||||||
|
}
|
33
i18n/en.json
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"search": {
|
||||||
|
"placeholder": "Search or type a URL",
|
||||||
|
"engine-aria": "Switch search engine",
|
||||||
|
"engine": {
|
||||||
|
"google": "Google",
|
||||||
|
"baidu": "Baidu",
|
||||||
|
"bing": "Bing",
|
||||||
|
"duckduckgo": "DuckDuckGo",
|
||||||
|
"yandex": "Yandex",
|
||||||
|
"yahoo": "Yahoo",
|
||||||
|
"ecosia": "Ecosia"
|
||||||
|
},
|
||||||
|
"search-help-text": "Search {engine}"
|
||||||
|
},
|
||||||
|
"notfound": {
|
||||||
|
"title": "page not found",
|
||||||
|
"desc": "Please check if there is a typo in the URL. <br/>If SparkHome brought you to this page,<br/> please <a style=\"text-decoration:underline;\" href=\"mailto:contact@alikia2x.com\">contact us.</a>"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "SparkHome"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"base64": {
|
||||||
|
"title": "Base64 tools - LuminaraUtils",
|
||||||
|
"decode": "Decode",
|
||||||
|
"encode": "Encode",
|
||||||
|
"result": "Result: ",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
i18n/es.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "Buscar o escribir una URL"
|
||||||
|
}
|
||||||
|
}
|
5
i18n/fr.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "Rechercher ou saisir une URL"
|
||||||
|
}
|
||||||
|
}
|
5
i18n/it.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "Cerca o digita un URL"
|
||||||
|
}
|
||||||
|
}
|
12
i18n/ja.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"search": {
|
||||||
|
"placeholder": "検索またはURLを入力"
|
||||||
|
},
|
||||||
|
"Search": {
|
||||||
|
"engine-aria": "検索エンジンを切り替える",
|
||||||
|
"engine": {
|
||||||
|
"google": "Google"
|
||||||
|
},
|
||||||
|
"placeholder": "検索またはURLを入力"
|
||||||
|
}
|
||||||
|
}
|
5
i18n/ko.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "검색 또는 URL 입력"
|
||||||
|
}
|
||||||
|
}
|
5
i18n/pt.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "Pesquisar ou digitar uma URL"
|
||||||
|
}
|
||||||
|
}
|
5
i18n/ru.json
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"search" : {
|
||||||
|
"placeholder" : "Искать или ввести URL"
|
||||||
|
}
|
||||||
|
}
|
53
i18n/zh.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"search": {
|
||||||
|
"placeholder": "搜索或输入网址",
|
||||||
|
"engine-aria": "切换搜索引擎",
|
||||||
|
"engine": {
|
||||||
|
"google": "谷歌",
|
||||||
|
"baidu": "百度",
|
||||||
|
"bing": "必应",
|
||||||
|
"duckduckgo": "DuckDuckGo",
|
||||||
|
"yandex": "Yandex",
|
||||||
|
"yahoo": "雅虎",
|
||||||
|
"ecosia": "Ecosia"
|
||||||
|
},
|
||||||
|
"search-help-text": "用 {engine} 搜索"
|
||||||
|
},
|
||||||
|
"Search": {
|
||||||
|
"placeholder": "搜索或输入网址",
|
||||||
|
"engine-aria": "切换搜索引擎",
|
||||||
|
"engine": {
|
||||||
|
"google": "谷歌",
|
||||||
|
"baidu": "百度",
|
||||||
|
"bing": "必应",
|
||||||
|
"duckduckgo": "DuckDuckGo",
|
||||||
|
"yandex": "Yandex",
|
||||||
|
"yahoo": "雅虎",
|
||||||
|
"ecosia": "Ecosia"
|
||||||
|
},
|
||||||
|
"search-help-text": "用 {engine} 搜索"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "页面未找到"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "星火主页"
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"base64": {
|
||||||
|
"title": "Base64 工具",
|
||||||
|
"decode": "解码",
|
||||||
|
"encode": "编码",
|
||||||
|
"result": "结果: ",
|
||||||
|
"copy": "复制",
|
||||||
|
"copied": "已复制"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notfound": {
|
||||||
|
"desc": "请检查网址是否出错。 <br/>如果你从星火主页跳转到这里,<br/> 请 <a style=\"text-decoration:underline;\" href=\"mailto:contact@alikia2x.com\">联系我们</a>",
|
||||||
|
"title": "网页不存在"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "星火主页"
|
||||||
|
}
|
||||||
|
}
|
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SparkHome</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8
lib/copy.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default function copyToClipboard(value: string){
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
23
lib/darkModeHook.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
// Custom React Hook for dark mode detect
|
||||||
|
export default function useDarkMode() {
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const colorSchemeQueryList = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
setDarkMode(colorSchemeQueryList.matches ? true : false);
|
||||||
|
|
||||||
|
const handleChange = () => {
|
||||||
|
setDarkMode(colorSchemeQueryList.matches ? true : false);
|
||||||
|
};
|
||||||
|
|
||||||
|
colorSchemeQueryList.addEventListener("change", handleChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
colorSchemeQueryList.removeEventListener("change", handleChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return darkMode;
|
||||||
|
}
|
10246
lib/license.txt
Normal file
134
lib/nlp/data/en.json
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
{
|
||||||
|
"weather.summary": [
|
||||||
|
"how's the weather",
|
||||||
|
"What's going on with the weather?",
|
||||||
|
"Can you give me an update on the weather?",
|
||||||
|
"How's the forecast looking today?",
|
||||||
|
"Give me a summary of the current weather.",
|
||||||
|
"Can you tell me the current weather?",
|
||||||
|
"What is the weather situation at the moment?",
|
||||||
|
"Could you provide a quick weather update?",
|
||||||
|
"Is it raining or sunny outside?",
|
||||||
|
"What's the weather like right now?",
|
||||||
|
"Tell me the current weather conditions.",
|
||||||
|
"How about the weather today?",
|
||||||
|
"Is it a good day to be outside?",
|
||||||
|
"What should I expect in terms of weather today?",
|
||||||
|
"Is there any severe weather to be aware of?",
|
||||||
|
"Can you summarize today's weather forecast?",
|
||||||
|
"What's the weather looking like for the next few hours?",
|
||||||
|
"Is it going to stay this way all day?",
|
||||||
|
"Could you give me a brief overview of the weather?",
|
||||||
|
"What's the general weather situation in our area?",
|
||||||
|
"Is it cloudy or clear outside?",
|
||||||
|
"Any weather alerts I should know about?",
|
||||||
|
"How's the weather looking for outdoor activities?",
|
||||||
|
"What's the forecast saying for today's weather?",
|
||||||
|
"Is it going to be a warm day?",
|
||||||
|
"Are we expecting any storms today?",
|
||||||
|
"What's the weather condition outside my window?",
|
||||||
|
"Is it a typical day for this season in terms of weather?",
|
||||||
|
"how's the weather now?"
|
||||||
|
],
|
||||||
|
|
||||||
|
"weather.temp": [
|
||||||
|
"What's the temperature like right now?",
|
||||||
|
"Can you tell me the current temperature?",
|
||||||
|
"How hot is it outside?",
|
||||||
|
"What's the temperature supposed to be today?",
|
||||||
|
"What is the current temp outside?",
|
||||||
|
"Could you tell me the outdoor temperature?",
|
||||||
|
"Is it cold or warm outside?",
|
||||||
|
"What's the high temperature for today?",
|
||||||
|
"What's the low temperature expected tonight?",
|
||||||
|
"How does the temperature feel outside?",
|
||||||
|
"Is it going to get warmer or cooler today?",
|
||||||
|
"What's the temperature in the shade?",
|
||||||
|
"Can you provide the current temp in Celsius?",
|
||||||
|
"What's the temperature in Fahrenheit right now?",
|
||||||
|
"Is it too hot to be outside?",
|
||||||
|
"What's the temperature like in the morning?",
|
||||||
|
"How about the temperature in the evening?",
|
||||||
|
"Is it warm enough to go swimming?",
|
||||||
|
"What's the temperature in the city center?",
|
||||||
|
"Can you tell me the temp in the nearby area?",
|
||||||
|
"Is it below freezing outside?",
|
||||||
|
"What's the average temperature for today?",
|
||||||
|
"Is the temperature dropping or rising?",
|
||||||
|
"What should I wear considering the temperature?"
|
||||||
|
],
|
||||||
|
|
||||||
|
"base64.encode": [
|
||||||
|
"Please encode this data with base64: %s",
|
||||||
|
"I need to encode the following data in base64: %s",
|
||||||
|
"Could you encode this string using base64? %s",
|
||||||
|
"Convert this data to b64 encoding: %s",
|
||||||
|
"I want to encode this information with base64: %s",
|
||||||
|
"Help me encode this in base64: %s",
|
||||||
|
"Can you encode this data to base64 format? %s",
|
||||||
|
"b64 encode",
|
||||||
|
"base64 encode",
|
||||||
|
"encode base64 %s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"base64.decode": [
|
||||||
|
"Please decode this base64 data: %s",
|
||||||
|
"I have a base64 encoded string that needs decoding: %s",
|
||||||
|
"Could you decode this base64 string for me? %s",
|
||||||
|
"Convert this base64 encoded data back to its original form: %s",
|
||||||
|
"I need to decode this base64 information: %s",
|
||||||
|
"Help me decode this base64 data: %s",
|
||||||
|
"Can you translate this base64 back to normal text? %s",
|
||||||
|
"b64 decode",
|
||||||
|
"base64 decode",
|
||||||
|
"decode base64 %s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"url.encode": [
|
||||||
|
"Please encode this URL: %s",
|
||||||
|
"I need to encode this URL component: %s",
|
||||||
|
"Could you encode this part of the URL? %s",
|
||||||
|
"Convert this URL to its encoded form: %s",
|
||||||
|
"I want to encode this URL for safe transmission: %s",
|
||||||
|
"Help me encode this URL segment: %s",
|
||||||
|
"Can you encode this URL data? %s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"url.decode": [
|
||||||
|
"Please decode this URL: %s",
|
||||||
|
"I have an encoded URL that needs decoding: %s",
|
||||||
|
"Could you decode this URL for me? %s",
|
||||||
|
"Convert this encoded URL back to its original form: %s",
|
||||||
|
"I need to decode this URL component: %s",
|
||||||
|
"Help me decode this URL segment: %s",
|
||||||
|
"Can you translate this encoded URL back to normal? %s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"html.encode": [
|
||||||
|
"Please encode this HTML entity: %s",
|
||||||
|
"I need to encode this text to HTML entity: %s",
|
||||||
|
"Could you encode this as an HTML entity? %s",
|
||||||
|
"Convert this text to HTML entity encoding: %s",
|
||||||
|
"I want to encode this to prevent HTML interpretation: %s",
|
||||||
|
"Help me encode this into HTML entity: %s",
|
||||||
|
"Can you encode this for HTML usage? %s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"html.decode": [
|
||||||
|
"Please decode this HTML entity: %s",
|
||||||
|
"I have an HTML entity that needs decoding: %s",
|
||||||
|
"Could you decode this HTML entity for me? %s",
|
||||||
|
"Convert this HTML entity back to its original text: %s",
|
||||||
|
"I need to decode this HTML entity to plain text: %s",
|
||||||
|
"Help me decode this HTML entity: %s",
|
||||||
|
"Can you translate this HTML entity back to normal text? %s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"None": [
|
||||||
|
"free weather api",
|
||||||
|
"js get timezone",
|
||||||
|
"how",
|
||||||
|
"how's",
|
||||||
|
"how's the"
|
||||||
|
]
|
||||||
|
}
|
127
lib/nlp/data/zh.json
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"weather.summary": [
|
||||||
|
"天气如何",
|
||||||
|
"现在的天气",
|
||||||
|
"今天的天气预报",
|
||||||
|
"现在的天气状况",
|
||||||
|
"今天天气怎么样",
|
||||||
|
"目前是什么天气",
|
||||||
|
"今天的天气概述",
|
||||||
|
"当前天气状况如何",
|
||||||
|
"今天会下雨吗",
|
||||||
|
"今天会下雪吗",
|
||||||
|
"今天晴天吗",
|
||||||
|
"今天的天气状况如何",
|
||||||
|
"现在外面是什么天气",
|
||||||
|
"今天天气好么",
|
||||||
|
"今天适合外出吗",
|
||||||
|
"今天的天气适宜做什么",
|
||||||
|
"今天有没有雾霾",
|
||||||
|
"今天的空气质量如何",
|
||||||
|
"今天的紫外线指数是多少",
|
||||||
|
"今天有没有大风",
|
||||||
|
"今天会不会很冷",
|
||||||
|
"今天的天气会变化吗",
|
||||||
|
"今天晚上的天气如何",
|
||||||
|
"今天夜里会下雨吗",
|
||||||
|
"今天的天气对出行有影响吗",
|
||||||
|
"今天的天气对运动有影响吗",
|
||||||
|
"今天的天气对工作有影响吗",
|
||||||
|
"今天的天气对旅游有影响吗",
|
||||||
|
"今天的天气对健康有影响吗"
|
||||||
|
],
|
||||||
|
"weather.temp": [
|
||||||
|
"现在的温度",
|
||||||
|
"现在多少度",
|
||||||
|
"外面有多热",
|
||||||
|
"明天热不热?",
|
||||||
|
"现在的气温是多少",
|
||||||
|
"今天最高温度是多少",
|
||||||
|
"今天最低温度是多少",
|
||||||
|
"现在外面感觉冷吗",
|
||||||
|
"现在需要穿外套吗",
|
||||||
|
"现在适合穿短袖吗",
|
||||||
|
"现在的温度适合外出吗",
|
||||||
|
"现在的温度适合运动吗",
|
||||||
|
"现在的温度适合睡觉吗",
|
||||||
|
"明天会比今天热吗",
|
||||||
|
"明天会比今天冷吗",
|
||||||
|
"今天的温度变化大吗",
|
||||||
|
"现在的温度适合开空调吗",
|
||||||
|
"现在的温度适合开暖气吗",
|
||||||
|
"室外的温度是多少",
|
||||||
|
"室内的温度是多少",
|
||||||
|
"现在的温度适合种植吗",
|
||||||
|
"现在的温度适合养宠物吗",
|
||||||
|
"现在的温度对健康有影响吗",
|
||||||
|
"现在的温度是否舒适",
|
||||||
|
"现在的温度是否适合工作"
|
||||||
|
],
|
||||||
|
"base64.encode": [
|
||||||
|
"请将数据使用base64编码:%s",
|
||||||
|
"需要将以下数据base64编码:%s",
|
||||||
|
"请将此字符串转为base64:%s",
|
||||||
|
"将数据转为base64编码:%s",
|
||||||
|
"信息base64编码:%s",
|
||||||
|
"请帮忙编码base64:%s",
|
||||||
|
"将数据编码为base64:%s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"base64.decode": [
|
||||||
|
"请解码这个base64数据:%s",
|
||||||
|
"有base64编码字符串需要解码:%s",
|
||||||
|
"帮忙解码base64:%s",
|
||||||
|
"将base64编码转回原数据:%s",
|
||||||
|
"解码base64信息:%s",
|
||||||
|
"解码这个base64:%s",
|
||||||
|
"将base64转文本:%s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"url.encode": [
|
||||||
|
"请编码这个URL:%s",
|
||||||
|
"URL部分需要编码:%s",
|
||||||
|
"请将URL部分编码:%s",
|
||||||
|
"URL编码转换:%s",
|
||||||
|
"安全传输需编码URL:%s",
|
||||||
|
"编码URL段:%s",
|
||||||
|
"URL数据编码:%s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"url.decode": [
|
||||||
|
"请解码这个URL:%s",
|
||||||
|
"有URL编码需要解码:%s",
|
||||||
|
"解码这个URL:%s",
|
||||||
|
"URL编码转回原URL:%s",
|
||||||
|
"解码URL部分:%s",
|
||||||
|
"解码URL段:%s",
|
||||||
|
"URL编码转文本:%s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"html.encode": [
|
||||||
|
"请编码HTML实体:%s",
|
||||||
|
"文本转为HTML实体:%s",
|
||||||
|
"编码为HTML实体:%s",
|
||||||
|
"文本HTML实体编码:%s",
|
||||||
|
"预防HTML解析编码:%s",
|
||||||
|
"HTML实体编码:%s",
|
||||||
|
"文本HTML使用编码:%s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"html.decode": [
|
||||||
|
"请解码HTML实体:%s",
|
||||||
|
"HTML实体需要解码:%s",
|
||||||
|
"解码HTML实体:%s",
|
||||||
|
"HTML实体转回文本:%s",
|
||||||
|
"HTML实体解码:%s",
|
||||||
|
"解码HTML实体:%s",
|
||||||
|
"HTML实体转文本:%s"
|
||||||
|
],
|
||||||
|
|
||||||
|
"None": [
|
||||||
|
"你好",
|
||||||
|
"为什么计算机使用二进制",
|
||||||
|
"什么是",
|
||||||
|
"热",
|
||||||
|
"怎么"
|
||||||
|
]
|
||||||
|
}
|
47
lib/nlp/load.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import { containerBootstrap } from "@nlpjs/core";
|
||||||
|
// @ts-ignore
|
||||||
|
import { Nlp } from "@nlpjs/nlp";
|
||||||
|
// @ts-ignore
|
||||||
|
import { NluManager, NluNeural } from "@nlpjs/nlu";
|
||||||
|
// @ts-ignore
|
||||||
|
import { LangEn } from "@nlpjs/lang-en-min";
|
||||||
|
// @ts-ignore
|
||||||
|
import { LangZh } from "@nlpjs/lang-zh";
|
||||||
|
import * as fflate from 'fflate';
|
||||||
|
|
||||||
|
export class NLU {
|
||||||
|
manager: any;
|
||||||
|
inited: boolean = false;
|
||||||
|
async loadIntentionModel() {
|
||||||
|
const container = await containerBootstrap();
|
||||||
|
container.use(Nlp);
|
||||||
|
container.use(LangEn);
|
||||||
|
container.use(LangZh);
|
||||||
|
container.use(NluNeural);
|
||||||
|
const manager = new NluManager({
|
||||||
|
container,
|
||||||
|
locales: ["en", "zh"],
|
||||||
|
nlu: {
|
||||||
|
useNoneFeature: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await fetch("/model");
|
||||||
|
|
||||||
|
const responseBuf = await response.arrayBuffer();
|
||||||
|
const compressed = new Uint8Array(responseBuf);
|
||||||
|
const decompressed = fflate.decompressSync(compressed);
|
||||||
|
const modelText = fflate.strFromU8(decompressed);
|
||||||
|
manager.fromJSON(JSON.parse(modelText));
|
||||||
|
this.manager = manager;
|
||||||
|
}
|
||||||
|
async init() {
|
||||||
|
await this.loadIntentionModel();
|
||||||
|
this.inited = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
async process(lang: string, text: string): Promise<any> {
|
||||||
|
const actual = await this.manager.process(lang, text);
|
||||||
|
return actual;
|
||||||
|
}
|
||||||
|
}
|
71
lib/nlp/train.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import { containerBootstrap } from "@nlpjs/core";
|
||||||
|
// @ts-ignore
|
||||||
|
import { Nlp } from "@nlpjs/nlp";
|
||||||
|
// @ts-ignore
|
||||||
|
import { NluManager, NluNeural } from "@nlpjs/nlu";
|
||||||
|
// @ts-ignore
|
||||||
|
import { LangEn } from "@nlpjs/lang-en-min";
|
||||||
|
// @ts-ignore
|
||||||
|
import { LangZh } from "@nlpjs/lang-zh";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import * as fflate from 'fflate';
|
||||||
|
|
||||||
|
let zh: TrainData = {};
|
||||||
|
let en: TrainData = {};
|
||||||
|
|
||||||
|
type TrainData = {
|
||||||
|
[key: string]: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function trainIntentionModel() {
|
||||||
|
try {
|
||||||
|
const dataZH = fs.readFileSync("./lib/nlp/data/zh.json", "utf8");
|
||||||
|
const dataEN = fs.readFileSync("./lib/nlp/data/en.json", "utf8");
|
||||||
|
zh = JSON.parse(dataZH);
|
||||||
|
en = JSON.parse(dataEN);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = await containerBootstrap();
|
||||||
|
container.use(Nlp);
|
||||||
|
container.use(LangEn);
|
||||||
|
container.use(LangZh);
|
||||||
|
container.use(NluNeural);
|
||||||
|
const manager = new NluManager({
|
||||||
|
container,
|
||||||
|
locales: ["en", "zh"],
|
||||||
|
nlu: {
|
||||||
|
useNoneFeature: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Adds the utterances and intents for the NLP
|
||||||
|
|
||||||
|
for (const key in zh) {
|
||||||
|
for (const value of zh[key]) {
|
||||||
|
manager.add("zh", value, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in en) {
|
||||||
|
for (const value of en[key]) {
|
||||||
|
manager.add("en", value, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.train();
|
||||||
|
|
||||||
|
const resultModel = manager.toJSON();
|
||||||
|
|
||||||
|
const buf = fflate.strToU8(JSON.stringify(resultModel));
|
||||||
|
|
||||||
|
const gzipped = fflate.gzipSync(buf, {
|
||||||
|
filename: 'model.json',
|
||||||
|
mtime: new Date().getTime()
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.writeFileSync("./public/model", Buffer.from(gzipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
trainIntentionModel();
|
18
lib/normalizeURL.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
export function normalizeURL(input: string): string {
|
||||||
|
try {
|
||||||
|
// try to create a URL object
|
||||||
|
const url = new URL(input);
|
||||||
|
// if the URL is valid, return it
|
||||||
|
return url.href;
|
||||||
|
} catch (error) {
|
||||||
|
// if the URL is invalid, try to add the protocol
|
||||||
|
const withHTTP = "http://" + input;
|
||||||
|
try {
|
||||||
|
const urlWithHTTP = new URL(withHTTP);
|
||||||
|
return urlWithHTTP.href;
|
||||||
|
} catch (error) {
|
||||||
|
// if the URL is still invalid, return the original input
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
lib/onesearch/getSearchEngineName.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { engineTranslation } from "lib/onesearch/translatedEngineList";
|
||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
import { settingsType } from "global";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function(){
|
||||||
|
const settings: settingsType = useAtomValue(settingsAtom);
|
||||||
|
const currentEngine = settings.currentSearchEngine;
|
||||||
|
const displayEngine = getName(currentEngine);
|
||||||
|
return displayEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getName(engineKey: string) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return engineTranslation.includes(engineKey) ? t(`search.engine.${engineKey}`) : engineKey;
|
||||||
|
}
|
25
lib/onesearch/handleEnter.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { settingsType, suggestionItem } from "global";
|
||||||
|
import copyToClipboard from "lib/copy";
|
||||||
|
import { normalizeURL } from "lib/normalizeURL";
|
||||||
|
import search from "lib/search";
|
||||||
|
|
||||||
|
export default function (
|
||||||
|
index: number,
|
||||||
|
suggestion: suggestionItem[],
|
||||||
|
_query: string,
|
||||||
|
settings: settingsType,
|
||||||
|
searchBoxRef: React.RefObject<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
const selected = suggestion[index];
|
||||||
|
const engine = settings.searchEngines[settings.currentSearchEngine];
|
||||||
|
const newTab = settings.searchInNewTab;
|
||||||
|
if (selected.type === "QUERY" || selected.type === "default") {
|
||||||
|
search(selected.suggestion, engine, newTab);
|
||||||
|
} else if (selected.type === "NAVIGATION" || selected.type === "default-link") {
|
||||||
|
window.open(normalizeURL(selected.suggestion));
|
||||||
|
} else if (selected.type === "text") {
|
||||||
|
console.log("????");
|
||||||
|
copyToClipboard(selected.suggestion);
|
||||||
|
searchBoxRef.current?.focus();
|
||||||
|
}
|
||||||
|
}
|
1
lib/onesearch/translatedEngineList.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const engineTranslation = ["google", "bing", "baidu", "duckduckgo", "yandex", "ecosia", "yahoo"];
|
4
lib/search.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export default function(query: string, engine: string, newTab: boolean = true) {
|
||||||
|
if(newTab) window.open(engine.replace("%s", query));
|
||||||
|
else window.location.href = engine.replace("%s", query);
|
||||||
|
}
|
5
lib/state/background.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
const bgFocusAtom = atom(false);
|
||||||
|
|
||||||
|
export { bgFocusAtom };
|
5
lib/state/query.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
const queryAtom = atom("");
|
||||||
|
|
||||||
|
export { queryAtom };
|
24
lib/state/settings.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { settingsType } from "global";
|
||||||
|
import { atomWithStorage } from 'jotai/utils'
|
||||||
|
|
||||||
|
const defaultSettings: settingsType = {
|
||||||
|
"version": 2,
|
||||||
|
"elementBackdrop": true,
|
||||||
|
"bgBlur": true,
|
||||||
|
"timeShowSecond": false,
|
||||||
|
"currentSearchEngine": "google",
|
||||||
|
"searchInNewTab": true,
|
||||||
|
"searchEngines": {
|
||||||
|
"google": "https://www.google.com/search?q=%s",
|
||||||
|
"bing": "https://www.bing.com/search?q=%s",
|
||||||
|
"baidu": "https://www.baidu.com/s?wd=%s",
|
||||||
|
"duckduckgo": "https://duckduckgo.com/?q=%s",
|
||||||
|
"yandex": "https://yandex.com/search/?text=%s",
|
||||||
|
"yahoo": "https://search.yahoo.com/search?p=%s",
|
||||||
|
"ecosia": "https://www.ecosia.org/search?q=%s"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsAtom = atomWithStorage('settings', defaultSettings);
|
||||||
|
|
||||||
|
export { settingsAtom };
|
6
lib/state/suggestion.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { suggestionItem } from "global";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
const suggestionAtom = atom([] as suggestionItem[]);
|
||||||
|
|
||||||
|
export { suggestionAtom };
|
5
lib/state/suggestionSelection.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
const selectedSuggestionAtom = atom(0);
|
||||||
|
|
||||||
|
export { selectedSuggestionAtom };
|
21
lib/telemetering/sendError.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import pjson from "package.json"
|
||||||
|
|
||||||
|
const CLIENT_VERSION = pjson.version;
|
||||||
|
|
||||||
|
export function sendError(error: Error) {
|
||||||
|
fetch("/api/v1/error", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
time: new Date().getTime()/1000,
|
||||||
|
version: CLIENT_VERSION,
|
||||||
|
ua: navigator.userAgent,
|
||||||
|
cause: error.cause,
|
||||||
|
stack: error.stack
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
1
lib/url/tldList.ts
Normal file
88
lib/url/validLink.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import punycode from "punycode";
|
||||||
|
import { tldList } from "./tldList";
|
||||||
|
|
||||||
|
export default function validLink(link: string) {
|
||||||
|
let finalURL;
|
||||||
|
try {
|
||||||
|
const url = new URL(link);
|
||||||
|
finalURL = url;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// if the URL is invalid, try to add the protocol
|
||||||
|
try {
|
||||||
|
const urlWithHTTP = new URL("http://" + link);
|
||||||
|
finalURL = urlWithHTTP;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
validTLD(finalURL.host) ||
|
||||||
|
isValidIPv6(finalURL.host.slice(1, finalURL.host.length - 1)) ||
|
||||||
|
isValidIPv4(finalURL.host)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validTLD(domain: string): boolean {
|
||||||
|
const tld = punycode.toUnicode(domain.split(".").reverse()[0]);
|
||||||
|
if (tldList.includes(tld)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIPv6(ip: string): boolean {
|
||||||
|
const length = ip.length;
|
||||||
|
let groups = 1;
|
||||||
|
let groupDigits = 0;
|
||||||
|
let doubleColonCount = 0;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const char = ip[i];
|
||||||
|
if ("0" <= char && char <= "9") {
|
||||||
|
groupDigits++;
|
||||||
|
} else if ("a" <= char && char <= "f") {
|
||||||
|
groupDigits++;
|
||||||
|
} else if ("A" <= char && char <= "F") {
|
||||||
|
groupDigits++;
|
||||||
|
} else if (char === ":" && i + 1 < length && ip[i + 1] !== ":") {
|
||||||
|
groups++;
|
||||||
|
groupDigits = 0;
|
||||||
|
} else if (char === ":" && i + 1 < length && ip[i + 1] === ":") {
|
||||||
|
doubleColonCount++;
|
||||||
|
i++;
|
||||||
|
groupDigits = 0;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (groups > 8) {
|
||||||
|
return false;
|
||||||
|
} else if (groupDigits > 4) {
|
||||||
|
return false;
|
||||||
|
} else if (doubleColonCount > 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (doubleColonCount === 0 && groups !== 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIPv4(ip: string): boolean {
|
||||||
|
const parts = ip.split(".");
|
||||||
|
if (parts.length !== 4) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const part of parts) {
|
||||||
|
const num = Number(part);
|
||||||
|
if (isNaN(num) || num < 0 || num > 255 || !part.match(/^\d+$/)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
8
lib/version.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import * as pjson from "package.json";
|
||||||
|
|
||||||
|
export default function getVersion(){
|
||||||
|
return pjson.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clientNLUVersion = 2;
|
||||||
|
export const apiVersion = 1;
|
39
lib/weather/getCurrentWeather.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export function getClosestHourTimestamp(): string {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(0, 0, 0); // 设置分钟、秒和毫秒为0
|
||||||
|
|
||||||
|
// 获取本地时间的年份、月份、日期、小时
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0'); // 月份从0开始
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hour = String(now.getHours()).padStart(2, '0');
|
||||||
|
|
||||||
|
// 拼接成所需的格式
|
||||||
|
const localHourTimestamp = `${year}-${month}-${day}T${hour}:00`;
|
||||||
|
|
||||||
|
return localHourTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findClosestDateIndex(dates: string[], utc_offset_seconds: number): number {
|
||||||
|
const now = new Date();
|
||||||
|
const nowTimestamp = now.getTime();
|
||||||
|
const offsetMilliseconds = utc_offset_seconds * 1000;
|
||||||
|
|
||||||
|
let closestIndex = -1;
|
||||||
|
let closestDiff = Infinity;
|
||||||
|
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
const date = new Date(dates[i]);
|
||||||
|
const adjustedTimestamp = date.getTime();
|
||||||
|
|
||||||
|
if (adjustedTimestamp <= nowTimestamp) {
|
||||||
|
const diff = nowTimestamp - adjustedTimestamp;
|
||||||
|
if (diff < closestDiff) {
|
||||||
|
closestDiff = diff;
|
||||||
|
closestIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return closestIndex;
|
||||||
|
}
|
17
lib/weather/getLocation.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const options = {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 3600
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLocationNative(callback: Function) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos: GeolocationPosition) => {
|
||||||
|
callback(pos.coords);
|
||||||
|
},
|
||||||
|
(err: GeolocationPositionError) => {
|
||||||
|
callback(err);
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
}
|
23
lib/weather/getWeather.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export async function getWeather(lat: number, lon: number) {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
const cacheKey = `weather-cache-${lat.toFixed(2)}-${lon.toFixed(2)}-${timezone}`;
|
||||||
|
const localData = localStorage.getItem(cacheKey);
|
||||||
|
if (localData != null) {
|
||||||
|
console.log('Using cache');
|
||||||
|
const parsedLocalData = JSON.parse(localData);
|
||||||
|
if (parsedLocalData["hourly"]["time"][0] != undefined &&
|
||||||
|
new Date().getTime() - new Date(parsedLocalData["hourly"]["time"][0]).getTime() < 86400 * 1000
|
||||||
|
) {
|
||||||
|
return parsedLocalData;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.log('Cache expired');
|
||||||
|
localStorage.removeItem(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = `https://api.open-meteo.com/v1/cma?latitude=${lat.toString()}&longitude=${lon.toString()}&hourly=apparent_temperature,precipitation,weather_code&timezone=${encodeURIComponent(timezone)}&forecast_days=1`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const responseJson = await response.json();
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify(responseJson));
|
||||||
|
return responseJson;
|
||||||
|
}
|
294
lib/weather/wmocode.ts
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
type WeatherInfo = {
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WMOCodeTable = {
|
||||||
|
[key: string]: {
|
||||||
|
day: WeatherInfo;
|
||||||
|
night: WeatherInfo;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export let WMOCodeTable: WMOCodeTable = {
|
||||||
|
"0": {
|
||||||
|
day: {
|
||||||
|
description: "Sunny",
|
||||||
|
image: "http://openweathermap.org/img/wn/01d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Clear",
|
||||||
|
image: "http://openweathermap.org/img/wn/01n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
day: {
|
||||||
|
description: "Mainly Sunny",
|
||||||
|
image: "http://openweathermap.org/img/wn/01d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Mainly Clear",
|
||||||
|
image: "http://openweathermap.org/img/wn/01n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2": {
|
||||||
|
day: {
|
||||||
|
description: "Partly Cloudy",
|
||||||
|
image: "http://openweathermap.org/img/wn/02d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Partly Cloudy",
|
||||||
|
image: "http://openweathermap.org/img/wn/02n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"3": {
|
||||||
|
day: {
|
||||||
|
description: "Cloudy",
|
||||||
|
image: "http://openweathermap.org/img/wn/03d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Cloudy",
|
||||||
|
image: "http://openweathermap.org/img/wn/03n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"45": {
|
||||||
|
day: {
|
||||||
|
description: "Foggy",
|
||||||
|
image: "http://openweathermap.org/img/wn/50d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Foggy",
|
||||||
|
image: "http://openweathermap.org/img/wn/50n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"48": {
|
||||||
|
day: {
|
||||||
|
description: "Rime Fog",
|
||||||
|
image: "http://openweathermap.org/img/wn/50d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Rime Fog",
|
||||||
|
image: "http://openweathermap.org/img/wn/50n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"51": {
|
||||||
|
day: {
|
||||||
|
description: "Light Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"53": {
|
||||||
|
day: {
|
||||||
|
description: "Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"55": {
|
||||||
|
day: {
|
||||||
|
description: "Heavy Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Heavy Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"56": {
|
||||||
|
day: {
|
||||||
|
description: "Light Freezing Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Freezing Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"57": {
|
||||||
|
day: {
|
||||||
|
description: "Freezing Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Freezing Drizzle",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"61": {
|
||||||
|
day: {
|
||||||
|
description: "Light Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"63": {
|
||||||
|
day: {
|
||||||
|
description: "Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"65": {
|
||||||
|
day: {
|
||||||
|
description: "Heavy Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Heavy Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"66": {
|
||||||
|
day: {
|
||||||
|
description: "Light Freezing Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Freezing Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"67": {
|
||||||
|
day: {
|
||||||
|
description: "Freezing Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Freezing Rain",
|
||||||
|
image: "http://openweathermap.org/img/wn/10n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"71": {
|
||||||
|
day: {
|
||||||
|
description: "Light Snow",
|
||||||
|
image: "http://openweathermap.org/img/wn/13d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Snow",
|
||||||
|
image: "http://openweathermap.org/img/wn/13n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"73": {
|
||||||
|
day: {
|
||||||
|
description: "Snow",
|
||||||
|
image: "http://openweathermap.org/img/wn/13d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Snow",
|
||||||
|
image: "http://openweathermap.org/img/wn/13n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"75": {
|
||||||
|
day: {
|
||||||
|
description: "Heavy Snow",
|
||||||
|
image: "http://openweathermap.org/img/wn/13d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Heavy Snow",
|
||||||
|
image: "http://openweathermap.org/img/wn/13n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"77": {
|
||||||
|
day: {
|
||||||
|
description: "Snow Grains",
|
||||||
|
image: "http://openweathermap.org/img/wn/13d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Snow Grains",
|
||||||
|
image: "http://openweathermap.org/img/wn/13n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"80": {
|
||||||
|
day: {
|
||||||
|
description: "Light Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"81": {
|
||||||
|
day: {
|
||||||
|
description: "Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"82": {
|
||||||
|
day: {
|
||||||
|
description: "Heavy Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/09d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Heavy Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/09n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"85": {
|
||||||
|
day: {
|
||||||
|
description: "Light Snow Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/13d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Snow Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/13n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"86": {
|
||||||
|
day: {
|
||||||
|
description: "Snow Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/13d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Snow Showers",
|
||||||
|
image: "http://openweathermap.org/img/wn/13n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"95": {
|
||||||
|
day: {
|
||||||
|
description: "Thunderstorm",
|
||||||
|
image: "http://openweathermap.org/img/wn/11d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Thunderstorm",
|
||||||
|
image: "http://openweathermap.org/img/wn/11n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"96": {
|
||||||
|
day: {
|
||||||
|
description: "Light Thunderstorms With Hail",
|
||||||
|
image: "http://openweathermap.org/img/wn/11d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Light Thunderstorms With Hail",
|
||||||
|
image: "http://openweathermap.org/img/wn/11n@2x.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"99": {
|
||||||
|
day: {
|
||||||
|
description: "Thunderstorm With Hail",
|
||||||
|
image: "http://openweathermap.org/img/wn/11d@2x.png"
|
||||||
|
},
|
||||||
|
night: {
|
||||||
|
description: "Thunderstorm With Hail",
|
||||||
|
image: "http://openweathermap.org/img/wn/11n@2x.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -1,15 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "a2x.pub",
|
|
||||||
port: "",
|
|
||||||
pathname: "/*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
61
package.json
@ -1,29 +1,56 @@
|
|||||||
{
|
{
|
||||||
"name": "sparkhome",
|
"name": "sparkhome",
|
||||||
"version": "4.1.0",
|
|
||||||
"private": false,
|
"private": false,
|
||||||
|
"version": "5.3.0",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "bun server.ts",
|
||||||
"build": "next build",
|
"build": "tsc -b && vite build",
|
||||||
"start": "next start",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"lint": "next lint"
|
"preview": "NODE_ENV=production bun server.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "14.1.1",
|
"@iconify/react": "^5.0.1",
|
||||||
"react": "^18.2.0",
|
"@nextui-org/react": "^2.4.2",
|
||||||
"react-dom": "^18.2.0",
|
"@types/bun": "^1.1.6",
|
||||||
"recoil": "^0.7.7",
|
"@types/express": "^4.17.21",
|
||||||
|
"cac": "^6.7.14",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"framer-motion": "^11.2.12",
|
||||||
|
"i18next": "^23.11.5",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.0",
|
||||||
|
"i18next-icu": "^2.3.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"jotai": "^2.8.3",
|
||||||
|
"node-nlp": "^4.27.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-i18next": "^14.1.2",
|
||||||
|
"react-router": "^6.23.1",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"search-engine-autocomplete": "^0.4.3",
|
||||||
"valid-url": "^1.0.9",
|
"valid-url": "^1.0.9",
|
||||||
"validate-color": "^2.2.4"
|
"validate-color": "^2.2.4",
|
||||||
|
"vite-express": "^0.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react": "^18",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"@types/valid-url": "^1.0.7",
|
"@types/valid-url": "^1.0.7",
|
||||||
"autoprefixer": "^10.0.1",
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||||
"postcss": "^8",
|
"@typescript-eslint/parser": "^7.13.1",
|
||||||
"tailwindcss": "^3.3.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"typescript": "^5"
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.3.1",
|
||||||
|
"vite-plugin-pages": "^0.32.2",
|
||||||
|
"vite-tsconfig-paths": "^4.3.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
17
pages/[...].tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="relative w-screen h-screen flex justify-center items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h1 className="text-7xl font-thin">404</h1>
|
||||||
|
<div className="relative h-20 mx-4 w-[0.15rem] bg-black dark:bg-white"></div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="uppercase text-3xl font-light">{t("notfound.title")}</div>
|
||||||
|
<div className="text-sm" dangerouslySetInnerHTML={{__html:t("notfound.desc")}}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
69
pages/about/index.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import useDarkMode from "lib/darkModeHook";
|
||||||
|
import getVersion, { apiVersion, clientNLUVersion } from "lib/version";
|
||||||
|
|
||||||
|
export default function AboutPage() {
|
||||||
|
const darkMode = useDarkMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dark:bg-[rgb(23,25,29)] dark:text-white min-h-screen w-screen overflow-x-hidden">
|
||||||
|
<main
|
||||||
|
className="relative h-full w-full md:w-3/4 lg:w-1/2 left-0 md:left-[12.5%] lg:left-1/4
|
||||||
|
pt-12"
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl font-bold mb-6">About SparkHome</h1>
|
||||||
|
<div className="flex mb-8">
|
||||||
|
<img src="/favicon.ico" className="relative w-20 h-20" />
|
||||||
|
<div className="flex flex-col ml-4">
|
||||||
|
<span className="text-3xl font-bold">SparkHome</span>
|
||||||
|
<p className="mt-2 text-xl">
|
||||||
|
Made with <span className="text-red-500">♥️</span> by
|
||||||
|
<a className="underline text-red-500 mx-1" href="https://alikia2x.com">
|
||||||
|
alikia2x
|
||||||
|
</a>
|
||||||
|
from Luminara Studio
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Version title="Overall Version" version={getVersion()} versionClass="bg-red-500" />
|
||||||
|
<Version
|
||||||
|
title="Browser NLU Model Version"
|
||||||
|
version={"Build " + clientNLUVersion}
|
||||||
|
versionClass="bg-purple-500"
|
||||||
|
/>
|
||||||
|
<Version
|
||||||
|
title="Backend API Version"
|
||||||
|
version={"/v" + apiVersion}
|
||||||
|
versionClass="bg-orange-500"
|
||||||
|
/>
|
||||||
|
<p className="flex items-center my-3">
|
||||||
|
<span className="font-bold text-2xl mr-4 w-[36rem]">License</span>
|
||||||
|
<span className="relative px-2 py-1 text-sm font-bold rounded-md text-nowrap underline bg-green-600">
|
||||||
|
<a href="/about/license">→ view</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="relative font-bold text-2xl mt-12">Presented By</p>
|
||||||
|
{!darkMode && <img src="/LuminaraStudio.png" className="relative h-56 mt-6" />}
|
||||||
|
{darkMode && <img src="/LuminaraStudioDark.png" className="relative h-56 mt-6" />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Version(props: { title: string; version: string; versionClass?: string }) {
|
||||||
|
document.title = "About SparkHome";
|
||||||
|
return (
|
||||||
|
<p className="flex items-center my-3">
|
||||||
|
<span className="font-bold text-2xl mr-4 w-[36rem]">{props.title}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
"relative px-2 py-1 text-sm font-bold rounded-md text-nowrap " +
|
||||||
|
props.versionClass ?? ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.version}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
17
pages/about/license/index.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import LICENSE from "lib/license.txt?raw";
|
||||||
|
|
||||||
|
export default function LicensePage() {
|
||||||
|
return (
|
||||||
|
<div className="dark:bg-[rgb(23,25,29)] dark:text-white min-h-screen w-screen overflow-x-hidden">
|
||||||
|
<main
|
||||||
|
className="relative h-full w-full md:w-3/4 lg:w-1/2 left-0 md:left-[12.5%] lg:left-1/4
|
||||||
|
pt-12"
|
||||||
|
>
|
||||||
|
<h1 className="text-4xl font-bold mb-6">LICENSE</h1>
|
||||||
|
<div className="font-mono text-justify whitespace-break-spaces">
|
||||||
|
{LICENSE}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
27
pages/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import Background from "components/background";
|
||||||
|
import Time from "components/time";
|
||||||
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import Search from "components/search";
|
||||||
|
import { settingsAtom } from "lib/state/settings";
|
||||||
|
import { bgFocusAtom } from "lib/state/background";
|
||||||
|
import EngineSelector from "components/engineSelector";
|
||||||
|
import OneSearch from "components/onesearch/onesearch";
|
||||||
|
|
||||||
|
export default function Homepage() {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const setBgFocus = useSetAtom(bgFocusAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen fixed overflow-hidden w-screen bg-black">
|
||||||
|
<Background />
|
||||||
|
<EngineSelector
|
||||||
|
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
|
||||||
|
dark:text-white text-shadow-lg z-10"
|
||||||
|
/>
|
||||||
|
<Search onFocus={() => setBgFocus(true)} />
|
||||||
|
<Time showSecond={settings.timeShowSecond} />
|
||||||
|
<OneSearch />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1118
pnpm-lock.yaml
@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
autoprefixer: {}
|
||||||
},
|
}
|
||||||
};
|
};
|
||||||
|
BIN
public/LuminaraStudio.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
public/LuminaraStudioDark.png
Normal file
After Width: | Height: | Size: 81 KiB |
Before Width: | Height: | Size: 4.3 MiB |
Before Width: | Height: | Size: 9.0 MiB |
Before Width: | Height: | Size: 6.3 MiB |
BIN
public/favicon.ico
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
public/model
Normal file
88
server.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import chalk from "chalk";
|
||||||
|
import express from "express";
|
||||||
|
import ViteExpress from "vite-express";
|
||||||
|
import pjson from "./package.json";
|
||||||
|
import { networkInterfaces } from "os";
|
||||||
|
import cac from "cac";
|
||||||
|
import { configureBackendRoutes } from "./backend/route";
|
||||||
|
|
||||||
|
|
||||||
|
async function helloMessage() {
|
||||||
|
const { base } = await ViteExpress.getViteConfig();
|
||||||
|
const timeCost = new Date().getTime() - start.getTime();
|
||||||
|
console.log("");
|
||||||
|
console.log(
|
||||||
|
" ",
|
||||||
|
chalk.redBright("SparkHome"),
|
||||||
|
chalk.redBright("v" + pjson.version),
|
||||||
|
chalk.whiteBright(" ready in"),
|
||||||
|
`${Math.round(timeCost)} ms`
|
||||||
|
);
|
||||||
|
console.log("");
|
||||||
|
console.log(" ", chalk.redBright("➜ "), "Local:\t", chalk.cyan(`http://${host}:${port}${base}`));
|
||||||
|
if (host !== "localhost") {
|
||||||
|
for (const ip of ips) {
|
||||||
|
console.log(" ", chalk.redBright("➜ "), "Network:\t", chalk.cyan(`http://${ip}:${port}${base}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(" ", chalk.red("➜ "), chalk.whiteBright("press"), "h + enter", chalk.whiteBright("to show help"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInput() {
|
||||||
|
for await (const line of console) {
|
||||||
|
switch (line) {
|
||||||
|
case "h":
|
||||||
|
console.log(" Shortcuts");
|
||||||
|
console.log(" ", chalk.whiteBright("press"), "c + enter ", chalk.whiteBright("to clear console"));
|
||||||
|
console.log(" ", chalk.whiteBright("press"), "q + enter ", chalk.whiteBright("to quit"));
|
||||||
|
break;
|
||||||
|
case "c":
|
||||||
|
console.clear();
|
||||||
|
break;
|
||||||
|
case "q":
|
||||||
|
server.on("vite:close", ()=>{});
|
||||||
|
server.close();
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date();
|
||||||
|
const cli = cac();
|
||||||
|
const nets = networkInterfaces();
|
||||||
|
const ips: string[] = [];
|
||||||
|
for (const name of Object.keys(nets)) {
|
||||||
|
if (nets[name] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const net of nets[name]) {
|
||||||
|
// Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses
|
||||||
|
// 'IPv4' is in Node <= 17, from 18 it's a number 4 or 6
|
||||||
|
const familyV4Value = typeof net.family === "string" ? "IPv4" : 4;
|
||||||
|
if (net.family === familyV4Value && !net.internal) {
|
||||||
|
ips.push(net.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = 3000;
|
||||||
|
let host = "localhost";
|
||||||
|
|
||||||
|
cli.option("--host [host]", "Sepcify host name")
|
||||||
|
cli.help()
|
||||||
|
cli.version(pjson.version);
|
||||||
|
const parsed = cli.parse();
|
||||||
|
if (parsed.options.host!==undefined && typeof parsed.options.host == "boolean" && parsed.options.host) {
|
||||||
|
host = "0.0.0.0";
|
||||||
|
}
|
||||||
|
|
||||||
|
configureBackendRoutes(app);
|
||||||
|
|
||||||
|
const server = app.listen(port, host);
|
||||||
|
|
||||||
|
ViteExpress.bind(app, server, helloMessage);
|
||||||
|
|
||||||
|
handleInput();
|
74
src/app.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { useRoutes } from "react-router-dom";
|
||||||
|
import routes from "~react-pages";
|
||||||
|
import i18n from "i18next";
|
||||||
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
import ICU from 'i18next-icu';
|
||||||
|
import * as en from "i18n/en.json"
|
||||||
|
import * as zh from "i18n/zh.json"
|
||||||
|
import * as ja from "i18n/ja.json"
|
||||||
|
import * as ar from "i18n/ar.json"
|
||||||
|
import * as de from "i18n/de.json"
|
||||||
|
import * as es from "i18n/es.json"
|
||||||
|
import * as fr from "i18n/fr.json"
|
||||||
|
import * as it from "i18n/it.json"
|
||||||
|
import * as ko from "i18n/ko.json"
|
||||||
|
import * as pt from "i18n/pt.json"
|
||||||
|
import * as ru from "i18n/ru.json"
|
||||||
|
|
||||||
|
i18n.use(initReactI18next) // passes i18n down to react-i18next
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(ICU)
|
||||||
|
.init({
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: en
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
translation: zh
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
translation: ja
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
translation: ar
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
translation: de
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
translation: es
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
translation: fr
|
||||||
|
},
|
||||||
|
it: {
|
||||||
|
translation: it
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
translation: ko
|
||||||
|
},
|
||||||
|
pt: {
|
||||||
|
translation: pt
|
||||||
|
},
|
||||||
|
ru: {
|
||||||
|
translation: ru
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fallbackLng: "en",
|
||||||
|
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false // react already safes from xss => https://www.i18next.com/translation-function/interpolation#unescape
|
||||||
|
},
|
||||||
|
|
||||||
|
detection: {
|
||||||
|
order: ['navigator'],
|
||||||
|
caches: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>;
|
||||||
|
}
|
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
18
src/main.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import { App } from "./app";
|
||||||
|
import "./index.css";
|
||||||
|
import { NextUIProvider } from "@nextui-org/react";
|
||||||
|
|
||||||
|
const app = createRoot(document.getElementById("root")!);
|
||||||
|
|
||||||
|
app.render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<NextUIProvider>
|
||||||
|
<App />
|
||||||
|
</NextUIProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
2
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pages/client-react" />
|
@ -1,20 +1,21 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
import { nextui } from "@nextui-org/react";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
content: [
|
content: [
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./node_modules/@nextui-org/theme/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
|
||||||
"gradient-conic":
|
"gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))"
|
||||||
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
},
|
plugins: [nextui()]
|
||||||
plugins: [],
|
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
70
test/validLink.test.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from "@jest/globals";
|
||||||
|
import validLink, { validTLD } from "../lib/url/validLink";
|
||||||
|
|
||||||
|
describe("Check if a string is an accessible domain/URL/IP", () => {
|
||||||
|
test("Plain, full URL", () => {
|
||||||
|
// Plain form
|
||||||
|
expect(validLink("http://example.com")).toBe(true);
|
||||||
|
// With https and path
|
||||||
|
expect(validLink("https://jestjs.io/docs/getting-started/")).toBe(true);
|
||||||
|
// With anchor
|
||||||
|
expect(validLink("https://difftastic.wilfred.me.uk/zh-CN/git.html#git-difftool")).toBe(true);
|
||||||
|
// With params
|
||||||
|
expect(validLink("https://www.bilibili.com/list/ml2252204359?oid=990610203&bvid=BV1sx4y1g7Hh")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Punycode URL", () => {
|
||||||
|
expect(validLink("https://原神大学.com/")).toBe(true);
|
||||||
|
expect(validLink("中国原神大学.com")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Invalid TLD with protocol", () => {
|
||||||
|
expect(validLink("https://www.example.notexist")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Invalid TLD with no protocol", () => {
|
||||||
|
expect(validLink("www.example.notexist")).toBe(false);
|
||||||
|
});
|
||||||
|
test("IPv4 without protocol", () => {
|
||||||
|
expect(validLink("127.0.0.1")).toBe(true);
|
||||||
|
});
|
||||||
|
test("IPv6 without protocol", () => {
|
||||||
|
expect(validLink("[::]")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reference: https://www.iana.org/domains/root/db
|
||||||
|
describe("Check if the given TLD exist and assigned.", () => {
|
||||||
|
test("Valid normal TLD", () => {
|
||||||
|
expect(validTLD("com")).toBe(true);
|
||||||
|
expect(validTLD("top")).toBe(true);
|
||||||
|
expect(validTLD("net")).toBe(true);
|
||||||
|
expect(validTLD("org")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Valid new TLDs", () => {
|
||||||
|
// they really exist!
|
||||||
|
expect(validTLD("foo")).toBe(true);
|
||||||
|
expect(validTLD("bar")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Exist but not assigned TLD", () => {
|
||||||
|
expect(validTLD("active")).toBe(false);
|
||||||
|
expect(validTLD("off")).toBe(false);
|
||||||
|
});
|
||||||
|
test("with dot", () => {
|
||||||
|
expect(validTLD(".com")).toBe(true);
|
||||||
|
expect(validTLD(".us")).toBe(true);
|
||||||
|
expect(validTLD(".cn")).toBe(true);
|
||||||
|
expect(validTLD(".io")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Punycode TLDs", () => {
|
||||||
|
expect(validTLD(".中国")).toBe(true);
|
||||||
|
expect(validTLD(".РФ")).toBe(true);
|
||||||
|
expect(validTLD(".कॉम")).toBe(true);
|
||||||
|
expect(validTLD("ایران")).toBe(true);
|
||||||
|
expect(validTLD("இலங்கை")).toBe(true);
|
||||||
|
expect(validTLD("გე")).toBe(true);
|
||||||
|
expect(validTLD("ポイント")).toBe(true);
|
||||||
|
});
|
||||||
|
test("Punycode TLDs but not assigned", () => {
|
||||||
|
expect(validTLD("テスト")).toBe(false);
|
||||||
|
expect(validTLD("परीक्षा")).toBe(false);
|
||||||
|
expect(validTLD("测试")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src", "**/*.ts", "**/*.tsx", "global.d.ts"]
|
||||||
|
}
|
@ -1,26 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"references": [
|
||||||
"allowJs": true,
|
{
|
||||||
"skipLibCheck": true,
|
"path": "./tsconfig.app.json"
|
||||||
"strict": true,
|
},
|
||||||
"noEmit": true,
|
{
|
||||||
"esModuleInterop": true,
|
"path": "./tsconfig.node.json"
|
||||||
"module": "esnext",
|
}
|
||||||
"moduleResolution": "bundler",
|
]
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "global.d.ts", "app/store/store.js"],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
}
|
||||||
|
14
tsconfig.node.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
15
vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react-swc";
|
||||||
|
import Pages from "vite-plugin-pages";
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
Pages({
|
||||||
|
dirs: "./pages/"
|
||||||
|
}),
|
||||||
|
tsconfigPaths()
|
||||||
|
]
|
||||||
|
});
|