diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index afc3dc9b..a41a0cb4 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -882,6 +882,9 @@ } }, "settings": { + "search": { + "placeholder": "Search settings..." + }, "account": { "accountDetails": { "deviceNameLabel": "Device name", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c0ad1750..3a85c096 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useAsyncFn } from "react-use"; +import { useAsyncFn, useWindowSize } from "react-use"; import { base64ToBuffer, @@ -13,6 +13,8 @@ import { getSettings, updateSettings } from "@/backend/accounts/settings"; import { editUser } from "@/backend/accounts/user"; import { getAllProviders } from "@/backend/providers/providers"; import { Button } from "@/components/buttons/Button"; +import { SearchBarInput } from "@/components/form/SearchBar"; +import { ThinContainer } from "@/components/layout/ThinContainer"; import { WideContainer } from "@/components/layout/WideContainer"; import { UserIcons } from "@/components/UserIcon"; import { Heading1 } from "@/components/utils/Text"; @@ -39,16 +41,68 @@ import { usePreviewThemeStore, useThemeStore } from "@/stores/theme"; import { SubPageLayout } from "./layouts/SubPageLayout"; import { PreferencesPart } from "./parts/settings/PreferencesPart"; -function SettingsLayout(props: { children: React.ReactNode }) { +function SettingsLayout(props: { + children: React.ReactNode; + searchQuery: string; + onSearchChange: (value: string, force: boolean) => void; + onSearchUnFocus: (newSearch?: string) => void; +}) { + const { t } = useTranslation(); const { isMobile } = useIsMobile(); + const searchRef = useRef(null); + const { width: windowWidth, height: windowHeight } = useWindowSize(); + + // Dynamic offset calculation like HeroPart + const topSpacing = 16; // Base spacing + const [stickyOffset, setStickyOffset] = useState(topSpacing); + + // Detect if running as a PWA on iOS + const isIOSPWA = + /iPad|iPhone|iPod/i.test(navigator.userAgent) && + window.matchMedia("(display-mode: standalone)").matches; + + const adjustedTopSpacing = isIOSPWA ? 60 : topSpacing; + const isLandscape = windowHeight < windowWidth && isIOSPWA; + const adjustedOffset = isLandscape ? -40 : 0; + + useEffect(() => { + if (windowWidth > 1280) { + // On large screens the bar goes inline with the nav elements + setStickyOffset(adjustedTopSpacing); + } else { + // On smaller screens the bar goes below the nav elements + setStickyOffset(adjustedTopSpacing + 60 + adjustedOffset); + } + }, [adjustedOffset, adjustedTopSpacing, windowWidth]); return ( + {/* Floating Search Bar - starts in sticky state */} +
+ + + +
+
{props.children}
@@ -102,6 +156,8 @@ export function AccountSettings(props: { } export function SettingsPage() { + const [searchQuery, setSearchQuery] = useState(""); + useEffect(() => { const hash = window.location.hash; if (hash) { @@ -118,6 +174,77 @@ export function SettingsPage() { const previewTheme = usePreviewThemeStore((s) => s.previewTheme); const setPreviewTheme = usePreviewThemeStore((s) => s.setPreviewTheme); + // Simple text search with highlighting + const handleSearchChange = useCallback((value: string, _force: boolean) => { + setSearchQuery(value); + + // Remove existing highlights + const existingHighlights = document.querySelectorAll(".search-highlight"); + existingHighlights.forEach((el) => { + const parent = el.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(el.textContent || ""), el); + parent.normalize(); + } + }); + + if (value.trim()) { + // Find and highlight matching text + const walker = document.createTreeWalker( + document.querySelector("[data-settings-content]") || document.body, + NodeFilter.SHOW_TEXT, + null, + ); + + let node = walker.nextNode(); + + while (node) { + const text = node.textContent || ""; + const lowerText = text.toLowerCase(); + const lowerValue = value.toLowerCase(); + + if (lowerText.includes(lowerValue)) { + const regex = new RegExp( + `(${value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, + "gi", + ); + const highlightedText = text.replace( + regex, + '$1', + ); + + if (highlightedText !== text) { + const wrapper = document.createElement("div"); + wrapper.innerHTML = highlightedText; + const parent = node.parentNode; + if (parent) { + while (wrapper.firstChild) { + parent.insertBefore(wrapper.firstChild, node); + } + parent.removeChild(node); + } + } + } + node = walker.nextNode(); + } + + // Scroll to first highlighted element + const firstHighlighted = document.querySelector(".search-highlight"); + if (firstHighlighted) { + firstHighlighted.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + } + }, []); + + const handleSearchUnFocus = useCallback((newSearch?: string) => { + if (newSearch !== undefined) { + setSearchQuery(newSearch); + } + }, []); + const appLanguage = useLanguageStore((s) => s.language); const setAppLanguage = useLanguageStore((s) => s.setLanguage); @@ -476,7 +603,11 @@ export function SettingsPage() { return ( - +
{t("settings.account.title")}