Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
04d72d3ca8 | ||
![]() |
8e65767ac1 | ||
![]() |
ced6bffc28 | ||
![]() |
67b5f47440 | ||
![]() |
7d412ad439 | ||
![]() |
5ca8d32778 | ||
![]() |
1ab4f1ce63 | ||
![]() |
6bcf445848 | ||
![]() |
8223e696f9 | ||
![]() |
8a9ef8fcb4 | ||
![]() |
b3255de375 | ||
![]() |
09a7941eca | ||
![]() |
090154115a | ||
![]() |
0aef1e1641 | ||
![]() |
c1930d6bd7 | ||
![]() |
24de7bcaa3 | ||
![]() |
62a0e85554 | ||
![]() |
1f5a36832c | ||
![]() |
09d099a625 | ||
![]() |
a5f5112fc1 | ||
![]() |
8c005b3769 | ||
![]() |
9a623164e4 | ||
![]() |
304ea14003 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
README.md
|
||||||
|
.next
|
||||||
|
.git
|
67
Dockerfile
Normal file
67
Dockerfile
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
FROM node:18-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc ./
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
# Learn more here: https://nextjs.org/telemetry
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN \
|
||||||
|
if [ -f yarn.lock ]; then yarn run build; \
|
||||||
|
elif [ -f package-lock.json ]; then npm run build; \
|
||||||
|
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||||
|
else echo "Lockfile not found." && exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 4594
|
||||||
|
|
||||||
|
ENV PORT 4594
|
||||||
|
|
||||||
|
# server.js is created by next build from the standalone output
|
||||||
|
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||||
|
CMD HOSTNAME="0.0.0.0" node server.js
|
28
app/[locale]/global.css
Normal file
28
app/[locale]/global.css
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
39
app/[locale]/layout.tsx
Normal file
39
app/[locale]/layout.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./global.css";
|
||||||
|
import { NextIntlClientProvider, useMessages } from "next-intl";
|
||||||
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import { Providers } from "../providers";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "SparkHome",
|
||||||
|
description: "Your best browser homepage, with a magic searchbox.",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.ico"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params: { locale }
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
|
const messages = useMessages();
|
||||||
|
return (
|
||||||
|
<html lang={locale} suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<ThemeProvider>
|
||||||
|
<Providers>
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</Providers>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
11
app/[locale]/not-found.tsx
Normal file
11
app/[locale]/not-found.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const t = useTranslations("404");
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>{t('title')}</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RecoilRoot } from "recoil";
|
import { RecoilRoot } from "recoil";
|
||||||
import Homepage from "../components";
|
import Homepage from "../../components";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
13
app/api/autocomplete/route.ts
Normal file
13
app/api/autocomplete/route.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { completeGoogle } from "search-engine-autocomplete";
|
||||||
|
import { NextRequest } from "next/server"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const query = request.nextUrl.searchParams.get('q')!;
|
||||||
|
let language = request.nextUrl.searchParams.get('l');
|
||||||
|
if (language === null) language = 'en-US';
|
||||||
|
const data = await completeGoogle(query, language);
|
||||||
|
const completeWord: string | undefined = data.suggestions.filter((s) => {
|
||||||
|
return s.relativeRelevance > 0.96
|
||||||
|
})[0]?.suggestion;
|
||||||
|
return new Response(completeWord ? completeWord : query);
|
||||||
|
}
|
13
app/api/ping/route.ts
Normal file
13
app/api/ping/route.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { NEXT_API_VERSION, SPARKHOME_VERSION, CLIENT_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"; // defaults to auto
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const time = new Date().getTime() / 1000;
|
||||||
|
const responseString =
|
||||||
|
`SparkHome ${SPARKHOME_VERSION}
|
||||||
|
Client ${CLIENT_VERSION}
|
||||||
|
API ${NEXT_API_VERSION}
|
||||||
|
ServerTime ${time}
|
||||||
|
Powered by alikia2x (∠・ω< )⌒★`;
|
||||||
|
return new Response(responseString);
|
||||||
|
}
|
9
app/api/suggestion/route.ts
Normal file
9
app/api/suggestion/route.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { completeGoogle } from "search-engine-autocomplete";
|
||||||
|
import { NextRequest } from "next/server"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const query = request.nextUrl.searchParams.get('q')!;
|
||||||
|
const language = "ja-JP";
|
||||||
|
const data = await completeGoogle(query, language);
|
||||||
|
return new Response(JSON.stringify(data));
|
||||||
|
}
|
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 64 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 +1,7 @@
|
|||||||
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({
|
export default function RootLayout({
|
||||||
children
|
children,
|
||||||
}: Readonly<{
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}) {
|
||||||
return (
|
return children;
|
||||||
<html lang="en">
|
|
||||||
<body className={inter.className}>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
9
app/not-found.tsx
Normal file
9
app/not-found.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>Not Found</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
12
app/providers.tsx
Normal file
12
app/providers.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// app/providers.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import {NextUIProvider} from '@nextui-org/react'
|
||||||
|
|
||||||
|
export function Providers({children}: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<NextUIProvider>
|
||||||
|
{children}
|
||||||
|
</NextUIProvider>
|
||||||
|
)
|
||||||
|
}
|
@ -1,41 +1,35 @@
|
|||||||
import Image from "next/image";
|
"use client";
|
||||||
import { useRecoilValue } from "recoil";
|
|
||||||
import { settingsState } from "./state/settings";
|
|
||||||
import validUrl from "valid-url";
|
|
||||||
import validateColor from "validate-color";
|
|
||||||
|
|
||||||
function Background(props: {
|
import { useEffect, useState } from "react";
|
||||||
isFocus: boolean;
|
import { useRecoilState } from "recoil";
|
||||||
src: string;
|
import { bgFocusState } from "./state/background";
|
||||||
onClick: () => void;
|
import BackgroundContainer from "./backgroundContainer";
|
||||||
}) {
|
|
||||||
const settings = useRecoilValue(settingsState);
|
export default function () {
|
||||||
if (validateColor(props.src)) {
|
const [isFocus, setFocus] = useRecoilState(bgFocusState);
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div suppressHydrationWarning>
|
||||||
className="w-full h-full fixed object-cover inset-0 duration-200 z-0"
|
{darkMode ? (
|
||||||
style={{ backgroundColor: props.src }}
|
<BackgroundContainer src="rgb(23,25,29)" isFocus={isFocus} onClick={() => setFocus(false)} darkMode={darkMode}/>
|
||||||
onClick={props.onClick}
|
) : (
|
||||||
></div>
|
<BackgroundContainer src="white" isFocus={isFocus} onClick={() => setFocus(false)} darkMode={darkMode}/>
|
||||||
|
)}
|
||||||
|
</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;
|
|
||||||
|
47
components/backgroundContainer.tsx
Normal file
47
components/backgroundContainer.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import { useRecoilValue } from "recoil";
|
||||||
|
import { settingsState } from "./state/settings";
|
||||||
|
import validUrl from "valid-url";
|
||||||
|
import validateColor from "validate-color";
|
||||||
|
|
||||||
|
export default function (props: { isFocus: boolean; src: string; darkMode: boolean; onClick: () => void }) {
|
||||||
|
const settings = useRecoilValue(settingsState);
|
||||||
|
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 (
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,23 +1,36 @@
|
|||||||
import { useRecoilState } from "recoil";
|
"use client";
|
||||||
import Background from "./background";
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRecoilState, useSetRecoilState } from "recoil";
|
||||||
|
import { settingsState } from "./state/settings";
|
||||||
import Search from "./search/search";
|
import Search from "./search/search";
|
||||||
import { bgFocusState } from "./state/background";
|
import { bgFocusState } from "./state/background";
|
||||||
|
import EngineSelector from "./search/engineSelector";
|
||||||
|
import Onesearch from "./search/onesearch/onesearch";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const Background = dynamic(() => import("./background"), {
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
const Time = dynamic(() => import("./time"), {
|
||||||
|
ssr: false
|
||||||
|
});
|
||||||
|
|
||||||
export default function Homepage() {
|
export default function Homepage() {
|
||||||
const [isFocus, setFocus] = useRecoilState(bgFocusState);
|
const [settings, setSettings] = useRecoilState(settingsState);
|
||||||
|
const setFocus = useSetRecoilState(bgFocusState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full fixed overflow-hidden w-full bg-black">
|
<div className="h-full fixed overflow-hidden w-full bg-black">
|
||||||
<Background
|
<Time showSecond={settings.timeShowSecond} />
|
||||||
src="rgb(23,25,29)"
|
<EngineSelector
|
||||||
isFocus={isFocus}
|
className="absolute top-20 lg:top-44 short:top-0 translate-x-[-50%] translate-y-[-0.2rem]
|
||||||
onClick={() => setFocus(false)}
|
left-1/2 w-11/12 sm:w-[700px] text:black text-right
|
||||||
/>
|
dark:text-white text-3xl text-shadow-lg z-10"
|
||||||
<Search
|
|
||||||
onFocus={() => {
|
|
||||||
setFocus(true);
|
|
||||||
console.log("focus");
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
<Background />
|
||||||
|
<Search onFocus={() => setFocus(true)} />
|
||||||
|
<Onesearch />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
76
components/search/engineSelector.tsx
Normal file
76
components/search/engineSelector.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { SetStateAction, useEffect, useState } from "react";
|
||||||
|
import { Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Button } from "@nextui-org/react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRecoilValue, useSetRecoilState } from "recoil";
|
||||||
|
import { settingsState } from "../state/settings";
|
||||||
|
import { engineTranslation } from "./translatedEngineList";
|
||||||
|
|
||||||
|
export default function(
|
||||||
|
props: { className: string }
|
||||||
|
) {
|
||||||
|
const t = useTranslations("Search");
|
||||||
|
const settings: settings = useRecoilValue(settingsState);
|
||||||
|
const items = settings.searchEngines;
|
||||||
|
const currentEngine: string = settings.currentSearchEngine;
|
||||||
|
const displayEngine = getName(currentEngine);
|
||||||
|
const [selectedKeys, setSelectedKeys] = useState(new Set([currentEngine]) as any);
|
||||||
|
const selectedValue = React.useMemo(() => Array.from(selectedKeys).join(", "), [selectedKeys]);
|
||||||
|
const setSettings = useSetRecoilState(settingsState);
|
||||||
|
|
||||||
|
function setEngine(engine: string) {
|
||||||
|
setSettings((oldSettings) => {
|
||||||
|
return {
|
||||||
|
...oldSettings,
|
||||||
|
currentSearchEngine: engine
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getName(engineKey: string) {
|
||||||
|
return engineTranslation.includes(engineKey) ? t(`engine.${engineKey}`) : engineKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedValue !== currentEngine) {
|
||||||
|
setEngine(selectedValue);
|
||||||
|
}
|
||||||
|
}, [selectedValue]);
|
||||||
|
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={props.className}>
|
||||||
|
{
|
||||||
|
isClient &&
|
||||||
|
(
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownTrigger>
|
||||||
|
<Button variant="bordered" className="capitalize">
|
||||||
|
{displayEngine}
|
||||||
|
</Button>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownMenu
|
||||||
|
aria-label={t("engine-aria")}
|
||||||
|
variant="light"
|
||||||
|
disallowEmptySelection
|
||||||
|
selectionMode="single"
|
||||||
|
selectedKeys={selectedKeys}
|
||||||
|
onSelectionChange={setSelectedKeys}
|
||||||
|
>
|
||||||
|
{Object.keys(items).map((item) => (
|
||||||
|
<DropdownItem key={item} suppressHydrationWarning>
|
||||||
|
{getName(item)}
|
||||||
|
</DropdownItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
24
components/search/onesearch/onesearch.tsx
Normal file
24
components/search/onesearch/onesearch.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import SuggestionBox from "./suggestionBox";
|
||||||
|
import Suggestion from "./suggestion";
|
||||||
|
import { useRecoilValue } from "recoil";
|
||||||
|
import { queryState } from "@/components/state/query";
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
const [suggestion, setSuggetsion] = useState([]);
|
||||||
|
const query = useRecoilValue(queryState);
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/suggestion?q=${query}`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setSuggetsion(data);
|
||||||
|
});
|
||||||
|
}, [query]);
|
||||||
|
return (
|
||||||
|
<SuggestionBox>
|
||||||
|
{suggestion.map((s: string) => {
|
||||||
|
return <Suggestion key={s}>{s}</Suggestion>;
|
||||||
|
})}
|
||||||
|
</SuggestionBox>
|
||||||
|
);
|
||||||
|
}
|
7
components/search/onesearch/suggestion.tsx
Normal file
7
components/search/onesearch/suggestion.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={`w-full h-10 leading-10 bg-slate-200 dark:bg-zinc-800 hover:bg-zinc-700 px-5 z-10 cursor-pointer duration-100`}>
|
||||||
|
<p>{props.children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
8
components/search/onesearch/suggestionBox.tsx
Normal file
8
components/search/onesearch/suggestionBox.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export default function(props: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="relative bg-slate-200 dark:bg-zinc-800 w-11/12 sm:w-[700px] h-auto left-1/2
|
||||||
|
translate-x-[-50%] top-72 z-20 rounded overflow-hidden duration-250">
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,24 +1,57 @@
|
|||||||
import { atom, useRecoilValue } from "recoil";
|
"use client";
|
||||||
import { settingsState } from "../state/settings";
|
|
||||||
|
import { useRecoilState, useRecoilValue } from "recoil";
|
||||||
|
import { settingsState } from "../state/settings";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { normalizeURL } from "@/lib/normalizeURL";
|
||||||
|
import validLink from "@/lib/url/validLink";
|
||||||
|
import { queryState } from "../state/query";
|
||||||
|
|
||||||
|
export default function Search(props: { onFocus: () => void }) {
|
||||||
|
const settings: settings = useRecoilValue(settingsState);
|
||||||
|
const t = useTranslations("Search");
|
||||||
|
const [query, setQuery] = useRecoilState(queryState);
|
||||||
|
|
||||||
export default function Search(props: {
|
|
||||||
onFocus: () => void;
|
|
||||||
}) {
|
|
||||||
const settings = useRecoilValue(settingsState);
|
|
||||||
let style = "default";
|
let style = "default";
|
||||||
|
|
||||||
|
function handleKeydown(e: any) {
|
||||||
|
let URL = "";
|
||||||
|
if (validLink(query)) {
|
||||||
|
URL = normalizeURL(query);
|
||||||
|
} else {
|
||||||
|
URL = settings.searchEngines[settings.currentSearchEngine];
|
||||||
|
URL = URL.replace("%s", query);
|
||||||
|
}
|
||||||
|
if (e.key == "Enter") {
|
||||||
|
location.href = URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (style === "default") {
|
if (style === "default") {
|
||||||
return (
|
return (
|
||||||
// 祖传样式,勿动
|
// 祖传样式,勿动
|
||||||
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
|
<div className="absolute w-full top-[8.5rem] lg:top-56 short:top-24 z-1 left-1/2 translate-x-[-50%] ">
|
||||||
<input
|
<input
|
||||||
className="absolute z-1 w-11/12 sm:w-[700px] h-10 rounded-lg left-1/2 translate-x-[-50%]
|
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)]
|
text-center outline-none border-[1px] 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:border-neutral-500 dark:focus:border-neutral-300 placeholder:text-slate-500
|
||||||
dark:placeholder:text-slate-400 text-slate-900 dark:text-white"
|
dark:placeholder:text-slate-400 text-slate-900 dark:text-white"
|
||||||
id="searchBox"
|
id="searchBox"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder={t("placeholder")}
|
||||||
onFocus={props.onFocus}
|
onFocus={props.onFocus}
|
||||||
|
onKeyDown={handleKeydown}
|
||||||
|
onChange={(e) =>
|
||||||
|
setQuery((_) => {
|
||||||
|
return e.target.value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck="false"
|
||||||
|
value={query}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -30,7 +63,7 @@ export default function Search(props: {
|
|||||||
className={
|
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
|
`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
|
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` +
|
border-solid border-0 duration-200 pr-2 shadow-md focus:shadow-none` +
|
||||||
(settings.bgBlur
|
(settings.bgBlur
|
||||||
? `bg-[rgba(255,255,255,0.5)] dark:bg-[rgba(24,24,24,0.75)] backdrop-blur-xl
|
? `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`
|
placeholder:text-slate-500 dark:placeholder:text-slate-400 text-slate-900 dark:text-white`
|
||||||
@ -39,7 +72,7 @@ export default function Search(props: {
|
|||||||
}
|
}
|
||||||
id="searchBox"
|
id="searchBox"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search"
|
placeholder={t("placeholder")}
|
||||||
onFocus={props.onFocus}
|
onFocus={props.onFocus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
1
components/search/translatedEngineList.ts
Normal file
1
components/search/translatedEngineList.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const engineTranslation = ["google", "bing", "baidu", "duckduckgo", "yandex", "ecosia", "yahoo"];
|
10
components/state/query.ts
Normal file
10
components/state/query.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { atom } from "recoil";
|
||||||
|
|
||||||
|
const queryState = atom({
|
||||||
|
key: "searchQuery",
|
||||||
|
default: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
export {
|
||||||
|
queryState,
|
||||||
|
}
|
@ -1,14 +1,43 @@
|
|||||||
import { atom, selector } from "recoil";
|
import isLocalStorageAvailable from "@/lib/isLocalStorageAvailable";
|
||||||
|
import { atom } from "recoil";
|
||||||
|
|
||||||
|
const defaultSettings: settings = {
|
||||||
|
version: 1,
|
||||||
|
elementBackdrop: true,
|
||||||
|
bgBlur: true,
|
||||||
|
timeShowSecond: false,
|
||||||
|
currentSearchEngine: "google",
|
||||||
|
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 localStorageEffect =
|
||||||
|
(key: any) =>
|
||||||
|
({ setSelf, onSet }: any) => {
|
||||||
|
if (isLocalStorageAvailable()===false){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const savedValue = localStorage.getItem(key);
|
||||||
|
if (savedValue != null) {
|
||||||
|
setSelf(JSON.parse(savedValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
onSet((newValue: settings) => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(newValue));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const settingsState = atom({
|
const settingsState = atom({
|
||||||
key: "settings",
|
key: "settings",
|
||||||
default: {
|
default: defaultSettings,
|
||||||
version: 1,
|
effects_UNSTABLE: [localStorageEffect("settings")]
|
||||||
elementBackdrop: true,
|
|
||||||
bgBlur: true
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export {
|
export { settingsState };
|
||||||
settingsState,
|
|
||||||
}
|
|
||||||
|
50
components/time.tsx
Normal file
50
components/time.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { useFormatter } from "next-intl";
|
||||||
|
|
||||||
|
export default function Time(props: {
|
||||||
|
showSecond: boolean
|
||||||
|
}) {
|
||||||
|
const [currentTime, setCurrentTime] = useState(new Date());
|
||||||
|
const format = useFormatter();
|
||||||
|
|
||||||
|
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 z-10"
|
||||||
|
>
|
||||||
|
{formatTime()}{" "}
|
||||||
|
<span className="text-lg leading-9 relative">
|
||||||
|
{format.dateTime(currentTime, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric"
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
9
global.d.ts
vendored
9
global.d.ts
vendored
@ -1,3 +1,10 @@
|
|||||||
type settings = {
|
type settings = {
|
||||||
bgBlur: boolean
|
version: number;
|
||||||
|
elementBackdrop: boolean;
|
||||||
|
bgBlur: boolean;
|
||||||
|
timeShowSecond: boolean;
|
||||||
|
currentSearchEngine: string;
|
||||||
|
searchEngines: {
|
||||||
|
[key: string]: string,
|
||||||
|
};
|
||||||
};
|
};
|
14
i18n.ts
Normal file
14
i18n.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {notFound} from 'next/navigation';
|
||||||
|
import {getRequestConfig} from 'next-intl/server';
|
||||||
|
|
||||||
|
// Can be imported from a shared config
|
||||||
|
const locales = ['en', 'zh'];
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({locale}) => {
|
||||||
|
// Validate that the incoming `locale` parameter is valid
|
||||||
|
if (!locales.includes(locale as any)) notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: (await import(`/messages/${locale}.json`)).default
|
||||||
|
};
|
||||||
|
});
|
18
jest.config.ts
Normal file
18
jest.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import type { Config } from 'jest'
|
||||||
|
import nextJest from 'next/jest.js'
|
||||||
|
|
||||||
|
const createJestConfig = nextJest({
|
||||||
|
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
|
||||||
|
dir: './',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add any custom config to be passed to Jest
|
||||||
|
const config: Config = {
|
||||||
|
coverageProvider: 'v8',
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
// Add more setup options before each test is run
|
||||||
|
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
|
||||||
|
export default createJestConfig(config)
|
81
lib/browser_history_search/chrome/chrome.sql
Normal file
81
lib/browser_history_search/chrome/chrome.sql
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
SELECT processed_url, title,
|
||||||
|
timeK * 4.4 + (visitK + typeK) * 6.5 + typeK * 0.22 + relativeK AS searchRank
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
CASE
|
||||||
|
WHEN processed_url LIKE '[query]%' THEN
|
||||||
|
CASE
|
||||||
|
WHEN (title like '% [query]%' or title like '[query]%') THEN 12.5
|
||||||
|
ELSE 12.3
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN processed_url LIKE '%[query]%' THEN 2.5
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN (title like '% [query]%' or title like '[query]%') THEN 1.6
|
||||||
|
ELSE -1
|
||||||
|
END
|
||||||
|
END
|
||||||
|
END AS relativeK
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
(1 / (5.2 * LOG(38, 0.000015 * (strftime('%s', 'now') - last_visit_time / 1000000 - (strftime('%s', '1601-01-01'))) + 1) + 1)) AS timeK,
|
||||||
|
(1 / (-1 * ln(5 * visit_count + 2.71828)) + 1.07) AS visitK,
|
||||||
|
(1 / (-1 * ln(7 * typed_count + 2.71828)) + 1.12) AS typeK,
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(url, '://') > 0 THEN
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), 'www.') = 1 THEN SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), INSTR(SUBSTR(url, INSTR(url, '://') + 3), '.') + 1)
|
||||||
|
ELSE SUBSTR(url, INSTR(url, '://') + 3)
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(url, 'www.') = 1 THEN SUBSTR(url, INSTR(url, '.') + 1)
|
||||||
|
ELSE url
|
||||||
|
END
|
||||||
|
END AS processed_url
|
||||||
|
FROM urls
|
||||||
|
) AS subquery where relativeK > 0 and hidden <> 1
|
||||||
|
) AS subquery
|
||||||
|
group by processed_url
|
||||||
|
ORDER BY searchRank DESC
|
||||||
|
LIMIT 9
|
||||||
|
;
|
||||||
|
|
||||||
|
SELECT processed_url, title,
|
||||||
|
timeK * 4.4 + (visitK + typeK) * 6.5 + typeK * 0.22 + relativeK AS searchRank
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
CASE
|
||||||
|
WHEN processed_url LIKE '[query]%' THEN -1
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN (title like '% [query]%' or title like '[query]%') THEN 1.6
|
||||||
|
ELSE -1
|
||||||
|
END
|
||||||
|
END AS relativeK
|
||||||
|
FROM (
|
||||||
|
SELECT *,
|
||||||
|
(1 / (5.2 * LOG(38, 0.000015 * (strftime('%s', 'now') - last_visit_time / 1000000 - (strftime('%s', '1601-01-01'))) + 1) + 1)) AS timeK,
|
||||||
|
(1 / (-1 * ln(5 * visit_count + 2.71828)) + 1.07) AS visitK,
|
||||||
|
(1 / (-1 * ln(7 * typed_count + 2.71828)) + 1.12) AS typeK,
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(url, '://') > 0 THEN
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), 'www.') = 1 THEN SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), INSTR(SUBSTR(url, INSTR(url, '://') + 3), '.') + 1)
|
||||||
|
ELSE SUBSTR(url, INSTR(url, '://') + 3)
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN INSTR(url, 'www.') = 1 THEN SUBSTR(url, INSTR(url, '.') + 1)
|
||||||
|
ELSE url
|
||||||
|
END
|
||||||
|
END AS processed_url
|
||||||
|
FROM urls
|
||||||
|
) AS subquery where hidden <> 1 and relativeK > 0
|
||||||
|
) AS subquery
|
||||||
|
group by processed_url
|
||||||
|
ORDER BY searchRank DESC
|
||||||
|
LIMIT 6
|
||||||
|
;
|
40
lib/browser_history_search/firefox/firefox.sql
Normal file
40
lib/browser_history_search/firefox/firefox.sql
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
select
|
||||||
|
*
|
||||||
|
from
|
||||||
|
moz_places
|
||||||
|
where
|
||||||
|
rev_host like '%amgif.www.'
|
||||||
|
or rev_host like '%amgif.'
|
||||||
|
group by
|
||||||
|
rev_host
|
||||||
|
order by
|
||||||
|
sum(frecency) desc
|
||||||
|
limit
|
||||||
|
8;
|
||||||
|
|
||||||
|
select
|
||||||
|
*
|
||||||
|
from
|
||||||
|
moz_places
|
||||||
|
where
|
||||||
|
rev_host like '%amgif%'
|
||||||
|
or url like '%figma%'
|
||||||
|
group by
|
||||||
|
rev_host
|
||||||
|
order by
|
||||||
|
sum(frecency) desc
|
||||||
|
limit
|
||||||
|
4;
|
||||||
|
|
||||||
|
select
|
||||||
|
*
|
||||||
|
from
|
||||||
|
moz_places
|
||||||
|
where
|
||||||
|
title like '%figma%'
|
||||||
|
group by
|
||||||
|
rev_host
|
||||||
|
order by
|
||||||
|
sum(frecency) desc
|
||||||
|
limit
|
||||||
|
3;
|
10
lib/isLocalStorageAvailable.ts
Normal file
10
lib/isLocalStorageAvailable.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export default function(){
|
||||||
|
var test = 'test';
|
||||||
|
try {
|
||||||
|
localStorage.setItem(test, test);
|
||||||
|
localStorage.removeItem(test);
|
||||||
|
return true;
|
||||||
|
} catch(e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
31
lib/loadSettings.ts
Normal file
31
lib/loadSettings.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import isLocalStorageAvailable from "./isLocalStorageAvailable";
|
||||||
|
|
||||||
|
const defaultSettings = {
|
||||||
|
version: 1,
|
||||||
|
elementBackdrop: true,
|
||||||
|
bgBlur: true,
|
||||||
|
timeShowSecond: false,
|
||||||
|
currentSearchEngine: "google",
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function (setSettings: any) {
|
||||||
|
if (isLocalStorageAvailable()===false){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem("settings") === null) {
|
||||||
|
localStorage.setItem("settings", JSON.stringify(defaultSettings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const localSettings = JSON.parse(localStorage.getItem("settings") as string);
|
||||||
|
setSettings(localSettings);
|
||||||
|
}
|
19
lib/normalizeURL.ts
Normal file
19
lib/normalizeURL.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
|
console.error("Invalid URL:", input);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
1
lib/url/tldList.ts
Normal file
1
lib/url/tldList.ts
Normal file
File diff suppressed because one or more lines are too long
33
lib/url/validLink.ts
Normal file
33
lib/url/validLink.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import punycode from "punycode/";
|
||||||
|
import { tldList } from "./tldList";
|
||||||
|
|
||||||
|
export default function validLink(link: string) {
|
||||||
|
let finalURL = '';
|
||||||
|
try {
|
||||||
|
const url = new URL(link);
|
||||||
|
finalURL = url.origin;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// if the URL is invalid, try to add the protocol
|
||||||
|
try {
|
||||||
|
const urlWithHTTP = new URL("http://" + link);
|
||||||
|
finalURL = urlWithHTTP.origin;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validTLD(finalURL)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
3
lib/version.ts
Normal file
3
lib/version.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const SPARKHOME_VERSION="4.7.2";
|
||||||
|
export const CLIENT_VERSION="4.7.1";
|
||||||
|
export const NEXT_API_VERSION="4.7.1";
|
21
messages/en.json
Normal file
21
messages/en.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "Page Not Found"
|
||||||
|
},
|
||||||
|
"About": {
|
||||||
|
"title": "SparkHome"
|
||||||
|
}
|
||||||
|
}
|
18
messages/zh.json
Normal file
18
messages/zh.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"Search": {
|
||||||
|
"placeholder": "搜索或输入网址",
|
||||||
|
"engine-aria": "搜索引擎切换",
|
||||||
|
"engine": {
|
||||||
|
"google": "谷歌",
|
||||||
|
"baidu": "百度",
|
||||||
|
"bing": "必应",
|
||||||
|
"duckduckgo": "DuckDuckGo",
|
||||||
|
"yandex": "Yandex",
|
||||||
|
"yahoo": "雅虎",
|
||||||
|
"ecosia": "Ecosia"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"title": "未找到"
|
||||||
|
}
|
||||||
|
}
|
14
middleware.ts
Normal file
14
middleware.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
|
||||||
|
export default createMiddleware({
|
||||||
|
// A list of all locales that are supported
|
||||||
|
locales: ['en', 'zh'],
|
||||||
|
|
||||||
|
// Used when no locale matches
|
||||||
|
defaultLocale: 'en'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Match only internationalized pathnames
|
||||||
|
matcher: ['/', '/(zh|en)/:path*']
|
||||||
|
};
|
@ -1,4 +1,9 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@ -9,7 +14,8 @@ const nextConfig = {
|
|||||||
pathname: "/*"
|
pathname: "/*"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
output: 'standalone'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withNextIntl(nextConfig);
|
38
package.json
38
package.json
@ -1,29 +1,47 @@
|
|||||||
{
|
{
|
||||||
"name": "sparkhome",
|
"name": "sparkhome",
|
||||||
"version": "4.1.0",
|
"version": "4.10.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "14.1.1",
|
"@nextui-org/react": "^2.2.10",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"framer-motion": "^11.0.25",
|
||||||
|
"next": "14.1.4",
|
||||||
|
"next-intl": "^3.11.1",
|
||||||
|
"next-themes": "^0.3.0",
|
||||||
|
"punycode": "^2.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"recoil": "^0.7.7",
|
"recoil": "^0.7.7",
|
||||||
|
"search-engine-autocomplete": "^0.4.2",
|
||||||
|
"tailwind-merge": "^2.2.2",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"valid-url": "^1.0.9",
|
"valid-url": "^1.0.9",
|
||||||
"validate-color": "^2.2.4"
|
"validate-color": "^2.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@jest/globals": "^29.7.0",
|
||||||
"@types/react": "^18",
|
"@testing-library/jest-dom": "^6.4.2",
|
||||||
"@types/react-dom": "^18",
|
"@testing-library/react": "^14.3.0",
|
||||||
|
"@types/jest": "^29.5.12",
|
||||||
|
"@types/node": "^20.12.6",
|
||||||
|
"@types/punycode": "^2.1.4",
|
||||||
|
"@types/react": "^18.2.75",
|
||||||
|
"@types/react-dom": "^18.2.24",
|
||||||
"@types/valid-url": "^1.0.7",
|
"@types/valid-url": "^1.0.7",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.4.19",
|
||||||
"postcss": "^8",
|
"jest": "^29.7.0",
|
||||||
"tailwindcss": "^3.3.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"typescript": "^5"
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"ts-jest": "^29.1.2",
|
||||||
|
"typescript": "^5.4.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
5875
pnpm-lock.yaml
5875
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -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}",
|
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./node_modules/@nextui-org/theme/dist/**/*.{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;
|
||||||
|
64
test/validLink.test.ts
Normal file
64
test/validLink.test.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { describe, expect, test } from "@jest/globals";
|
||||||
|
import validLink, { validTLD } from "../lib/url/validLink";
|
||||||
|
|
||||||
|
describe("Check if a string is an accessible domain/URL", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user