浏览代码

Merge branch 'main' into openmeteo-widget

Ben Phelps 2 年之前
父节点
当前提交
aedd9cfeb9

+ 4 - 0
public/locales/bg/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/ca/common.json

@@ -207,5 +207,9 @@
         "cpu": "Processador",
         "cpu": "Processador",
         "mem": "Memòria",
         "mem": "Memòria",
         "wait": "Si us plau, espereu"
         "wait": "Si us plau, espereu"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/de/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "RAM",
         "mem": "RAM",
         "wait": "Bitte warten"
         "wait": "Bitte warten"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/en/common.json

@@ -219,6 +219,10 @@
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
     },
     },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
+    },
     "wmo": {
     "wmo": {
         "0-day": "Sunny",
         "0-day": "Sunny",
         "0-night": "Clear",
         "0-night": "Clear",

+ 4 - 0
public/locales/es/common.json

@@ -207,5 +207,9 @@
         "cpu": "Procesador",
         "cpu": "Procesador",
         "mem": "Memoria",
         "mem": "Memoria",
         "wait": "Espere por favor"
         "wait": "Espere por favor"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/fi/common.json

@@ -207,5 +207,9 @@
         "wait": "Please wait",
         "wait": "Please wait",
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM"
         "mem": "MEM"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 5 - 1
public/locales/fr/common.json

@@ -47,7 +47,7 @@
         "wanted": "Demande",
         "wanted": "Demande",
         "queued": "En attente",
         "queued": "En attente",
         "movies": "Films",
         "movies": "Films",
-        "missing": "Missing"
+        "missing": "Manquant"
     },
     },
     "readarr": {
     "readarr": {
         "wanted": "Demande",
         "wanted": "Demande",
@@ -207,5 +207,9 @@
         "cpu": "Cpu",
         "cpu": "Cpu",
         "mem": "Mém",
         "mem": "Mém",
         "wait": "Merci de patienter"
         "wait": "Merci de patienter"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/he/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/hr/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/hu/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/it/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/nb-NO/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/nl/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/pl/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/pt-BR/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/pt/common.json

@@ -218,5 +218,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/ro/common.json

@@ -207,5 +207,9 @@
         "cpu": "Procesor",
         "cpu": "Procesor",
         "mem": "Memorie",
         "mem": "Memorie",
         "wait": "Te rugam sa astepti"
         "wait": "Te rugam sa astepti"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/ru/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/sr/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/sv/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Vänligen vänta"
         "wait": "Vänligen vänta"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 9 - 5
public/locales/te/common.json

@@ -92,7 +92,7 @@
         "wanted": "కావలెను",
         "wanted": "కావలెను",
         "queued": "క్యూయూఎడ్",
         "queued": "క్యూయూఎడ్",
         "movies": "సినిమాలు",
         "movies": "సినిమాలు",
-        "missing": "Missing"
+        "missing": "మిస్సింగ్"
     },
     },
     "lidarr": {
     "lidarr": {
         "wanted": "కావలెను",
         "wanted": "కావలెను",
@@ -192,11 +192,11 @@
         "up": "అప్",
         "up": "అప్",
         "down": "డౌన్",
         "down": "డౌన్",
         "wait": "దయచేసి వేచి ఉండండి",
         "wait": "దయచేసి వేచి ఉండండి",
-        "lan": "LAN",
+        "lan": "లాన్",
         "wlan": "WLAN",
         "wlan": "WLAN",
-        "devices": "Devices",
-        "lan_devices": "LAN Devices",
-        "wlan_devices": "WLAN Devices"
+        "devices": "పరికరాలు",
+        "lan_devices": "LAN పరికరాలు",
+        "wlan_devices": "WLAN పరికరాలు"
     },
     },
     "plex": {
     "plex": {
         "streams": "యాక్టివ్ స్ట్రీమ్‌లు",
         "streams": "యాక్టివ్ స్ట్రీమ్‌లు",
@@ -207,5 +207,9 @@
         "cpu": "సీపియూ",
         "cpu": "సీపియూ",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "దయచేసి వేచి ఉండండి"
         "wait": "దయచేసి వేచి ఉండండి"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/tr/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/vi/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/yue/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/zh-CN/common.json

@@ -207,5 +207,9 @@
         "cpu": "处理器",
         "cpu": "处理器",
         "mem": "内存",
         "mem": "内存",
         "wait": "请稍等"
         "wait": "请稍等"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 4 - 0
public/locales/zh-Hant/common.json

@@ -207,5 +207,9 @@
         "cpu": "CPU",
         "cpu": "CPU",
         "mem": "MEM",
         "mem": "MEM",
         "wait": "Please wait"
         "wait": "Please wait"
+    },
+    "homepagesearch": {
+        "bookmark": "Bookmark",
+        "service": "Service"
     }
     }
 }
 }

+ 156 - 0
src/components/quicklaunch.jsx

@@ -0,0 +1,156 @@
+import { useTranslation } from "react-i18next";
+import { useEffect, useState, useRef, useCallback, useContext } from "react";
+import classNames from "classnames";
+
+import { resolveIcon } from "./services/item";
+
+import { SettingsContext } from "utils/contexts/settings";
+
+export default function QuickLaunch({servicesAndBookmarks, searchString, setSearchString, isOpen, close, searchDescriptions}) {
+  const { t } = useTranslation();
+  const { settings } = useContext(SettingsContext);
+
+  const searchField = useRef();
+
+  const [results, setResults] = useState([]);
+  const [currentItemIndex, setCurrentItemIndex] = useState(null);
+
+  function openCurrentItem(newWindow) {
+    const result = results[currentItemIndex];
+    window.open(result.href, newWindow ? "_blank" : result.target ?? settings.target ?? "_blank");
+  }
+
+  const closeAndReset = useCallback(() => {
+    close(false);
+    setTimeout(() => {
+      setSearchString("");
+      setCurrentItemIndex(null);
+    }, 200); // delay a little for animations
+  }, [close, setSearchString, setCurrentItemIndex]);
+
+  function handleSearchChange(event) {
+    setSearchString(event.target.value.toLowerCase())
+  }
+
+  function handleSearchKeyDown(event) {
+    if (event.key === "Escape") {
+      closeAndReset();
+    } else if (event.key === "Enter" && results.length) {
+      closeAndReset();
+      openCurrentItem(event.metaKey);
+    } else if (event.key === "ArrowDown" && results[currentItemIndex + 1]) {
+      setCurrentItemIndex(currentItemIndex + 1);
+      event.preventDefault();
+    } else if (event.key === "ArrowUp" && currentItemIndex > 0) {
+      setCurrentItemIndex(currentItemIndex - 1);
+      event.preventDefault();
+    }
+  }
+
+  function handleItemHover(event) {
+    setCurrentItemIndex(parseInt(event.target?.dataset?.index, 10));
+  }
+
+  function handleItemClick(event) {
+    closeAndReset();
+    openCurrentItem(event.metaKey);
+  }
+
+  useEffect(() => {
+    if (searchString.length === 0) setResults([]);
+    else {
+      let newResults = servicesAndBookmarks.filter(r => {
+        const nameMatch = r.name.toLowerCase().includes(searchString);
+        let descriptionMatch;
+        if (searchDescriptions) {
+          descriptionMatch = r.description?.toLowerCase().includes(searchString)
+          r.priority = nameMatch ? 2 * (+nameMatch) : +descriptionMatch; // eslint-disable-line no-param-reassign
+        }
+        return nameMatch || descriptionMatch;
+      });
+
+      if (searchDescriptions) {
+        newResults = newResults.sort((a, b) => b.priority - a.priority);
+      }
+
+      setResults(newResults);
+
+      if (newResults.length) {
+        setCurrentItemIndex(0);
+      }
+    }
+  }, [searchString, servicesAndBookmarks, searchDescriptions]);
+
+
+  const [hidden, setHidden] = useState(true);
+  useEffect(() => {
+    function handleBackdropClick(event) {
+      if (event.target?.tagName === "DIV") closeAndReset();
+    }
+    
+    if (isOpen) {
+      searchField.current.focus();
+      document.body.addEventListener('click', handleBackdropClick);
+      setHidden(false);
+    } else {
+      document.body.removeEventListener('click', handleBackdropClick);
+      setTimeout(() => {
+        setHidden(true);
+      }, 300); // disable on close
+    }
+
+  }, [isOpen, closeAndReset]);
+
+  function highlightText(text) {
+    const parts = text.split(new RegExp(`(${searchString})`, 'gi'));
+    return <span>{parts.map(part => part.toLowerCase() === searchString.toLowerCase() ? <span className="bg-theme-300/10">{part}</span> : part)}</span>;
+  }
+
+  return (
+    <div className={classNames(
+      "relative z-10 ease-in-out duration-300 transition-opacity",
+      hidden && !isOpen && "hidden",
+      !hidden && isOpen && "opacity-100",
+      !isOpen && "opacity-0",
+    )} role="dialog" aria-modal="true">
+      <div className="fixed inset-0 bg-gray-500 bg-opacity-50" />
+      <div className="fixed inset-0 z-10 overflow-y-auto">
+        <div className="flex min-h-full min-w-full items-start justify-center text-center">
+          <dialog className="mt-[10%] min-w-[80%] max-w-[90%] md:min-w-[40%] rounded-md p-0 block font-medium text-theme-700 dark:text-theme-200 dark:hover:text-theme-300 shadow-md shadow-theme-900/10 dark:shadow-theme-900/20 bg-theme-50 dark:bg-theme-800">
+            <input placeholder="Search" className={classNames(
+              results.length > 0 && "rounded-t-md",
+              results.length === 0 && "rounded-md",
+              "w-full p-4 m-0 border-0 border-b border-slate-700 focus:border-slate-700 focus:outline-0 focus:ring-0 text-sm md:text-xl text-theme-700 dark:text-theme-200 bg-theme-60 dark:bg-theme-800"
+              )} type="text" autoCorrect="false" ref={searchField} value={searchString} onChange={handleSearchChange} onKeyDown={handleSearchKeyDown} />
+            {results.length > 0 && <ul className="max-h-[60vh] overflow-y-auto m-2">
+              {results.map((r, i) => (
+                <li key={r.name}>
+                  <button type="button" data-index={i} onMouseEnter={handleItemHover} className={classNames(
+                    "flex flex-row w-full items-center justify-between rounded-md text-sm md:text-xl py-2 px-4 cursor-pointer text-theme-700 dark:text-theme-200",
+                    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 && resolveIcon(r.icon)}
+                        {r.abbr && r.abbr}
+                      </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 && 
+                          <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.abbr ? t("homepagesearch.bookmark") : t("homepagesearch.service")}</div>
+                  </button>
+                </li>
+              ))}
+            </ul>}
+          </dialog>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 1 - 1
src/components/services/item.jsx

@@ -8,7 +8,7 @@ import Widget from "./widget";
 import Docker from "widgets/docker/component";
 import Docker from "widgets/docker/component";
 import { SettingsContext } from "utils/contexts/settings";
 import { SettingsContext } from "utils/contexts/settings";
 
 
-function resolveIcon(icon) {
+export function resolveIcon(icon) {
   // direct or relative URLs
   // direct or relative URLs
   if (icon.startsWith("http") || icon.startsWith("/")) {
   if (icon.startsWith("http") || icon.startsWith("/")) {
     return <Image src={`${icon}`} width={32} height={32} alt="logo" />;
     return <Image src={`${icon}`} width={32} height={32} alt="logo" />;

+ 33 - 0
src/pages/index.jsx

@@ -21,6 +21,7 @@ import { SettingsContext } from "utils/contexts/settings";
 import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/config/api-response";
 import { bookmarksResponse, servicesResponse, widgetsResponse } from "utils/config/api-response";
 import ErrorBoundary from "components/errorboundry";
 import ErrorBoundary from "components/errorboundry";
 import themes from "utils/styles/themes";
 import themes from "utils/styles/themes";
+import QuickLaunch from "components/quicklaunch";
 
 
 const ThemeToggle = dynamic(() => import("components/toggles/theme"), {
 const ThemeToggle = dynamic(() => import("components/toggles/theme"), {
   ssr: false,
   ssr: false,
@@ -173,6 +174,8 @@ function Home({ initialSettings }) {
   const { data: services } = useSWR("/api/services");
   const { data: services } = useSWR("/api/services");
   const { data: bookmarks } = useSWR("/api/bookmarks");
   const { data: bookmarks } = useSWR("/api/bookmarks");
   const { data: widgets } = useSWR("/api/widgets");
   const { data: widgets } = useSWR("/api/widgets");
+  
+  const servicesAndBookmarks = [...services.map(sg => sg.services).flat(), ...bookmarks.map(bg => bg.bookmarks).flat()]
 
 
   useEffect(() => {
   useEffect(() => {
     if (settings.language) {
     if (settings.language) {
@@ -188,6 +191,28 @@ function Home({ initialSettings }) {
     }
     }
   }, [i18n, settings, color, setColor, theme, setTheme]);
   }, [i18n, settings, color, setColor, theme, setTheme]);
 
 
+  const [searching, setSearching] = useState(false);
+  const [searchString, setSearchString] = useState("");
+
+  useEffect(() => {
+    function handleKeyDown(e) {
+      if (e.target.tagName === "BODY") {
+        if (String.fromCharCode(e.keyCode).match(/(\w|\s)/g) && !(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
+          setSearching(true);
+        } else if (e.key === "Escape") {
+          setSearchString("");
+          setSearching(false);
+        }
+      }
+    }
+
+    document.addEventListener('keydown', handleKeyDown);
+
+    return function cleanup() {
+      document.removeEventListener('keydown', handleKeyDown);
+    }
+  })
+
   return (
   return (
     <>
     <>
       <Head>
       <Head>
@@ -211,6 +236,14 @@ function Home({ initialSettings }) {
             headerStyles[initialSettings.headerStyle || "underlined"]
             headerStyles[initialSettings.headerStyle || "underlined"]
           )}
           )}
         >
         >
+          <QuickLaunch
+            servicesAndBookmarks={servicesAndBookmarks}
+            searchString={searchString}
+            setSearchString={setSearchString}
+            isOpen={searching}
+            close={setSearching}
+            searchDescriptions={settings.quicklook?.searchDescriptions}
+          />
           {widgets && (
           {widgets && (
             <>
             <>
               {widgets
               {widgets