Merge main

This commit is contained in:
Jason Fischer 2023-02-05 00:21:35 -08:00
commit 11ae52df4a
No known key found for this signature in database
52 changed files with 1012 additions and 211 deletions

View file

@ -6,7 +6,7 @@ import ResolvedIcon from "./resolvedicon";
import { SettingsContext } from "utils/contexts/settings";
export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchDescriptions}) {
export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchDescriptions, searchProvider}) {
const { t } = useTranslation();
const { settings } = useContext(SettingsContext);
@ -34,7 +34,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
function handleSearchKeyDown(event) {
if (!isOpen) return;
if (event.key === "Escape") {
closeAndReset();
event.preventDefault();
@ -50,6 +50,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
}
}
function handleItemHover(event) {
setCurrentItemIndex(parseInt(event.target?.dataset?.index, 10));
}
@ -75,6 +76,15 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
if (searchDescriptions) {
newResults = newResults.sort((a, b) => b.priority - a.priority);
}
if (searchProvider) {
newResults.push(
{
href: searchProvider.url + encodeURIComponent(searchString),
name: `${searchProvider.name ?? t("quicklaunch.custom")} ${t("quicklaunch.search")} `,
type: 'search',
}
)
}
setResults(newResults);
@ -82,7 +92,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
setCurrentItemIndex(0);
}
}
}, [searchString, servicesAndBookmarks, searchDescriptions]);
}, [searchString, servicesAndBookmarks, searchDescriptions, searchProvider, t]);
const [hidden, setHidden] = useState(true);
@ -90,7 +100,7 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
function handleBackdropClick(event) {
if (event.target?.tagName === "DIV") closeAndReset();
}
if (isOpen) {
searchField.current.focus();
document.body.addEventListener('click', handleBackdropClick);
@ -135,20 +145,20 @@ export default function QuickLaunch({servicesAndBookmarks, searchString, setSear
i === currentItemIndex && "bg-theme-300/50 dark:bg-theme-700/50",
)} onClick={handleItemClick}>
<div className="flex flex-row items-center mr-4 pointer-events-none">
<div className="w-5 text-xs mr-4">
{(r.icon || r.abbr) && <div className="w-5 text-xs mr-4">
{r.icon && <ResolvedIcon icon={r.icon} />}
{r.abbr && r.abbr}
</div>
</div>}
<div className="flex flex-col md:flex-row text-left items-baseline mr-4 pointer-events-none">
<span className="mr-4">{r.name}</span>
{r.description &&
{r.description &&
<span className="text-xs text-theme-600 text-light">
{searchDescriptions && r.priority < 2 ? highlightText(r.description) : r.description}
</span>
}
</div>
</div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">{r.type === 'service' ? t("quicklaunch.service") : t("quicklaunch.bookmark")}</div>
<div className="text-xs text-theme-600 font-bold pointer-events-none">{t(`quicklaunch.${r.type ? r.type.toLowerCase() : 'bookmark'}`)}</div>
</button>
</li>
))}

View file

@ -30,8 +30,8 @@ export default function Status({ service }) {
}
return (
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.health ?? data.status}>
<div className="text-[8px] font-bold text-emerald-500/80 uppercase">{data.health ?? data.status}</div>
<div className="w-auto px-1.5 py-0.5 text-center bg-theme-500/10 dark:bg-theme-900/50 rounded-b-[3px] overflow-hidden" title={data.health || data.status}>
<div className="text-[8px] font-bold text-emerald-500/80 uppercase">{data.health || data.status}</div>
</div>
);
}

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { useState, useEffect, Fragment } from "react";
import { useTranslation } from "next-i18next";
import { FiSearch } from "react-icons/fi";
import { SiDuckduckgo, SiMicrosoftbing, SiGoogle, SiBaidu, SiBrave } from "react-icons/si";
import { Listbox, Transition } from "@headlessui/react";
import classNames from "classnames";
const providers = {
export const searchProviders = {
google: {
name: "Google",
url: "https://www.google.com/search?q=",
@ -36,21 +38,55 @@ const providers = {
},
};
function getAvailableProviderIds(options) {
if (options.provider && Array.isArray(options.provider)) {
return Object.keys(searchProviders).filter((value) => options.provider.includes(value));
}
if (options.provider && searchProviders[options.provider]) {
return [options.provider];
}
return null;
}
const localStorageKey = "search-name";
export function getStoredProvider() {
if (typeof window !== 'undefined') {
const storedName = localStorage.getItem(localStorageKey);
if (storedName) {
return Object.values(searchProviders).find((el) => el.name === storedName);
}
}
return null;
}
export default function Search({ options }) {
const { t } = useTranslation();
const provider = providers[options.provider];
const [query, setQuery] = useState("");
const availableProviderIds = getAvailableProviderIds(options);
if (!provider) {
const [query, setQuery] = useState("");
const [selectedProvider, setSelectedProvider] = useState(searchProviders[availableProviderIds[0] ?? searchProviders.google]);
useEffect(() => {
const storedProvider = getStoredProvider();
let storedProviderKey = null;
storedProviderKey = Object.keys(searchProviders).find((pkey) => searchProviders[pkey] === storedProvider);
if (storedProvider && availableProviderIds.includes(storedProviderKey)) {
setSelectedProvider(storedProvider);
}
}, [availableProviderIds]);
if (!availableProviderIds) {
return null;
}
function handleSubmit(event) {
const q = encodeURIComponent(query);
if (provider.url) {
window.open(`${provider.url}${q}`, options.target || "_blank");
const { url } = selectedProvider;
if (url) {
window.open(`${url}${q}`, options.target || "_blank");
} else {
window.open(`${options.url}${q}`, options.target || "_blank");
}
@ -60,6 +96,11 @@ export default function Search({ options }) {
setQuery("");
}
const onChangeProvider = (provider) => {
setSelectedProvider(provider);
localStorage.setItem(localStorageKey, provider.name);
}
return (
<form className="flex-col relative h-8 my-4 min-w-fit grow first:ml-0 ml-4" onSubmit={handleSubmit}>
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none w-full text-theme-800 dark:text-white" />
@ -82,17 +123,55 @@ export default function Search({ options }) {
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={options.focus}
/>
<button
type="submit"
className="
<Listbox as="div" value={selectedProvider} onChange={onChangeProvider} className="relative text-left" disabled={availableProviderIds?.length === 1}>
<div>
<Listbox.Button
className="
absolute right-0.5 bottom-0.5 rounded-r-md px-4 py-2 border-1
text-white font-medium text-sm
bg-theme-600/40 dark:bg-white/10
focus:ring-theme-500 dark:focus:ring-white/50"
>
<provider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</button>
>
<selectedProvider.icon className="text-white w-3 h-3" />
<span className="sr-only">{t("search.search")}</span>
</Listbox.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Listbox.Options
className="absolute right-0 z-10 mt-1 origin-top-right rounded-md
bg-theme-100 dark:bg-theme-600 shadow-lg
ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div className="flex flex-col">
{availableProviderIds.map((providerId) => {
const p = searchProviders[providerId];
return (
<Listbox.Option key={providerId} value={p} as={Fragment}>
{({ active }) => (
<li
className={classNames(
"rounded-md cursor-pointer",
active ? "bg-theme-600/10 dark:bg-white/10 dark:text-gray-900" : "dark:text-gray-100"
)}
>
<p.icon className="h-4 w-4 mx-4 my-2" />
</li>
)}
</Listbox.Option>
);
})}
</div>
</Listbox.Options>
</Transition>
</Listbox>
</form>
);
}

View file

@ -20,7 +20,6 @@ export default function Widget({ options }) {
<BiError className="w-8 h-8 text-theme-800 dark:text-theme-200" />
<div className="flex flex-col ml-3 text-left">
<span className="text-theme-800 dark:text-theme-200 text-sm">{t("widget.api_error")}</span>
<span className="text-theme-800 dark:text-theme-200 text-xs">-</span>
</div>
</div>
</div>
@ -28,7 +27,7 @@ export default function Widget({ options }) {
);
}
const defaultSite = statsData?.data?.find(s => s.name === "default");
const defaultSite = options.site ? statsData?.data.find(s => s.desc === options.site) : statsData?.data?.find(s => s.name === "default");
if (!defaultSite) {
return (
@ -55,6 +54,8 @@ export default function Widget({ options }) {
const name = wan.gw_name ?? defaultSite.desc;
const uptime = wan["gw_system-stats"] ? wan["gw_system-stats"].uptime : null;
const dataEmpty = !(wan.show || lan.show || wlan.show || uptime);
return (
<div className="flex-none flex flex-row items-center mr-3 py-1.5">
<div className="flex flex-col">
@ -64,6 +65,14 @@ export default function Widget({ options }) {
{name}
</div>
</div>
{dataEmpty && <div className="flex flex-row ml-3 text-[8px] justify-between">
<div className="flex flex-row items-center justify-end">
<div className="flex flex-row">
<BiError className="w-4 h-4 text-theme-800 dark:text-theme-200" />
<span className="text-theme-800 dark:text-theme-200 text-xs">{t("unifi.empty_data")}</span>
</div>
</div>
</div>}
<div className="flex flex-row ml-3 text-[10px] justify-between">
{uptime && <div className="flex flex-row" title={t("unifi.uptime")}>
<div className="pr-0.5 text-theme-800 dark:text-theme-200">

View file

@ -6,6 +6,7 @@ import Head from "next/head";
import "styles/globals.css";
import "styles/theme.css";
import "styles/manrope.css";
import "styles/custom.css";
import nextI18nextConfig from "../../next-i18next.config";
import { ColorProvider } from "utils/contexts/color";

View file

@ -22,6 +22,7 @@ import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/conf
import ErrorBoundary from "components/errorboundry";
import themes from "utils/styles/themes";
import QuickLaunch from "components/quicklaunch";
import { getStoredProvider, searchProviders } from "components/widgets/search/search";
const ThemeToggle = dynamic(() => import("components/toggles/theme"), {
ssr: false,
@ -193,6 +194,20 @@ function Home({ initialSettings }) {
const [searching, setSearching] = useState(false);
const [searchString, setSearchString] = useState("");
let searchProvider = null;
const searchWidget = Object.values(widgets).find(w => w.type === "search");
if (searchWidget) {
if (Array.isArray(searchWidget.options?.provider)) {
// if search provider is a list, try to retrieve from localstorage, fall back to the first
searchProvider = getStoredProvider() ?? searchProviders[searchWidget.options.provider[0]];
} else if (searchWidget.options?.provider === 'custom') {
searchProvider = {
url: searchWidget.options.url
}
} else {
searchProvider = searchProviders[searchWidget.options?.provider];
}
}
useEffect(() => {
function handleKeyDown(e) {
@ -251,6 +266,7 @@ function Home({ initialSettings }) {
isOpen={searching}
close={setSearching}
searchDescriptions={settings.quicklaunch?.searchDescriptions}
searchProvider={settings.quicklaunch?.hideInternetSearch ? null : searchProvider}
/>
{widgets && (
<>

3
src/styles/custom.css Normal file
View file

@ -0,0 +1,3 @@
/*
Mount this file and define your custom styles
*/

View file

@ -233,6 +233,7 @@ export function cleanServiceGroups(groups) {
currency, // coinmarketcap widget
symbols,
defaultinterval,
site, // unifi widget
namespace, // kubernetes widget
app,
podSelector,
@ -256,6 +257,9 @@ export function cleanServiceGroups(groups) {
if (server) cleanedService.widget.server = server;
if (container) cleanedService.widget.container = container;
}
if (type === "unifi") {
if (site) cleanedService.widget.site = site;
}
if (type === "kubernetes") {
if (namespace) cleanedService.widget.namespace = namespace;
if (app) cleanedService.widget.app = app;

View file

@ -22,6 +22,7 @@ const components = {
jackett: dynamic(() => import("./jackett/component")),
jellyfin: dynamic(() => import("./emby/component")),
jellyseerr: dynamic(() => import("./jellyseerr/component")),
komga: dynamic(() => import("./komga/component")),
lidarr: dynamic(() => import("./lidarr/component")),
mastodon: dynamic(() => import("./mastodon/component")),
medusa: dynamic(() => import("./medusa/component")),
@ -64,6 +65,7 @@ const components = {
watchtower: dynamic(() => import("./watchtower/component")),
xteve: dynamic(() => import("./xteve/component")),
immich: dynamic(() => import("./immich/component")),
uptimekuma: dynamic(() => import("./uptimekuma/component")),
};
export default components;

View file

@ -0,0 +1,37 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import Block from "components/services/widget/block";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: libraryData, error: libraryError } = useWidgetAPI(widget, "libraries");
const { data: seriesData, error: seriesError } = useWidgetAPI(widget, "series");
const { data: bookData, error: bookError } = useWidgetAPI(widget, "books");
if (libraryError || seriesError || bookError) {
const finalError = libraryError ?? seriesError ?? bookError;
return <Container error={finalError} />;
}
if (!libraryData || !seriesData || !bookData) {
return (
<Container service={service}>
<Block label="komga.libraries" />
<Block label="komga.series" />
<Block label="komga.books" />
</Container>
);
}
return (
<Container service={service}>
<Block label="komga.libraries" value={t("common.number", { value: libraryData.total })} />
<Block label="komga.series" value={t("common.number", { value: seriesData.totalElements })} />
<Block label="komga.books" value={t("common.number", { value: bookData.totalElements })} />
</Container>
);
}

View file

@ -0,0 +1,30 @@
import genericProxyHandler from "utils/proxy/handlers/generic";
import { jsonArrayFilter } from "utils/proxy/api-helpers";
const widget = {
api: "{url}/api/v1/{endpoint}",
proxyHandler: genericProxyHandler,
mappings: {
libraries: {
endpoint: "libraries",
map: (data) => ({
total: jsonArrayFilter(data, (item) => !item.unavailable).length,
}),
},
series: {
endpoint: "series",
validate: [
"totalElements"
]
},
books: {
endpoint: "books",
validate: [
"totalElements"
]
},
},
};
export default widget;

View file

@ -22,7 +22,7 @@ export default function Component({ service }) {
return (
<Container service={service}>
<Block label="proxmoxbackupserver.datastore_usage" />
<Block label="proxmoxbackupserver.failed_tasks" />
<Block label="proxmoxbackupserver.failed_tasks_24h" />
<Block label="proxmoxbackupserver.cpu_usage" />
<Block label="proxmoxbackupserver.memory_usage" />
</Container>

View file

@ -15,7 +15,7 @@ export default function Component({ service }) {
return <Container error={statsError} />;
}
const defaultSite = statsData?.data?.find(s => s.name === "default");
const defaultSite = widget.site ? statsData?.data.find(s => s.desc === widget.site) : statsData?.data?.find(s => s.name === "default");
if (!defaultSite) {
return (
@ -38,6 +38,14 @@ export default function Component({ service }) {
const uptime = wan["gw_system-stats"] ? `${t("common.number", { value: wan["gw_system-stats"].uptime / 86400, maximumFractionDigits: 1 })} ${t("unifi.days")}` : null;
if (!(wan.show || lan.show || wlan.show || uptime)) {
return (
<Container service={service}>
<Block value={ t("unifi.empty_data") } />
</Container>
)
}
return (
<Container service={service}>
{uptime && <Block label="unifi.uptime" value={ uptime } />}

View file

@ -0,0 +1,55 @@
import { useTranslation } from "next-i18next";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
import Block from "components/services/widget/block";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data: statusData, error: statusError } = useWidgetAPI(widget, "status_page");
const { data: heartbeatData, error: heartbeatError } = useWidgetAPI(widget, "heartbeat");
if (statusError || heartbeatError) {
return <Container error={statusError ?? heartbeatError} />;
}
if (!statusData || !heartbeatData) {
return (
<Container service={service}>
<Block label="uptimekuma.up"/>
<Block label="uptimekuma.down"/>
<Block label="uptimekuma.uptime"/>
<Block label="uptimekuma.incidents"/>
</Container>
);
}
let sitesUp = 0;
let sitesDown = 0;
Object.values(heartbeatData.heartbeatList).forEach((siteList) => {
const lastHeartbeat = siteList[siteList.length - 1];
if (lastHeartbeat?.status === 1) {
sitesUp += 1;
} else {
sitesDown += 1;
}
});
// Adapted from https://github.com/bastienwirtz/homer/blob/b7cd8f9482e6836a96b354b11595b03b9c3d67cd/src/components/services/UptimeKuma.vue#L105
const uptimeList = Object.values(heartbeatData.uptimeList);
const percent = uptimeList.reduce((a, b) => a + b, 0) / uptimeList.length || 0;
const uptime = (percent * 100).toFixed(1);
const incidentTime = statusData.incident ? (Math.abs(new Date(statusData.incident?.createdDate) - new Date()) / 1000) / (60 * 60) : null;
return (
<Container service={service}>
<Block label="uptimekuma.up" value={t("common.number", { value: sitesUp })} />
<Block label="uptimekuma.down" value={t("common.number", { value: sitesDown })} />
<Block label="uptimekuma.uptime" value={t("common.percent", { value: uptime })} />
{incidentTime && <Block label="uptimekuma.incident" value={t("common.number", { value: Math.round(incidentTime) }) + t("uptimekuma.m")} />}
</Container>
);
}

View file

@ -0,0 +1,18 @@
// import credentialedProxyHandler from "utils/proxy/handlers/credentialed";
import genericProxyHandler from "utils/proxy/handlers/generic";
const widget = {
api: "{url}/api/{endpoint}/{slug}",
proxyHandler: genericProxyHandler,
mappings: {
status_page: {
endpoint: "status-page",
},
heartbeat: {
endpoint: "status-page/heartbeat",
},
}
};
export default widget;

View file

@ -16,6 +16,7 @@ import hdhomerun from "./hdhomerun/widget";
import homebridge from "./homebridge/widget";
import jackett from "./jackett/widget";
import jellyseerr from "./jellyseerr/widget";
import komga from "./komga/widget";
import lidarr from "./lidarr/widget";
import mastodon from "./mastodon/widget";
import medusa from "./medusa/widget";
@ -58,6 +59,7 @@ import unifi from "./unifi/widget";
import watchtower from "./watchtower/widget";
import xteve from "./xteve/widget";
import immich from "./immich/widget";
import uptimekuma from "./uptimekuma/widget";
const widgets = {
adguard,
@ -79,6 +81,7 @@ const widgets = {
jackett,
jellyfin: emby,
jellyseerr,
komga,
lidarr,
mastodon,
medusa,
@ -122,6 +125,7 @@ const widgets = {
watchtower,
xteve,
immich,
uptimekuma,
};
export default widgets;