From 98179b5a336f8c9fa03f351a5a5362bfce52db90 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 26 Sep 2025 16:59:17 +0800 Subject: [PATCH 01/42] handle optimising better, add cancel button --- src/App.tsx | 3 +- .../components/MediaDatabaseCard.test.tsx | 39 ++++- src/components/MediaDatabaseCard.tsx | 146 +++++++++++++----- src/lib/coreApi.ts | 12 ++ src/lib/models.ts | 3 + src/translations/en-US.json | 3 + 6 files changed, 164 insertions(+), 42 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 908c4520..ce305ed6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,7 +55,8 @@ export default function App() { useEffect(() => { // Only show completion toast, progress is now shown in MediaDatabaseCard - if (!gamesIndex.indexing && prevGamesIndex?.indexing) { + // Skip toast if totalFiles is 0 (indicates cancellation) + if (!gamesIndex.indexing && prevGamesIndex?.indexing && (gamesIndex.totalFiles ?? 0) > 0) { toast.success((to) => , { id: "indexed", icon: ( diff --git a/src/__tests__/unit/components/MediaDatabaseCard.test.tsx b/src/__tests__/unit/components/MediaDatabaseCard.test.tsx index eb888b6b..842c19f7 100644 --- a/src/__tests__/unit/components/MediaDatabaseCard.test.tsx +++ b/src/__tests__/unit/components/MediaDatabaseCard.test.tsx @@ -7,6 +7,7 @@ import { CoreAPI } from '../../../lib/coreApi'; vi.mock('../../../lib/coreApi', () => ({ CoreAPI: { mediaGenerate: vi.fn(), + mediaGenerateCancel: vi.fn(), media: vi.fn() } })); @@ -91,8 +92,9 @@ describe('MediaDatabaseCard', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - expect(button).toBeDisabled(); + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons[0]; // The main update button is first + expect(updateButton).toBeDisabled(); }); it('should call CoreAPI.mediaGenerate when button is clicked', async () => { @@ -231,4 +233,37 @@ describe('MediaDatabaseCard', () => { const pulsingElements = container.querySelectorAll('.animate-pulse'); expect(pulsingElements.length).toBeGreaterThan(0); }); + + it('should show cancel button when indexing', () => { + mockStore.gamesIndex.indexing = true; + + render(); + + const cancelButton = screen.getByRole('button', { name: /settings\.updateDb\.cancel/i }); + expect(cancelButton).toBeInTheDocument(); + expect(cancelButton).not.toBeDisabled(); + }); + + it('should call CoreAPI.mediaGenerateCancel when cancel button is clicked', async () => { + mockStore.gamesIndex.indexing = true; + const { CoreAPI } = await import('../../../lib/coreApi'); + + render(); + + const cancelButton = screen.getByRole('button', { name: /settings\.updateDb\.cancel/i }); + fireEvent.click(cancelButton); + + expect(CoreAPI.mediaGenerateCancel).toHaveBeenCalledOnce(); + }); + + it('should not show cancel button when not indexing', () => { + mockStore.gamesIndex.indexing = false; + + render(); + + const cancelButton = screen.queryByRole('button', { name: /settings\.updateDb\.cancel/i }); + expect(cancelButton).not.toBeInTheDocument(); + }); + + // TODO: Add tests for optimization progress and total media count when query mocking is fixed }); \ No newline at end of file diff --git a/src/components/MediaDatabaseCard.tsx b/src/components/MediaDatabaseCard.tsx index d7f65f24..db628fd5 100644 --- a/src/components/MediaDatabaseCard.tsx +++ b/src/components/MediaDatabaseCard.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import classNames from "classnames"; -import { useQuery } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useStatusStore } from "@/lib/store"; import { CoreAPI } from "@/lib/coreApi"; import { DatabaseIcon } from "@/lib/images"; @@ -9,8 +10,17 @@ import { Button } from "./wui/Button"; export function MediaDatabaseCard() { const { t } = useTranslation(); + const queryClient = useQueryClient(); const connected = useStatusStore((state) => state.connected); const gamesIndex = useStatusStore((state) => state.gamesIndex); + const [isCancelling, setIsCancelling] = useState(false); + + // Reset cancelling state when indexing stops + useEffect(() => { + if (!gamesIndex.indexing && isCancelling) { + setIsCancelling(false); + } + }, [gamesIndex.indexing, isCancelling]); // Fetch real-time database status const { data: mediaStatus, isLoading } = useQuery({ @@ -25,37 +35,59 @@ export function MediaDatabaseCard() { CoreAPI.mediaGenerate(); }; + const handleCancelUpdate = async () => { + setIsCancelling(true); + try { + await CoreAPI.mediaGenerateCancel(); + // Invalidate media query to get fresh database status after cancellation + queryClient.invalidateQueries({ queryKey: ["media"] }); + } catch (error) { + console.error("Failed to cancel media generation:", error); + } finally { + setIsCancelling(false); + } + }; + const renderStatus = () => { // Show progress when indexing if (gamesIndex.indexing) { return ( -
-
- {gamesIndex.currentStepDisplay - ? gamesIndex.currentStep === gamesIndex.totalSteps - ? t("toast.writingDb") - : gamesIndex.currentStepDisplay - : t("toast.preparingDb")} -
-
-
+
+
+
+ {gamesIndex.currentStepDisplay + ? gamesIndex.currentStep === gamesIndex.totalSteps + ? t("toast.writingDb") + : gamesIndex.currentStepDisplay + : t("toast.preparingDb")} +
+
+
+
+
); } @@ -63,7 +95,7 @@ export function MediaDatabaseCard() { // Show connection status if (!connected) { return ( -
+
{t("settings.updateDb.status.noConnection")}
); @@ -72,7 +104,7 @@ export function MediaDatabaseCard() { // Show loading state while fetching database status if (isLoading) { return ( -
+
{t("settings.updateDb.status.checking")}
); @@ -81,20 +113,56 @@ export function MediaDatabaseCard() { // Use real database status from API const databaseExists = mediaStatus?.database?.exists ?? false; - if (!databaseExists) { + // Check optimization status first - this takes priority over database existence + const isOptimizing = mediaStatus?.database?.optimizing ?? false; + if (isOptimizing) { + return ( +
+
+ {mediaStatus?.database?.currentStepDisplay || + t("settings.updateDb.status.optimizing")} +
+
+
+
+
+ ); + } + + if (!databaseExists && !gamesIndex.indexing && !isOptimizing) { return ( -
- {t("create.search.gamesDbUpdate")} +
+ No database found
); } - // Database exists and is ready - never show file count here - return ( -
- {t("settings.updateDb.status.ready")} -
- ); + // Database exists and is ready - show media count if available + if (databaseExists) { + const totalMedia = mediaStatus?.database?.totalMedia; + if (totalMedia !== undefined && totalMedia > 0) { + const formattedCount = totalMedia.toLocaleString(); + return ( +
+ {t("settings.updateDb.status.mediaCount", { + count: totalMedia, + formattedCount + })} +
+ ); + } else { + return ( +
+ {t("settings.updateDb.status.ready")} +
+ ); + } + } + + return null; }; return ( @@ -112,4 +180,4 @@ export function MediaDatabaseCard() {
); -} \ No newline at end of file +} diff --git a/src/lib/coreApi.ts b/src/lib/coreApi.ts index d1940e06..a29a65f1 100644 --- a/src/lib/coreApi.ts +++ b/src/lib/coreApi.ts @@ -601,6 +601,18 @@ class CoreApi { }); } + mediaGenerateCancel(): Promise { + return new Promise((resolve, reject) => { + this.call(Method.MediaGenerateCancel) + .then(() => { + resolve(); + }) + .catch((error) => { + console.error("Media generate cancel API call failed:", error); + reject(error); + }); + }); + } systems(): Promise { return new Promise((resolve, reject) => { diff --git a/src/lib/models.ts b/src/lib/models.ts index a4361700..d2960a39 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -6,6 +6,7 @@ export enum Method { Media = "media", MediaSearch = "media.search", MediaGenerate = "media.generate", + MediaGenerateCancel = "media.generate.cancel", MediaActive = "media.active", MediaActiveUpdate = "media.active.update", Systems = "systems", @@ -160,10 +161,12 @@ export interface TokenResponse { export interface IndexResponse { exists: boolean; indexing: boolean; + optimizing?: boolean; totalSteps?: number; currentStep?: number; currentStepDisplay?: string; totalFiles?: number; + totalMedia?: number; } export interface PlayingResponse { diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 8566cb93..2889f59a 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -191,7 +191,10 @@ "insertMode": "Hold", "insertHelp": "Exits the media when tag is removed. Requires a separate NFC reader.", "updateDb": "Update media database", + "updateDb.cancel": "Cancel", "updateDb.status.ready": "Database is ready", + "updateDb.status.optimizing": "Optimizing database...", + "updateDb.status.mediaCount": "Database ready: {{formattedCount}} media files", "updateDb.status.noConnection": "Not connected", "updateDb.status.checking": "Checking database status...", "designer": "Zaparoo Designer", From 4beb92fb0495920588d099b8bb5a567bc3761a83 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 28 Sep 2025 14:07:41 +0800 Subject: [PATCH 02/42] search improvements --- package.json | 1 + pnpm-lock.yaml | 20 + src/components/BackToTop.tsx | 9 +- src/components/BottomNav.tsx | 2 +- src/components/MediaDatabaseCard.tsx | 90 +++-- src/components/SlideModal.tsx | 12 +- src/components/SystemSelector.tsx | 503 ++++++++++++++++++++++++++ src/components/ui/loading-spinner.tsx | 32 ++ src/components/ui/tabs.tsx | 2 +- src/routes/create.search.tsx | 52 ++- src/translations/en-US.json | 19 +- 11 files changed, 692 insertions(+), 50 deletions(-) create mode 100644 src/components/SystemSelector.tsx create mode 100644 src/components/ui/loading-spinner.tsx diff --git a/package.json b/package.json index a0afd02f..f09da176 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.81.5", "@tanstack/react-router": "^1.125.6", + "@tanstack/react-virtual": "^3.13.12", "@uidotdev/usehooks": "^2.4.1", "axios": "1.10.0", "class-variance-authority": "^0.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5e32424..5a2734b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@tanstack/react-router': specifier: ^1.125.6 version: 1.125.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1711,6 +1714,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.125.4': resolution: {integrity: sha512-tdgGI0Kwt3Lgs9ceLbG32NPh4I2H1T9t2TKjcS+I78sifm5rjTWV8lfqVRNrvMPk5ek60vXPOL2AHAUg6ohwsA==} engines: {node: '>=12'} @@ -1751,6 +1760,9 @@ packages: '@tanstack/store@0.7.2': resolution: {integrity: sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==} + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tanstack/virtual-file-routes@1.121.21': resolution: {integrity: sha512-3nuYsTyaq6ZN7jRZ9z6Gj3GXZqBOqOT0yzd/WZ33ZFfv4yVNIvsa5Lw+M1j3sgyEAxKMqGu/FaNi7FCjr3yOdw==} engines: {node: '>=12'} @@ -6835,6 +6847,12 @@ snapshots: react-dom: 19.1.0(react@19.1.0) use-sync-external-store: 1.5.0(react@19.1.0) + '@tanstack/react-virtual@3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + '@tanstack/router-core@1.125.4': dependencies: '@tanstack/history': 1.121.34 @@ -6903,6 +6921,8 @@ snapshots: '@tanstack/store@0.7.2': {} + '@tanstack/virtual-core@3.13.12': {} + '@tanstack/virtual-file-routes@1.121.21': {} '@testing-library/dom@10.4.1': diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx index e5716214..46daca09 100644 --- a/src/components/BackToTop.tsx +++ b/src/components/BackToTop.tsx @@ -6,11 +6,13 @@ import { debounce } from "lodash"; interface BackToTopProps { scrollContainerRef: RefObject; threshold?: number; + bottomOffset?: string; } export function BackToTop({ scrollContainerRef, - threshold = 300 + threshold = 300, + bottomOffset = "calc(1rem + 80px)" }: BackToTopProps) { const [isVisible, setIsVisible] = useState(false); const { t } = useTranslation(); @@ -49,7 +51,7 @@ export function BackToTop({ return (
- + <> + +
+ {/* System selector for choosing which systems to update */} + setSystemSelectorOpen(true)} + /> + +
+
+ + setSystemSelectorOpen(false)} + onSelect={setSelectedSystems} + selectedSystems={selectedSystems} + mode="multi" + title={t("settings.updateDb.selectSystemsTitle")} + includeAllOption={true} + /> + ); } diff --git a/src/components/SlideModal.tsx b/src/components/SlideModal.tsx index 09e609a8..728f06e7 100644 --- a/src/components/SlideModal.tsx +++ b/src/components/SlideModal.tsx @@ -13,6 +13,8 @@ export function SlideModal(props: { children: ReactNode; className?: string; scrollRef?: RefObject; + footer?: ReactNode; + fixedHeight?: string; }) { const modalId = useId(); const modalManager = useSlideModalManager(); @@ -103,7 +105,10 @@ export function SlideModal(props: { style={{ bottom: props.isOpen ? "0" : "-100vh", transition: "bottom 0.2s ease-in-out", - maxHeight: `calc(100vh - ${safeInsets.top} - 75px)` + ...(props.fixedHeight + ? { height: props.fixedHeight } + : { maxHeight: `calc(100vh - ${safeInsets.top} - 75px)` } + ) }} >
{props.children}
+ {props.footer && ( +
+ {props.footer} +
+ )}
); diff --git a/src/components/SystemSelector.tsx b/src/components/SystemSelector.tsx new file mode 100644 index 00000000..4e00c2f9 --- /dev/null +++ b/src/components/SystemSelector.tsx @@ -0,0 +1,503 @@ +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Search, Check, X } from "lucide-react"; +import { useDebounce } from "use-debounce"; +import classNames from "classnames"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; +import { SlideModal } from "./SlideModal"; +import { Button } from "./wui/Button"; +import { BackToTop } from "./BackToTop"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; + +export interface System { + id: string; + name: string; + category?: string; +} + +interface SystemSelectorProps { + isOpen: boolean; + onClose: () => void; + onSelect: (systems: string[]) => void; + selectedSystems: string[]; + mode: "single" | "multi"; + title?: string; + includeAllOption?: boolean; +} + +interface GroupedSystems { + [category: string]: System[]; +} + +const ITEM_HEIGHT = 56; // Height of each system item in pixels + +export function SystemSelector({ + isOpen, + onClose, + onSelect, + selectedSystems, + mode, + title, + includeAllOption = false +}: SystemSelectorProps) { + const { t } = useTranslation(); + const scrollContainerRef = useRef(null); + const tabsListRef = useRef(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [debouncedSearchQuery] = useDebounce(searchQuery, 300); + const [showLeftGradient, setShowLeftGradient] = useState(false); + const [showRightGradient, setShowRightGradient] = useState(true); + + // Get indexing state to disable selector when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + // Fetch systems data + const { data: systemsData, isLoading } = useQuery({ + queryKey: ["systems"], + queryFn: () => CoreAPI.systems() + }); + + // Process and filter systems + const { filteredSystems, categories } = useMemo(() => { + if (!systemsData?.systems) { + return { filteredSystems: [], categories: [] }; + } + + const systems = systemsData.systems; + + // Group systems by category + const grouped: GroupedSystems = {}; + const categorySet = new Set(); + + systems.forEach((system) => { + const category = system.category || "Other"; + categorySet.add(category); + + if (!grouped[category]) { + grouped[category] = []; + } + grouped[category].push(system); + }); + + // Sort categories alphabetically, but put common ones first + const priorityCategories = ["Nintendo", "Sony", "Sega", "Atari"]; + const categories = Array.from(categorySet).sort((a, b) => { + const aPriority = priorityCategories.indexOf(a); + const bPriority = priorityCategories.indexOf(b); + + if (aPriority !== -1 && bPriority !== -1) { + return aPriority - bPriority; + } + if (aPriority !== -1) return -1; + if (bPriority !== -1) return 1; + return a.localeCompare(b); + }); + + // Filter systems based on search and category + let filtered: System[] = []; + + if (selectedCategory === "all") { + filtered = systems; + } else { + filtered = grouped[selectedCategory] || []; + } + + // Apply search filter + if (debouncedSearchQuery.trim()) { + const query = debouncedSearchQuery.toLowerCase(); + filtered = filtered.filter( + (system) => + system.name.toLowerCase().includes(query) || + system.id.toLowerCase().includes(query) + ); + } + + // Sort filtered systems by name + filtered.sort((a, b) => a.name.localeCompare(b.name)); + + return { filteredSystems: filtered, categories }; + }, [systemsData, debouncedSearchQuery, selectedCategory]); + + // Handle system selection + const handleSystemSelect = useCallback( + (systemId: string) => { + // Don't allow selection while indexing + if (gamesIndex.indexing) return; + + if (mode === "single") { + onSelect([systemId]); + onClose(); + } else { + const newSelection = selectedSystems.includes(systemId) + ? selectedSystems.filter((id) => id !== systemId) + : [...selectedSystems, systemId]; + onSelect(newSelection); + } + }, + [mode, selectedSystems, onSelect, onClose, gamesIndex.indexing] + ); + + // Handle clear all + const handleClearAll = useCallback(() => { + // Don't allow clearing while indexing + if (gamesIndex.indexing) return; + onSelect([]); + }, [onSelect, gamesIndex.indexing]); + + // Handle apply (for multi-select) + const handleApply = useCallback(() => { + onClose(); + }, [onClose]); + + // Handle tabs scroll for gradient visibility + const handleTabsScroll = useCallback(() => { + const container = tabsListRef.current; + if (!container) return; + + const { scrollLeft, scrollWidth, clientWidth } = container; + + // Show left gradient if scrolled from start + setShowLeftGradient(scrollLeft > 0); + + // Show right gradient if not at end + setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); + }, []); + + // Set up virtualizer + const virtualizer = useVirtualizer({ + count: filteredSystems.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5 + }); + + // Set up tabs scroll listener for gradient visibility + useEffect(() => { + handleTabsScroll(); // Check initial state + const container = tabsListRef.current; + if (container) { + container.addEventListener("scroll", handleTabsScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleTabsScroll); + } + }, [handleTabsScroll, categories]); // Re-check when categories change + + // Footer for multi-select mode + const footer = + mode === "multi" ? ( +
+
+ + {t("systemSelector.selectedCount", { + count: selectedSystems.length + })} + +
+
+ {selectedSystems.length > 0 && ( + + )} +
+
+ ) : undefined; + + return ( + +
+ {/* Header with search */} +
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="border-input bg-background text-foreground w-full rounded-md border px-10 py-2 text-sm focus:ring-2 focus:ring-white/20 focus:outline-none" + /> + {searchQuery && ( + + )} +
+
+ + {/* Category tabs using shadcn tabs */} + +
+ {/* Left gradient - only show when scrolled */} +
+ +
+ + + {t("systemSelector.allCategories")} + + {categories.map((category) => ( + + {category} + + ))} + +
+ + {/* Right gradient - only show when more content */} +
+
+ + + {isLoading ? ( +
+ {t("loading")} +
+ ) : filteredSystems.length === 0 ? ( +
+ + {debouncedSearchQuery + ? t("systemSelector.noResults") + : t("systemSelector.noSystems")} + +
+ ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const system = filteredSystems[virtualItem.index]; + const isSelected = selectedSystems.includes(system.id); + + return ( +
+ +
+ ); + })} +
+
+ )} +
+ + + {/* Scroll to top button */} + +
+ + ); +} + +// Helper component for displaying selected systems +export function SystemSelectorTrigger({ + selectedSystems, + systemsData, + placeholder = "Select systems", + mode = "multi", + className, + onClick +}: { + selectedSystems: string[]; + systemsData?: { systems: System[] }; + placeholder?: string; + mode?: "single" | "multi"; + className?: string; + onClick: () => void; +}) { + const { t } = useTranslation(); + + // Get indexing state to disable trigger when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + const displayText = useMemo(() => { + if (!systemsData?.systems) return placeholder; + + if (selectedSystems.length === 0) { + return placeholder; + } + + if (selectedSystems.length === systemsData.systems.length) { + return t("systemSelector.allSystems"); + } + + if (mode === "single" && selectedSystems.length === 1) { + const system = systemsData.systems.find( + (s) => s.id === selectedSystems[0] + ); + return system?.name || selectedSystems[0]; + } + + if (selectedSystems.length <= 3) { + const systemNames = selectedSystems + .map((id) => systemsData.systems.find((s) => s.id === id)?.name || id) + .join(", "); + return systemNames; + } + + return t("systemSelector.multipleSelected", { + count: selectedSystems.length + }); + }, [selectedSystems, systemsData, placeholder, mode, t]); + + const handleClick = () => { + // Don't open selector while indexing + if (gamesIndex.indexing) return; + onClick(); + }; + + return ( + + ); +} diff --git a/src/components/ui/loading-spinner.tsx b/src/components/ui/loading-spinner.tsx new file mode 100644 index 00000000..85f78a59 --- /dev/null +++ b/src/components/ui/loading-spinner.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +interface LoadingSpinnerProps { + className?: string; + size?: number; +} + +export const LoadingSpinner = React.forwardRef< + SVGSVGElement, + LoadingSpinnerProps +>(({ className, size = 24 }, ref) => { + return ( + + + + ); +}); + +LoadingSpinner.displayName = "LoadingSpinner"; \ No newline at end of file diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index ddaefccd..cb24dbdc 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -11,7 +11,7 @@ const TabsList = React.forwardRef< (null); @@ -98,6 +101,14 @@ function Search() { preventScrollOnSwipe: false }); + // Handle system selection from selector + const handleSystemSelect = async (systems: string[]) => { + const selectedSystem = systems.length === 1 ? systems[0] : "all"; + setQuerySystem(selectedSystem); + setSelectedResult(null); + await Preferences.set({ key: "searchSystem", value: selectedSystem }); + }; + return ( <> {t("create.search.systemInput")} - + setSystemSelectorOpen(true)} + className={classNames({ + "opacity-50": !connected || !gamesIndex.exists || gamesIndex.indexing + })} + /> +
+
+ )} +
); } - if (props.resp.total > 0) { + if (props.resp.results.length > 0) { return ( <> -

- {props.resp.total > props.resp.results.length - ? t("create.search.gamesFoundMax", { - count: props.resp.total, - max: props.resp.results.length - }) - : t("create.search.gamesFound", { count: props.resp.total })} -

-
+ {/* Screen reader announcements */} +
+ {getAriaLiveMessage()} +
+ + {/* Results list */} +
{props.resp.results.map((game, i) => { const handleGameSelect = () => { if ( @@ -127,6 +190,23 @@ export function SearchResults(props: {

{game.name}

{game.system.name}

+ {game.tags && game.tags.length > 0 && ( +
+ {game.tags.slice(0, 4).map((tag, tagIndex) => ( + + {tag.tag} + + ))} + {game.tags.length > 4 && ( + + +{game.tags.length - 4} + + )} +
+ )}
diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx new file mode 100644 index 00000000..f5158872 --- /dev/null +++ b/src/components/TagSelector.tsx @@ -0,0 +1,460 @@ +import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Search, Check, X } from "lucide-react"; +import { useDebounce } from "use-debounce"; +import classNames from "classnames"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; +import { TagInfo } from "@/lib/models"; +import { SlideModal } from "./SlideModal"; +import { Button } from "./wui/Button"; +import { BackToTop } from "./BackToTop"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; + +interface TagSelectorProps { + isOpen: boolean; + onClose: () => void; + onSelect: (tags: string[]) => void; + selectedTags: string[]; + systems?: string[]; + title?: string; +} + +interface GroupedTags { + [type: string]: TagInfo[]; +} + +const ITEM_HEIGHT = 56; // Height of each tag item in pixels + +export function TagSelector({ + isOpen, + onClose, + onSelect, + selectedTags, + systems = [], + title +}: TagSelectorProps) { + const { t } = useTranslation(); + const scrollContainerRef = useRef(null); + const tabsListRef = useRef(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedType, setSelectedType] = useState("all"); + const [debouncedSearchQuery] = useDebounce(searchQuery, 300); + const [showLeftGradient, setShowLeftGradient] = useState(false); + const [showRightGradient, setShowRightGradient] = useState(true); + + // Get indexing state to disable selector when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + // Fetch tags data + const { data: tagsData, isLoading } = useQuery({ + queryKey: ["tags", systems], + queryFn: () => CoreAPI.mediaTags(systems.length > 0 ? systems : undefined), + enabled: isOpen // Only fetch when modal is open + }); + + // Process and filter tags + const { filteredTags, types } = useMemo(() => { + if (!tagsData?.tags) { + return { filteredTags: [], types: [] }; + } + + const tags = tagsData.tags; + + // Group tags by type + const grouped: GroupedTags = {}; + const typeSet = new Set(); + + tags.forEach((tag) => { + typeSet.add(tag.type); + + if (!grouped[tag.type]) { + grouped[tag.type] = []; + } + grouped[tag.type].push(tag); + }); + + // Sort types alphabetically, but put common ones first + const priorityTypes = ["genre", "year", "series", "publisher"]; + const types = Array.from(typeSet).sort((a, b) => { + const aPriority = priorityTypes.indexOf(a); + const bPriority = priorityTypes.indexOf(b); + + if (aPriority !== -1 && bPriority !== -1) { + return aPriority - bPriority; + } + if (aPriority !== -1) return -1; + if (bPriority !== -1) return 1; + return a.localeCompare(b); + }); + + // Filter tags based on search and type + let filtered: TagInfo[] = []; + + if (selectedType === "all") { + filtered = tags; + } else { + filtered = grouped[selectedType] || []; + } + + // Apply search filter + if (debouncedSearchQuery.trim()) { + const query = debouncedSearchQuery.toLowerCase(); + filtered = filtered.filter( + (tag) => + tag.tag.toLowerCase().includes(query) || + tag.type.toLowerCase().includes(query) + ); + } + + // Sort filtered tags by tag name + filtered.sort((a, b) => a.tag.localeCompare(b.tag)); + + return { filteredTags: filtered, types }; + }, [tagsData, debouncedSearchQuery, selectedType]); + + // Handle tag selection + const handleTagSelect = useCallback( + (tag: string) => { + // Don't allow selection while indexing + if (gamesIndex.indexing) return; + + const newSelection = selectedTags.includes(tag) + ? selectedTags.filter((t) => t !== tag) + : [...selectedTags, tag]; + onSelect(newSelection); + }, + [selectedTags, onSelect, gamesIndex.indexing] + ); + + // Handle clear all + const handleClearAll = useCallback(() => { + // Don't allow clearing while indexing + if (gamesIndex.indexing) return; + onSelect([]); + }, [onSelect, gamesIndex.indexing]); + + // Handle apply + const handleApply = useCallback(() => { + onClose(); + }, [onClose]); + + // Handle tabs scroll for gradient visibility + const handleTabsScroll = useCallback(() => { + const container = tabsListRef.current; + if (!container) return; + + const { scrollLeft, scrollWidth, clientWidth } = container; + + // Show left gradient if scrolled from start + setShowLeftGradient(scrollLeft > 0); + + // Show right gradient if not at end + setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); + }, []); + + // Set up virtualizer + const virtualizer = useVirtualizer({ + count: filteredTags.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ITEM_HEIGHT, + overscan: 5 + }); + + // Set up tabs scroll listener for gradient visibility + useEffect(() => { + handleTabsScroll(); // Check initial state + const container = tabsListRef.current; + if (container) { + container.addEventListener("scroll", handleTabsScroll, { passive: true }); + return () => container.removeEventListener("scroll", handleTabsScroll); + } + }, [handleTabsScroll, types]); // Re-check when types change + + // Footer for multi-select mode + const footer = ( +
+
+ + {t("tagSelector.selectedCount", { + count: selectedTags.length + })} + +
+
+ {selectedTags.length > 0 && ( + + )} +
+
+ ); + + return ( + +
+ {/* Header with search */} +
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="border-input bg-background text-foreground w-full rounded-md border px-10 py-2 text-sm focus:ring-2 focus:ring-white/20 focus:outline-none" + /> + {searchQuery && ( + + )} +
+
+ + {/* Type tabs using shadcn tabs */} + +
+ {/* Left gradient - only show when scrolled */} +
+ +
+ + + {t("tagSelector.allTypes")} + + {types.map((type) => ( + + {t(`tagSelector.type.${type}`, { defaultValue: type })} + + ))} + +
+ + {/* Right gradient - only show when more content */} +
+
+ + + {isLoading ? ( +
+ {t("loading")} +
+ ) : filteredTags.length === 0 ? ( +
+ + {debouncedSearchQuery + ? t("tagSelector.noResults") + : t("tagSelector.noTags")} + +
+ ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const tag = filteredTags[virtualItem.index]; + const isSelected = selectedTags.includes(tag.tag); + + return ( +
+ +
+ ); + })} +
+
+ )} +
+ + + {/* Scroll to top button */} + +
+ + ); +} + +// Helper component for displaying selected tags +export function TagSelectorTrigger({ + selectedTags, + placeholder = "Select tags", + className, + onClick +}: { + selectedTags: string[]; + placeholder?: string; + className?: string; + onClick: () => void; +}) { + const { t } = useTranslation(); + + // Get indexing state to disable trigger when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + const displayText = useMemo(() => { + if (selectedTags.length === 0) { + return placeholder; + } + + if (selectedTags.length <= 3) { + return selectedTags.join(", "); + } + + return t("tagSelector.multipleSelected", { + count: selectedTags.length + }); + }, [selectedTags, placeholder, t]); + + const handleClick = () => { + // Don't open selector while indexing + if (gamesIndex.indexing) return; + onClick(); + }; + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/wui/TextInput.tsx b/src/components/wui/TextInput.tsx index edff62b6..5340845e 100644 --- a/src/components/wui/TextInput.tsx +++ b/src/components/wui/TextInput.tsx @@ -1,6 +1,6 @@ import React, { KeyboardEventHandler, useEffect, useState } from "react"; import classNames from "classnames"; -import { SaveIcon } from "../../lib/images"; +import { SaveIcon, ClearIcon } from "../../lib/images"; import { Button } from "./Button"; export function TextInput(props: { @@ -11,6 +11,7 @@ export function TextInput(props: { disabled?: boolean; className?: string; saveValue?: (value: string) => void; + clearable?: boolean; type?: string; onKeyUp?: KeyboardEventHandler; ref?: React.RefObject; @@ -31,38 +32,58 @@ export function TextInput(props: {
{props.label && {props.label}}
- { - setValue(e.target.value); - setModified(true); +
+ 0 && !props.disabled, + "rounded-md": !props.saveValue, + "rounded-s-md": props.saveValue + } + )} + disabled={props.disabled} + placeholder={props.placeholder} + value={value} + onChange={(e) => { + setValue(e.target.value); + setModified(true); - if (props.setValue) { - props.setValue(e.target.value); - } - }} - onKeyUp={props.onKeyUp} - /> + if (props.setValue) { + props.setValue(e.target.value); + } + }} + onKeyUp={props.onKeyUp} + /> + {props.clearable && value && value.length > 0 && !props.disabled && ( + + )} +
{props.saveValue && (
+
+ setTagSelectorOpen(false)} + onSelect={handleTagSelect} + selectedTags={queryTags} + systems={querySystem === "all" ? [] : [querySystem]} + title={t("create.search.selectTags")} + /> ); } diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 083d3a55..d14e27e6 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -90,18 +90,28 @@ "gamesDbUpdate": "Media database must be updated before searching", "searchError": "Error searching for media", "noGamesFound": "No media found", - "gamesFound": "{{count}} found", - "gamesFoundMax": "{{count}} found (showing first {{max}})", "title": "Search for Media", "gameInput": "Name", "gameInputPlaceholder": "Enter media name", "systemInput": "System", "allSystems": "All systems", + "tagsInput": "Tags", + "allTags": "All tags", "systemLabel": "System:", "pathLabel": "Path:", "writeLabel": "Write to tag", "playLabel": "Preview", - "selectSystem": "Select System" + "selectSystem": "Select System", + "selectTags": "Select Tags", + "loading": "Loading...", + "startSearching": "Ready to search", + "startSearchingHint": "Enter a game name or select filters to start searching", + "noResultsFound": "No results found for \"{{query}}\"", + "noResultsFoundSimple": "No results found", + "tryRemovingFiltersOnly": "Try removing filters to see more results", + "tryDifferentSearch": "Try removing filters or searching with different terms", + "tryDifferentTerms": "Try different search terms or check your spelling", + "clearFilters": "Clear filters" }, "custom": { "title": "Custom ZapScript", @@ -269,6 +279,26 @@ "multipleSelected": "{{count}} systems selected", "clearAll": "Clear all", "apply": "Apply" + }, + "tagSelector": { + "title": "Select Tags", + "searchPlaceholder": "Search tags...", + "allTypes": "All types", + "noResults": "No tags found", + "noTags": "No tags available", + "selectedCount": "{{count}} tags selected", + "multipleSelected": "{{count}} tags selected", + "clearAll": "Clear all", + "apply": "Apply", + "type": { + "genre": "Genre", + "year": "Year", + "series": "Series", + "publisher": "Publisher", + "developer": "Developer", + "platform": "Platform", + "category": "Category" + } } } } From 57bc3e8a3b5bb4a0461e21c6700efe73ed71eeb2 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 29 Sep 2025 09:08:22 +0800 Subject: [PATCH 04/42] search input spacing --- src/components/wui/TextInput.tsx | 2 +- src/routes/create.search.tsx | 72 ++++++++++++++++++-------------- src/translations/en-US.json | 2 +- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/components/wui/TextInput.tsx b/src/components/wui/TextInput.tsx index 5340845e..070437ea 100644 --- a/src/components/wui/TextInput.tsx +++ b/src/components/wui/TextInput.tsx @@ -30,7 +30,7 @@ export function TextInput(props: { return (
- {props.label && {props.label}} + {props.label && {props.label}}
=> { - const [systemPreference, tagPreference, systemsResponse] = await Promise.all([ - Preferences.get({ key: "searchSystem" }), - Preferences.get({ key: "searchTags" }), - CoreAPI.systems() - ]); + const [systemPreference, tagPreference, systemsResponse] = + await Promise.all([ + Preferences.get({ key: "searchSystem" }), + Preferences.get({ key: "searchTags" }), + CoreAPI.systems() + ]); let savedTags: string[] = []; try { @@ -56,7 +63,6 @@ interface LoaderData { systems: SystemsResponse; } - function Search() { const loaderData = Route.useLoaderData(); const gamesIndex = useStatusStore((state) => state.gamesIndex); @@ -110,17 +116,18 @@ function Search() { }; // State for search results - const [searchResults, setSearchResults] = useState(null); + const [searchResults, setSearchResults] = + useState(null); const [searchError, setSearchError] = useState(null); - const [selectedResult, setSelectedResult] = useState( null ); // Check if search has valid parameters const canSearch = connected && gamesIndex.exists && !gamesIndex.indexing; - const hasSearchParameters = query.trim() !== "" || querySystem !== "all" || queryTags.length > 0; + const hasSearchParameters = + query.trim() !== "" || querySystem !== "all" || queryTags.length > 0; const nfcWriter = useNfcWriter(); const [writeOpen, setWriteOpen] = useState(false); @@ -190,7 +197,11 @@ function Search() { back={() => navigate({ to: "/create" })} scrollRef={scrollContainerRef} > -
+
-
+
-
-
-
-
+
- +
- + Date: Mon, 29 Sep 2025 15:11:23 +0800 Subject: [PATCH 05/42] infinite scroll/pagination --- .../integration/route-interactions.test.tsx | 10 +- .../unit/components/MediaSearchModal.test.tsx | 5 + src/components/MediaSearchModal.tsx | 50 ++- src/components/SearchResults.tsx | 10 +- src/components/SystemSelector.tsx | 78 ++-- src/components/TagSelector.tsx | 73 ++-- src/components/VirtualSearchResults.tsx | 358 ++++++++++++++++++ src/hooks/useDragToScroll.ts | 85 +++++ src/hooks/useSmartTabs.ts | 109 ++++++ src/hooks/useVirtualInfiniteSearch.ts | 93 +++++ src/lib/models.ts | 8 + src/routes/create.search.tsx | 61 +-- src/translations/en-US.json | 3 +- 13 files changed, 789 insertions(+), 154 deletions(-) create mode 100644 src/components/VirtualSearchResults.tsx create mode 100644 src/hooks/useDragToScroll.ts create mode 100644 src/hooks/useSmartTabs.ts create mode 100644 src/hooks/useVirtualInfiniteSearch.ts diff --git a/src/__tests__/integration/route-interactions.test.tsx b/src/__tests__/integration/route-interactions.test.tsx index ab2b71aa..c5c48274 100644 --- a/src/__tests__/integration/route-interactions.test.tsx +++ b/src/__tests__/integration/route-interactions.test.tsx @@ -258,7 +258,15 @@ describe("Route Interactions - Integration Tests", () => { { id: "2", name: "Sonic", system: "Genesis", path: "/games/sonic.bin" } ]; - mockCoreAPI.mediaSearch.mockResolvedValue({ results: searchResults }); + mockCoreAPI.mediaSearch.mockResolvedValue({ + results: searchResults, + total: searchResults.length, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: searchResults.length, + } + }); const SearchToWriteFlow = () => { const [results, setResults] = React.useState([]); diff --git a/src/__tests__/unit/components/MediaSearchModal.test.tsx b/src/__tests__/unit/components/MediaSearchModal.test.tsx index 4f7586b5..8d1570d3 100644 --- a/src/__tests__/unit/components/MediaSearchModal.test.tsx +++ b/src/__tests__/unit/components/MediaSearchModal.test.tsx @@ -45,6 +45,11 @@ vi.mock("@/lib/coreApi", () => ({ { path: "/games/mario.sfc", name: "Super Mario World", systemName: "Super Nintendo" }, ], total: 1, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: 1, + }, }), }, })); diff --git a/src/components/MediaSearchModal.tsx b/src/components/MediaSearchModal.tsx index 7a833fdc..f7712f5d 100644 --- a/src/components/MediaSearchModal.tsx +++ b/src/components/MediaSearchModal.tsx @@ -2,14 +2,15 @@ import { useTranslation } from "react-i18next"; import { useEffect, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Preferences } from "@capacitor/preferences"; -import { useDebounce } from "use-debounce"; import { useStatusStore } from "@/lib/store.ts"; import { CoreAPI } from "@/lib/coreApi.ts"; import { SlideModal } from "@/components/SlideModal.tsx"; import { TextInput } from "@/components/wui/TextInput.tsx"; -import { SearchResults } from "@/components/SearchResults.tsx"; +import { Button } from "@/components/wui/Button.tsx"; +import { VirtualSearchResults } from "@/components/VirtualSearchResults.tsx"; import { SearchResultGame } from "@/lib/models.ts"; import { BackToTop } from "@/components/BackToTop.tsx"; +import { SearchIcon } from "@/lib/images.tsx"; export function MediaSearchModal(props: { isOpen: boolean; @@ -31,11 +32,12 @@ export function MediaSearchModal(props: { const { t } = useTranslation(); const [query, setQuery] = useState(""); - const [debouncedQuery] = useDebounce(query, 750); const [selectedSystem, setSelectedSystem] = useState("all"); const [selectedResult, setSelectedResult] = useState( null ); + const [hasSearched, setHasSearched] = useState(false); + const [isSearching, setIsSearching] = useState(false); const connected = useStatusStore((state) => state.connected); const { close, onSelect } = props; const scrollContainerRef = useRef(null); @@ -47,20 +49,15 @@ export function MediaSearchModal(props: { const gamesIndex = useStatusStore((state) => state.gamesIndex); - const searchResults = useQuery({ - queryKey: ["mediaSearch", debouncedQuery, selectedSystem], - queryFn: () => - CoreAPI.mediaSearch({ - query: debouncedQuery, - systems: selectedSystem === "all" ? [] : [selectedSystem] - }), - enabled: - debouncedQuery.length >= 2 && - gamesIndex.exists && - !gamesIndex.indexing, - retry: 3, - retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) - }); + // Manual search function + const performSearch = () => { + if (!connected || !gamesIndex.exists || gamesIndex.indexing || query.trim().length < 2) { + return; + } + + setIsSearching(true); + setHasSearched(true); + }; useEffect(() => { if (selectedResult) { @@ -90,6 +87,7 @@ export function MediaSearchModal(props: { onKeyUp={(e) => { if (e.key === "Enter" || e.keyCode === 13) { e.currentTarget.blur(); + performSearch(); } }} /> @@ -123,14 +121,24 @@ export function MediaSearchModal(props: { ))}
+ +
- setIsSearching(false)} />
diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx index c07cb3fa..22bc701a 100644 --- a/src/components/SearchResults.tsx +++ b/src/components/SearchResults.tsx @@ -64,7 +64,7 @@ export function SearchResults(props: { // Show initial state when no search has been performed if (!props.hasSearched && !props.resp) { return ( -
+

{t("create.search.startSearching")}

{t("create.search.startSearchingHint")}

@@ -74,7 +74,7 @@ export function SearchResults(props: { // Show loading spinner when searching if (props.loading) { return ( -
+
{t("create.search.loading")}
@@ -82,7 +82,7 @@ export function SearchResults(props: { } if (props.error) { - return

{t("create.search.searchError")}

; + return

{t("create.search.searchError")}

; } if (!props.resp) { @@ -114,7 +114,7 @@ export function SearchResults(props: { } return ( -
+

{mainMessage}

{suggestionMessage && (

@@ -143,7 +143,7 @@ export function SearchResults(props: {

{/* Results list */} -
+
{props.resp.results.map((game, i) => { const handleGameSelect = () => { if ( diff --git a/src/components/SystemSelector.tsx b/src/components/SystemSelector.tsx index 4e00c2f9..3cff34e7 100644 --- a/src/components/SystemSelector.tsx +++ b/src/components/SystemSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import { useState, useMemo, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -7,6 +7,7 @@ import { useDebounce } from "use-debounce"; import classNames from "classnames"; import { CoreAPI } from "@/lib/coreApi"; import { useStatusStore } from "@/lib/store"; +import { useSmartTabs } from "@/hooks/useSmartTabs"; import { SlideModal } from "./SlideModal"; import { Button } from "./wui/Button"; import { BackToTop } from "./BackToTop"; @@ -45,7 +46,7 @@ export function SystemSelector({ }: SystemSelectorProps) { const { t } = useTranslation(); const scrollContainerRef = useRef(null); - const tabsListRef = useRef(null); + const slideModalScrollRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState("all"); @@ -53,6 +54,20 @@ export function SystemSelector({ const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(true); + // Smart tabs hook for overflow detection and drag scrolling + const { hasOverflow, tabsProps } = useSmartTabs({ + onScrollChange: (scrollLeft, overflow) => { + if (!overflow) return; + + const container = tabsProps.ref.current; + if (!container) return; + + const { scrollWidth, clientWidth } = container; + setShowLeftGradient(scrollLeft > 0); + setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); + } + }); + // Get indexing state to disable selector when indexing is in progress const gamesIndex = useStatusStore((state) => state.gamesIndex); @@ -154,20 +169,6 @@ export function SystemSelector({ onClose(); }, [onClose]); - // Handle tabs scroll for gradient visibility - const handleTabsScroll = useCallback(() => { - const container = tabsListRef.current; - if (!container) return; - - const { scrollLeft, scrollWidth, clientWidth } = container; - - // Show left gradient if scrolled from start - setShowLeftGradient(scrollLeft > 0); - - // Show right gradient if not at end - setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); - }, []); - // Set up virtualizer const virtualizer = useVirtualizer({ count: filteredSystems.length, @@ -176,16 +177,6 @@ export function SystemSelector({ overscan: 5 }); - // Set up tabs scroll listener for gradient visibility - useEffect(() => { - handleTabsScroll(); // Check initial state - const container = tabsListRef.current; - if (container) { - container.addEventListener("scroll", handleTabsScroll, { passive: true }); - return () => container.removeEventListener("scroll", handleTabsScroll); - } - }, [handleTabsScroll, categories]); // Re-check when categories change - // Footer for multi-select mode const footer = mode === "multi" ? ( @@ -230,7 +221,7 @@ export function SystemSelector({ close={onClose} title={title || t("systemSelector.title")} footer={footer} - scrollRef={scrollContainerRef} + scrollRef={slideModalScrollRef} fixedHeight="90vh" >
@@ -265,17 +256,18 @@ export function SystemSelector({ className="flex min-h-0 flex-1 flex-col" >
- {/* Left gradient - only show when scrolled */} -
+ {/* Left gradient - only show when scrolled and overflowing */} + {hasOverflow && ( +
+ )}
{t("systemSelector.allCategories")} @@ -288,12 +280,14 @@ export function SystemSelector({
- {/* Right gradient - only show when more content */} -
+ {/* Right gradient - only show when more content and overflowing */} + {hasOverflow && ( +
+ )}
diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index f5158872..db942316 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef, useCallback, useEffect } from "react"; +import { useState, useMemo, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -7,6 +7,7 @@ import { useDebounce } from "use-debounce"; import classNames from "classnames"; import { CoreAPI } from "@/lib/coreApi"; import { useStatusStore } from "@/lib/store"; +import { useSmartTabs } from "@/hooks/useSmartTabs"; import { TagInfo } from "@/lib/models"; import { SlideModal } from "./SlideModal"; import { Button } from "./wui/Button"; @@ -38,7 +39,6 @@ export function TagSelector({ }: TagSelectorProps) { const { t } = useTranslation(); const scrollContainerRef = useRef(null); - const tabsListRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const [selectedType, setSelectedType] = useState("all"); @@ -46,6 +46,20 @@ export function TagSelector({ const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(true); + // Smart tabs hook for overflow detection and drag scrolling + const { hasOverflow, tabsProps } = useSmartTabs({ + onScrollChange: (scrollLeft, overflow) => { + if (!overflow) return; + + const container = tabsProps.ref.current; + if (!container) return; + + const { scrollWidth, clientWidth } = container; + setShowLeftGradient(scrollLeft > 0); + setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); + } + }); + // Get indexing state to disable selector when indexing is in progress const gamesIndex = useStatusStore((state) => state.gamesIndex); @@ -142,20 +156,6 @@ export function TagSelector({ onClose(); }, [onClose]); - // Handle tabs scroll for gradient visibility - const handleTabsScroll = useCallback(() => { - const container = tabsListRef.current; - if (!container) return; - - const { scrollLeft, scrollWidth, clientWidth } = container; - - // Show left gradient if scrolled from start - setShowLeftGradient(scrollLeft > 0); - - // Show right gradient if not at end - setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); - }, []); - // Set up virtualizer const virtualizer = useVirtualizer({ count: filteredTags.length, @@ -164,16 +164,6 @@ export function TagSelector({ overscan: 5 }); - // Set up tabs scroll listener for gradient visibility - useEffect(() => { - handleTabsScroll(); // Check initial state - const container = tabsListRef.current; - if (container) { - container.addEventListener("scroll", handleTabsScroll, { passive: true }); - return () => container.removeEventListener("scroll", handleTabsScroll); - } - }, [handleTabsScroll, types]); // Re-check when types change - // Footer for multi-select mode const footer = (
@@ -252,17 +242,18 @@ export function TagSelector({ className="flex min-h-0 flex-1 flex-col" >
- {/* Left gradient - only show when scrolled */} -
+ {/* Left gradient - only show when scrolled and overflowing */} + {hasOverflow && ( +
+ )}
{t("tagSelector.allTypes")} @@ -275,12 +266,14 @@ export function TagSelector({
- {/* Right gradient - only show when more content */} -
+ {/* Right gradient - only show when more content and overflowing */} + {hasOverflow && ( +
+ )}
void; + hasSearched?: boolean; + searchSystem?: string; + searchTags?: string[]; + onClearFilters?: () => void; + isSearching?: boolean; + onSearchComplete?: () => void; + scrollContainerRef?: RefObject; +} + +export function VirtualSearchResults({ + query, + systems, + tags = [], + selectedResult, + setSelectedResult, + hasSearched = false, + searchSystem = "all", + searchTags = [], + onClearFilters, + isSearching = false, + onSearchComplete, + scrollContainerRef +}: VirtualSearchResultsProps) { + const gamesIndex = useStatusStore((state) => state.gamesIndex); + const { t } = useTranslation(); + + const { + allItems, + totalCount, + isLoading, + isFetchingNextPage, + hasNextPage, + isError, + fetchNextPage, + refetch + } = useVirtualInfiniteSearch({ + query, + systems, + tags, + enabled: hasSearched && gamesIndex.exists && !gamesIndex.indexing + }); + + // Call onSearchComplete when loading finishes + useEffect(() => { + if (!isLoading && hasSearched && onSearchComplete) { + onSearchComplete(); + } + }, [isLoading, hasSearched, onSearchComplete]); + + // Calculate estimated item height based on whether tags are shown + const estimateSize = useCallback((index: number) => { + if (index >= allItems.length) return 60; // Loading item + const item = allItems[index]; + // Increased estimates to accommodate wrapped text and reduce layout shifts + // Base height: 32px (pt-3 pb-5) + content + 1px border = total (gap handled by virtualizer) + // Without tags: ~60px content + 32px padding + 1px border = 93px + // With tags: ~80px content + 32px padding + 1px border = 113px + return item.tags && item.tags.length > 0 ? 113 : 93; + }, [allItems]); + + const virtualizer = useVirtualizer({ + count: totalCount + (hasNextPage ? 1 : 0), // +1 for loading sentinel + getScrollElement: () => scrollContainerRef?.current || null, + estimateSize, + overscan: 5, + scrollMargin: 8, + gap: 8 + }); + + // Fetch next page when approaching the end + useEffect(() => { + const [lastItem] = [...virtualizer.getVirtualItems()].reverse(); + + if (!lastItem) return; + + if ( + lastItem.index >= totalCount - 1 && + hasNextPage && + !isFetchingNextPage + ) { + fetchNextPage(); + } + }, [ + hasNextPage, + fetchNextPage, + totalCount, + isFetchingNextPage, + virtualizer.getVirtualItems() + ]); + + // Screen reader announcement for search results + const getAriaLiveMessage = () => { + if (isLoading) return t("create.search.loading"); + if (isError) return t("create.search.searchError"); + if (totalCount > 0) { + return `${totalCount} ${totalCount === 1 ? 'result' : 'results'} found`; + } + return ""; + }; + + if (!gamesIndex.exists) { + return ( + +
+
+ +
+
+ + {t("create.search.gamesDbUpdate")} + +
+ +
+
+ ); + } + + // Show initial state when no search has been performed + if (!hasSearched) { + return ( +
+

{t("create.search.startSearching")}

+

{t("create.search.startSearchingHint")}

+
+ ); + } + + // Show loading spinner when searching initially + if (isLoading || isSearching) { + return ( +
+ + {t("create.search.loading")} +
+ ); + } + + if (isError) { + return ( +
+

{t("create.search.searchError")}

+
+ ); + } + + // Enhanced empty state with helpful suggestions + if (totalCount === 0) { + const hasActiveFilters = searchSystem !== "all" || (searchTags && searchTags.length > 0); + const hasQuery = query && query.trim().length > 0; + + let mainMessage: string; + let suggestionMessage: string; + + if (!hasQuery) { + // No search query - just show simple message + mainMessage = t("create.search.noResultsFoundSimple"); + suggestionMessage = hasActiveFilters + ? t("create.search.tryRemovingFiltersOnly") + : ""; + } else { + // Has search query + mainMessage = t("create.search.noResultsFound", { query: query }); + if (hasActiveFilters) { + suggestionMessage = t("create.search.tryDifferentSearch"); + } else { + suggestionMessage = t("create.search.tryDifferentTerms"); + } + } + + return ( +
+

{mainMessage}

+ {suggestionMessage && ( +

+ {suggestionMessage} +

+ )} + {hasActiveFilters && onClearFilters && ( +
+
+ )} +
+ ); + } + + return ( + <> + {/* Screen reader announcements */} +
+ {getAriaLiveMessage()} +
+ + {/* Virtual scrolling container */} +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const isLoading = virtualItem.index >= totalCount; + const game = allItems[virtualItem.index]; + + return ( +
+ {isLoading ? ( +
+ + {t("create.search.loading")} +
+ ) : ( + + )} +
+ ); + })} +
+ + ); +} + +interface SearchResultItemProps { + game: SearchResultGame; + selectedResult: SearchResultGame | null; + setSelectedResult: (game: SearchResultGame | null) => void; + isLast: boolean; + index: number; +} + +const SearchResultItem = React.memo(function SearchResultItem({ + game, + selectedResult, + setSelectedResult, + isLast, + index +}: SearchResultItemProps) { + const handleGameSelect = () => { + if ( + selectedResult && + selectedResult.path === game.path + ) { + setSelectedResult(null); + } else if ( + selectedResult && + selectedResult.path !== game.path + ) { + setSelectedResult(null); + setTimeout(() => { + setSelectedResult(game); + }, 150); + } else { + setSelectedResult(game); + } + }; + + return ( +
{ + e.preventDefault(); + handleGameSelect(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleGameSelect(); + } + }} + > +
+

{game.name}

+

{game.system.name}

+ {game.tags && game.tags.length > 0 && ( +
+ {game.tags.slice(0, 4).map((tag, tagIndex) => ( + + {tag.tag} + + ))} + {game.tags.length > 4 && ( + + +{game.tags.length - 4} + + )} +
+ )} +
+
+ +
+
+ ); +}); \ No newline at end of file diff --git a/src/hooks/useDragToScroll.ts b/src/hooks/useDragToScroll.ts new file mode 100644 index 00000000..b3f3cd13 --- /dev/null +++ b/src/hooks/useDragToScroll.ts @@ -0,0 +1,85 @@ +import React, { useRef, useCallback, useEffect } from "react"; + +interface DragToScrollOptions { + enabled?: boolean; + scrollSensitivity?: number; +} + +interface DragToScrollReturn { + isDragging: boolean; + dragProps: { + ref: React.RefObject; + onMouseDown: (e: React.MouseEvent) => void; + style: React.CSSProperties; + }; +} + +/** + * Hook that enables drag-to-scroll functionality for horizontal scrolling containers. + * Provides a smooth drag experience similar to mobile touch scrolling on desktop. + */ +export function useDragToScroll({ + enabled = true, + scrollSensitivity = 1 +}: DragToScrollOptions = {}): DragToScrollReturn { + const elementRef = useRef(null); + const isDraggingRef = useRef(false); + const startXRef = useRef(0); + const scrollStartRef = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if (!enabled || !elementRef.current) return; + + // Only handle left mouse button + if (e.button !== 0) return; + + isDraggingRef.current = true; + startXRef.current = e.clientX; + scrollStartRef.current = elementRef.current.scrollLeft; + + // Prevent text selection while dragging + e.preventDefault(); + + // Add global event listeners for drag + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!isDraggingRef.current || !elementRef.current) return; + + const deltaX = moveEvent.clientX - startXRef.current; + const newScrollLeft = scrollStartRef.current - (deltaX * scrollSensitivity); + + elementRef.current.scrollLeft = newScrollLeft; + }; + + const handleMouseUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + }, [enabled, scrollSensitivity]); + + // Clean up event listeners on unmount + useEffect(() => { + return () => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + }; + }, []); + + const dragProps = { + ref: elementRef, + onMouseDown: handleMouseDown, + style: { + cursor: enabled ? (isDraggingRef.current ? "grabbing" : "grab") : "default", + userSelect: "none" as const + } + }; + + return { + isDragging: isDraggingRef.current, + dragProps + }; +} \ No newline at end of file diff --git a/src/hooks/useSmartTabs.ts b/src/hooks/useSmartTabs.ts new file mode 100644 index 00000000..cffca740 --- /dev/null +++ b/src/hooks/useSmartTabs.ts @@ -0,0 +1,109 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { useDragToScroll } from "./useDragToScroll"; + +interface SmartTabsOptions { + onScrollChange?: (scrollLeft: number, hasOverflow: boolean) => void; +} + +interface SmartTabsReturn { + hasOverflow: boolean; + isDragging: boolean; + tabsProps: { + ref: React.RefObject; + onMouseDown: (e: React.MouseEvent) => void; + onScroll?: (e: React.UIEvent) => void; + style: React.CSSProperties; + className: string; + }; +} + +/** + * Hook that provides smart tab behavior: + * - Detects overflow and enables drag-to-scroll when needed + * - Centers tabs when they fit within the container + * - Provides scroll change callbacks for gradient indicators + */ +export function useSmartTabs({ + onScrollChange +}: SmartTabsOptions = {}): SmartTabsReturn { + const [hasOverflow, setHasOverflow] = useState(false); + + // Get drag-to-scroll functionality + const { isDragging, dragProps } = useDragToScroll({ + enabled: hasOverflow + }); + + // Check for overflow + const checkOverflow = useCallback(() => { + const element = dragProps.ref.current; + if (!element) return; + + const isOverflowing = element.scrollWidth > element.clientWidth; + setHasOverflow(isOverflowing); + }, [dragProps.ref]); + + // Handle scroll events for gradient indicators + const handleScroll = useCallback((e: React.UIEvent) => { + if (!onScrollChange) return; + + const element = e.currentTarget; + onScrollChange(element.scrollLeft, hasOverflow); + }, [onScrollChange, hasOverflow]); + + // Set up resize observer to detect overflow changes + useEffect(() => { + const element = dragProps.ref.current; + if (!element) return; + + // Initial check + checkOverflow(); + + // Create resize observer to watch for size changes + const resizeObserver = new ResizeObserver(() => { + checkOverflow(); + }); + + resizeObserver.observe(element); + + // Also observe children changes (when tabs are added/removed) + const mutationObserver = new MutationObserver(() => { + // Use setTimeout to ensure DOM has updated + setTimeout(checkOverflow, 0); + }); + + mutationObserver.observe(element, { + childList: true, + subtree: true + }); + + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [checkOverflow, dragProps.ref]); + + // Determine the appropriate className based on overflow state + const getClassName = () => { + const baseClasses = "flex w-full"; + + if (hasOverflow) { + // When overflowing: left-align and enable scrolling + return `${baseClasses} justify-start overflow-x-auto scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden`; + } else { + // When not overflowing: center the tabs + return `${baseClasses} justify-center`; + } + }; + + const tabsProps = { + ...dragProps, + onScroll: onScrollChange ? handleScroll : undefined, + className: getClassName() + }; + + return { + hasOverflow, + isDragging, + tabsProps + }; +} \ No newline at end of file diff --git a/src/hooks/useVirtualInfiniteSearch.ts b/src/hooks/useVirtualInfiniteSearch.ts new file mode 100644 index 00000000..1d960b52 --- /dev/null +++ b/src/hooks/useVirtualInfiniteSearch.ts @@ -0,0 +1,93 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { CoreAPI } from '@/lib/coreApi'; +import { SearchResultGame, SearchParams } from '@/lib/models'; + +interface UseVirtualInfiniteSearchOptions { + query: string; + systems: string[]; + tags?: string[]; + maxResults?: number; + enabled?: boolean; +} + +export function useVirtualInfiniteSearch({ + query, + systems, + tags = [], + maxResults = 100, + enabled = true +}: UseVirtualInfiniteSearchOptions) { + const searchQuery = useInfiniteQuery({ + queryKey: ['infiniteMediaSearch', query, systems, tags, maxResults], + queryFn: async ({ pageParam }) => { + const searchParams: SearchParams = { + query, + systems, + tags, + maxResults, + cursor: pageParam as string | undefined + }; + + return CoreAPI.mediaSearch(searchParams); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => { + // If pagination exists, use it. Otherwise fall back to legacy behavior (no pagination) + if (lastPage?.pagination) { + return lastPage.pagination.hasNextPage ? lastPage.pagination.nextCursor : undefined; + } + // Legacy fallback: no more pages if pagination field doesn't exist + return undefined; + }, + enabled: enabled, + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) + }); + + // Flatten all pages into a single array for virtual scrolling + const allItems = useMemo(() => { + if (!searchQuery.data?.pages) return []; + + const items: SearchResultGame[] = []; + searchQuery.data.pages.forEach(page => { + items.push(...page.results); + }); + + return items; + }, [searchQuery.data?.pages]); + + // Calculate total count from all pages + const totalCount = useMemo(() => { + if (!searchQuery.data?.pages) return 0; + return allItems.length; + }, [allItems.length]); + + // Get loading states + const isLoading = searchQuery.isLoading; + const isFetchingNextPage = searchQuery.isFetchingNextPage; + const hasNextPage = searchQuery.hasNextPage; + const isError = searchQuery.isError; + const error = searchQuery.error; + + return { + // Data + allItems, + totalCount, + + // Loading states + isLoading, + isFetchingNextPage, + hasNextPage, + isError, + error, + + // Actions + fetchNextPage: searchQuery.fetchNextPage, + refetch: searchQuery.refetch, + + // Raw query for debugging + query: searchQuery + }; +} \ No newline at end of file diff --git a/src/lib/models.ts b/src/lib/models.ts index 8266b4a0..9cf92e0b 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -60,6 +60,7 @@ export interface SearchParams { systems: string[]; maxResults?: number; tags?: string[]; + cursor?: string; } export interface TagInfo { @@ -74,9 +75,16 @@ export interface SearchResultGame { tags: TagInfo[]; } +export interface Pagination { + nextCursor: string | null; + hasNextPage: boolean; + pageSize: number; +} + export interface SearchResultsResponse { results: SearchResultGame[]; total: number; + pagination?: Pagination; } export interface System { diff --git a/src/routes/create.search.tsx b/src/routes/create.search.tsx index bf9cae11..6c62bcf4 100644 --- a/src/routes/create.search.tsx +++ b/src/routes/create.search.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Preferences } from "@capacitor/preferences"; import classNames from "classnames"; -import { SearchResults } from "@/components/SearchResults.tsx"; +import { VirtualSearchResults } from "@/components/VirtualSearchResults.tsx"; import { CopyButton } from "@/components/CopyButton.tsx"; import { BackToTop } from "@/components/BackToTop.tsx"; import { CoreAPI } from "../lib/coreApi.ts"; @@ -11,7 +11,6 @@ import { CreateIcon, PlayIcon, SearchIcon } from "../lib/images"; import { useNfcWriter, WriteAction } from "../lib/writeNfcHook"; import { SearchResultGame, - SearchResultsResponse, SystemsResponse } from "../lib/models"; import { SlideModal } from "../components/SlideModal"; @@ -75,7 +74,7 @@ function Search() { const [systemSelectorOpen, setSystemSelectorOpen] = useState(false); const [tagSelectorOpen, setTagSelectorOpen] = useState(false); - // State for tracking actual searched parameters + // State for tracking actual searched parameters and results const [searchParams, setSearchParams] = useState<{ query: string; system: string; @@ -97,29 +96,8 @@ function Search() { system: querySystem, tags: queryTags }); - - try { - const result = await CoreAPI.mediaSearch({ - query: query, - systems: querySystem === "all" ? [] : [querySystem], - tags: queryTags - }); - setSearchResults(result); - setSearchError(null); - } catch (error) { - console.error("Search error:", error); - setSearchError(error instanceof Error ? error : new Error(String(error))); - setSearchResults(null); - } finally { - setIsSearching(false); - } }; - // State for search results - const [searchResults, setSearchResults] = - useState(null); - const [searchError, setSearchError] = useState(null); - const [selectedResult, setSelectedResult] = useState( null ); @@ -161,8 +139,6 @@ function Search() { const selectedSystem = systems.length === 1 ? systems[0] : "all"; setQuerySystem(selectedSystem); setSelectedResult(null); - // Clear search params to indicate filters have changed - setSearchParams(null); await Preferences.set({ key: "searchSystem", value: selectedSystem }); }; @@ -170,8 +146,6 @@ function Search() { const handleTagSelect = async (tags: string[]) => { setQueryTags(tags); setSelectedResult(null); - // Clear search params to indicate filters have changed - setSearchParams(null); await Preferences.set({ key: "searchTags", value: JSON.stringify(tags) }); }; @@ -180,9 +154,6 @@ function Search() { setQuerySystem("all"); setQueryTags([]); setSelectedResult(null); - setSearchParams(null); - setSearchResults(null); - setSearchError(null); await Promise.all([ Preferences.set({ key: "searchSystem", value: "all" }), Preferences.set({ key: "searchTags", value: JSON.stringify([]) }) @@ -261,20 +232,22 @@ function Search() { disabled={!canSearch || !hasSearchParameters || isSearching} className="w-full" /> - -
+ + setIsSearching(false)} + scrollContainerRef={scrollContainerRef} + /> Date: Mon, 29 Sep 2025 17:58:06 +0800 Subject: [PATCH 06/42] fix virt list handling and managing media db status --- .../CoreApiWebSocket.gracePeriod.test.tsx | 25 +++++-- .../components/MediaDatabaseCard.test.tsx | 23 +++--- .../unit/components/MediaSearchModal.test.tsx | 50 +++++++++---- .../unit/components/SlideModal.test.tsx | 6 +- src/__tests__/unit/lib/store.test.ts | 1 + .../unit/routes/create.search.loader.test.ts | 53 +++++++++++--- src/components/CoreApiWebSocket.tsx | 23 +++++- src/components/MediaDatabaseCard.tsx | 65 ++++++++++------- src/components/SystemSelector.tsx | 73 +++++++++++++++---- src/components/TagSelector.tsx | 34 ++++++--- src/components/VirtualSearchResults.tsx | 5 +- src/components/ui/tabs.tsx | 2 +- src/hooks/useSmartTabs.ts | 16 ++++ src/hooks/useVirtualInfiniteSearch.ts | 2 +- src/lib/store.ts | 2 + src/routes/create.search.tsx | 11 +++ src/test-utils/index.tsx | 5 +- 17 files changed, 294 insertions(+), 102 deletions(-) diff --git a/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx b/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx index 3bc61581..d11d11d8 100644 --- a/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx +++ b/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useStatusStore, ConnectionState } from "../../../lib/store"; import { CoreApiWebSocket } from "../../../components/CoreApiWebSocket"; @@ -121,6 +122,18 @@ const createMockStoreState = (overrides = {}) => ({ ...overrides }); +// Helper to render CoreApiWebSocket with QueryClient +const renderWithQueryClient = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } } + }); + return render( + + + + ); +}; + describe("CoreApiWebSocket Grace Period", () => { beforeEach(() => { vi.useFakeTimers(); @@ -150,7 +163,7 @@ describe("CoreApiWebSocket Grace Period", () => { selector(mockState) ); - render(); + renderWithQueryClient(); // Simulate WebSocket close event via WebSocketManager callback if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onClose) { @@ -176,7 +189,7 @@ describe("CoreApiWebSocket Grace Period", () => { selector(mockState) ); - render(); + renderWithQueryClient(); // Simulate WebSocket open event via WebSocketManager callback if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onOpen) { @@ -201,7 +214,7 @@ describe("CoreApiWebSocket Grace Period", () => { selector(mockState) ); - render(); + renderWithQueryClient(); // Simulate WebSocket error event via WebSocketManager callback const errorEvent = new Event("error"); @@ -228,7 +241,7 @@ describe("CoreApiWebSocket Grace Period", () => { selector(mockState) ); - const { unmount } = render(); + const { unmount } = renderWithQueryClient(); unmount(); @@ -247,7 +260,7 @@ describe("CoreApiWebSocket Grace Period", () => { selector(mockState) ); - render(); + renderWithQueryClient(); expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.CONNECTING); }); @@ -268,7 +281,7 @@ describe("CoreApiWebSocket Grace Period", () => { selector(mockState) ); - render(); + renderWithQueryClient(); expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.ERROR); expect(mockSetConnectionError).toHaveBeenCalledWith("No device address configured"); diff --git a/src/__tests__/unit/components/MediaDatabaseCard.test.tsx b/src/__tests__/unit/components/MediaDatabaseCard.test.tsx index 842c19f7..5e3b1d71 100644 --- a/src/__tests__/unit/components/MediaDatabaseCard.test.tsx +++ b/src/__tests__/unit/components/MediaDatabaseCard.test.tsx @@ -73,9 +73,12 @@ describe('MediaDatabaseCard', () => { it('should render update button when not indexing', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - expect(button).toBeInTheDocument(); - expect(button).not.toBeDisabled(); + // Get all buttons with the updateDb text and find the one that's the main update button (not the system selector) + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + + expect(updateButton).toBeInTheDocument(); + expect(updateButton).not.toBeDisabled(); }); it('should disable button when not connected', () => { @@ -83,8 +86,9 @@ describe('MediaDatabaseCard', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - expect(button).toBeDisabled(); + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + expect(updateButton).toBeDisabled(); }); it('should disable button when indexing', () => { @@ -93,7 +97,7 @@ describe('MediaDatabaseCard', () => { render(); const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); - const updateButton = buttons[0]; // The main update button is first + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); expect(updateButton).toBeDisabled(); }); @@ -102,8 +106,9 @@ describe('MediaDatabaseCard', () => { render(); - const button = screen.getByRole('button', { name: /settings\.updateDb/i }); - fireEvent.click(button); + const buttons = screen.getAllByRole('button', { name: /settings\.updateDb/i }); + const updateButton = buttons.find(button => !button.textContent?.includes('settings.updateDb.allSystems')); + fireEvent.click(updateButton!); expect(CoreAPI.mediaGenerate).toHaveBeenCalledOnce(); }); @@ -135,7 +140,7 @@ describe('MediaDatabaseCard', () => { render(); // Wait for the query to resolve - expect(await screen.findByText('create.search.gamesDbUpdate')).toBeInTheDocument(); + expect(await screen.findByText('No database found')).toBeInTheDocument(); }); it('should show checking status when loading', async () => { diff --git a/src/__tests__/unit/components/MediaSearchModal.test.tsx b/src/__tests__/unit/components/MediaSearchModal.test.tsx index 8d1570d3..d3506e3c 100644 --- a/src/__tests__/unit/components/MediaSearchModal.test.tsx +++ b/src/__tests__/unit/components/MediaSearchModal.test.tsx @@ -77,20 +77,29 @@ vi.mock("@/components/wui/TextInput", () => ({ ), })); -vi.mock("@/components/SearchResults", () => ({ - SearchResults: ({ resp, setSelectedResult }: any) => ( -
- {resp?.results?.map((result: any, index: number) => ( +vi.mock("@/components/VirtualSearchResults", () => ({ + VirtualSearchResults: ({ query: _query, systems: _systems, selectedResult: _selectedResult, setSelectedResult, hasSearched }: any) => { + if (!hasSearched) { + return ( +
+

create.search.startSearching

+

create.search.startSearchingHint

+
+ ); + } + + return ( +
- ))} -
- ), +
+ ); + }, })); vi.mock("@/components/BackToTop", () => ({ @@ -144,10 +153,21 @@ describe("MediaSearchModal", () => { expect(searchInput).toHaveAttribute("placeholder", "create.search.gameInputPlaceholder"); }); - it("should render search results component", () => { + it("should render search results component", async () => { renderComponent(); - expect(screen.getByTestId("search-results")).toBeInTheDocument(); + // Enter search query + const searchInput = screen.getByTestId("search-input"); + fireEvent.change(searchInput, { target: { value: "mario" } }); + + // Click search button to trigger search + const searchButton = screen.getByRole("button", { name: /create.search.searchButton/i }); + fireEvent.click(searchButton); + + // Wait for search results to appear + await waitFor(() => { + expect(screen.getByTestId("search-results")).toBeInTheDocument(); + }); }); it("should render back to top component", () => { @@ -174,6 +194,10 @@ describe("MediaSearchModal", () => { const searchInput = screen.getByTestId("search-input"); fireEvent.change(searchInput, { target: { value: "mario" } }); + // Click search button to trigger search + const searchButton = screen.getByRole("button", { name: /create.search.searchButton/i }); + fireEvent.click(searchButton); + // Wait for search results to load and contain results await waitFor(() => { expect(screen.getByTestId("result-0")).toBeInTheDocument(); diff --git a/src/__tests__/unit/components/SlideModal.test.tsx b/src/__tests__/unit/components/SlideModal.test.tsx index 9993833f..fd76af10 100644 --- a/src/__tests__/unit/components/SlideModal.test.tsx +++ b/src/__tests__/unit/components/SlideModal.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from "vitest"; +import React from "react"; import { render, screen, fireEvent } from "../../../test-utils"; import { SlideModal } from "../../../components/SlideModal"; @@ -13,7 +14,10 @@ vi.mock("../../../hooks/useSlideModalManager", () => ({ registerModal: vi.fn(), unregisterModal: vi.fn(), closeAllExcept: vi.fn() - })) + })), + SlideModalContext: { + Provider: ({ children }: { children: React.ReactNode }) => children + } })); // Mock store diff --git a/src/__tests__/unit/lib/store.test.ts b/src/__tests__/unit/lib/store.test.ts index 8715e19f..83a22b3c 100644 --- a/src/__tests__/unit/lib/store.test.ts +++ b/src/__tests__/unit/lib/store.test.ts @@ -169,6 +169,7 @@ describe("StatusStore", () => { expect(resetState.gamesIndex).toEqual({ exists: true, indexing: false, + optimizing: false, totalSteps: 0, currentStep: 0, currentStepDisplay: "", diff --git a/src/__tests__/unit/routes/create.search.loader.test.ts b/src/__tests__/unit/routes/create.search.loader.test.ts index 03e477b1..ea0ccd19 100644 --- a/src/__tests__/unit/routes/create.search.loader.test.ts +++ b/src/__tests__/unit/routes/create.search.loader.test.ts @@ -41,30 +41,37 @@ describe("Create Search Route Loader", () => { ] }; - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags (empty array) mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "snes", + tagQuery: [], systems: mockSystemsResponse }); expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "searchSystem" }); + expect(mockPreferencesGet).toHaveBeenCalledWith({ key: "searchTags" }); expect(mockCoreApiSystems).toHaveBeenCalledWith(); }); it("should use default 'all' when no search system preference exists", async () => { const mockSystemsResponse = { systems: [] }; - mockPreferencesGet.mockResolvedValue({ value: null }); + mockPreferencesGet + .mockResolvedValueOnce({ value: null }) // searchSystem + .mockResolvedValueOnce({ value: null }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "all", + tagQuery: [], systems: mockSystemsResponse }); }); @@ -72,13 +79,16 @@ describe("Create Search Route Loader", () => { it("should use default 'all' when search system preference is undefined", async () => { const mockSystemsResponse = { systems: [] }; - mockPreferencesGet.mockResolvedValue({ value: undefined }); + mockPreferencesGet + .mockResolvedValueOnce({ value: undefined }) // searchSystem + .mockResolvedValueOnce({ value: undefined }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "all", + tagQuery: [], systems: mockSystemsResponse }); }); @@ -86,13 +96,16 @@ describe("Create Search Route Loader", () => { it("should handle empty string preference", async () => { const mockSystemsResponse = { systems: [] }; - mockPreferencesGet.mockResolvedValue({ value: "" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "" }) // searchSystem + .mockResolvedValueOnce({ value: "" }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "all", // empty string should default to "all" + tagQuery: [], systems: mockSystemsResponse }); }); @@ -108,7 +121,9 @@ describe("Create Search Route Loader", () => { }); it("should handle CoreAPI systems failure", async () => { - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags mockCoreApiSystems.mockRejectedValue(new Error("Core API unavailable")); await expect(Route.options?.loader?.({} as any)).rejects.toThrow("Core API unavailable"); @@ -123,13 +138,16 @@ describe("Create Search Route Loader", () => { }); it("should handle malformed systems response", async () => { - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags mockCoreApiSystems.mockResolvedValue(null); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "snes", + tagQuery: [], systems: null }); }); @@ -138,26 +156,34 @@ describe("Create Search Route Loader", () => { const mockSystemsResponse = { systems: [] }; // Test with a custom system ID - mockPreferencesGet.mockResolvedValue({ value: "custom-system-123" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "custom-system-123" }) // searchSystem + .mockResolvedValueOnce({ value: '["action", "rpg"]' }); // searchTags (JSON array) mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result).toEqual({ systemQuery: "custom-system-123", + tagQuery: ["action", "rpg"], systems: mockSystemsResponse }); }); it("should execute both API calls in parallel", async () => { - const preferencesPromise = new Promise(resolve => + const systemPreferencesPromise = new Promise(resolve => setTimeout(() => resolve({ value: "snes" }), 10) ); + const tagPreferencesPromise = new Promise(resolve => + setTimeout(() => resolve({ value: '[]' }), 5) + ); const systemsPromise = new Promise(resolve => setTimeout(() => resolve({ systems: [] }), 10) ); - mockPreferencesGet.mockReturnValue(preferencesPromise); + mockPreferencesGet + .mockReturnValueOnce(systemPreferencesPromise) + .mockReturnValueOnce(tagPreferencesPromise); mockCoreApiSystems.mockReturnValue(systemsPromise); const startTime = Date.now(); @@ -175,17 +201,22 @@ describe("Create Search Route Loader", () => { })); const mockSystemsResponse = { systems: largeSystems }; - mockPreferencesGet.mockResolvedValue({ value: "system-50" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "system-50" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags mockCoreApiSystems.mockResolvedValue(mockSystemsResponse); const result = await Route.options?.loader?.({} as any); expect(result.systems.systems).toHaveLength(100); expect(result.systemQuery).toBe("system-50"); + expect(result.tagQuery).toEqual([]); }); it("should handle CoreAPI timeout", async () => { - mockPreferencesGet.mockResolvedValue({ value: "snes" }); + mockPreferencesGet + .mockResolvedValueOnce({ value: "snes" }) // searchSystem + .mockResolvedValueOnce({ value: '[]' }); // searchTags // Simulate a timeout mockCoreApiSystems.mockImplementation(() => diff --git a/src/components/CoreApiWebSocket.tsx b/src/components/CoreApiWebSocket.tsx index 78d7d9c5..6a5a1e60 100644 --- a/src/components/CoreApiWebSocket.tsx +++ b/src/components/CoreApiWebSocket.tsx @@ -3,6 +3,7 @@ import { useShallow } from "zustand/react/shallow"; import { Preferences } from "@capacitor/preferences"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; import { WebSocketManager, WebSocketState } from "../lib/websocketManager.ts"; import { IndexResponse, @@ -19,6 +20,8 @@ import { import { useStatusStore, ConnectionState } from "../lib/store.ts"; export function CoreApiWebSocket() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); const wsManagerRef = useRef(null); const { @@ -45,8 +48,6 @@ export function CoreApiWebSocket() { })) ); - const { t } = useTranslation(); - // Get WebSocket URL and device address outside useEffect for early exit const deviceAddress = getDeviceAddress(); const wsUrl = getWsUrl(); @@ -97,7 +98,24 @@ export function CoreApiWebSocket() { const mediaIndexing = (params: IndexResponse) => { try { console.log("mediaIndexing", params); + + // Get current state before updating + const currentState = useStatusStore.getState().gamesIndex; + + // Update the store with new state setGamesIndex(params); + + // Check for state transitions that require media query invalidation + const transitionedFromIndexingToOptimizing = + currentState.indexing && !params.indexing && params.optimizing; + const transitionedFromOptimizingToComplete = + currentState.optimizing && !params.optimizing && !params.indexing; + + // Invalidate media query on important transitions to refresh UI status + if (transitionedFromIndexingToOptimizing || transitionedFromOptimizingToComplete) { + console.log("State transition detected, invalidating media query"); + queryClient.invalidateQueries({ queryKey: ["media"] }); + } } catch (err) { console.error("Error processing mediaIndexing notification:", err); } @@ -285,6 +303,7 @@ export function CoreApiWebSocket() { setGamesIndex, setLastToken, setPlaying, + queryClient, t ]); // Dependencies: re-create WebSocket if address or URL changes diff --git a/src/components/MediaDatabaseCard.tsx b/src/components/MediaDatabaseCard.tsx index daf5b0c7..1a271842 100644 --- a/src/components/MediaDatabaseCard.tsx +++ b/src/components/MediaDatabaseCard.tsx @@ -65,39 +65,67 @@ export function MediaDatabaseCard() { } }; + // Check various states from both store and API + const isOptimizing = gamesIndex.optimizing || mediaStatus?.database?.optimizing; + const isIndexing = gamesIndex.indexing || mediaStatus?.database?.indexing; + const renderStatus = () => { - // Check if indexing from either gamesIndex or media database - const isIndexing = gamesIndex.indexing || mediaStatus?.database?.indexing; - // Show progress when indexing - if (gamesIndex.indexing) { + // Check optimization status first - this takes priority + if (isOptimizing) { + return ( +
+
+ {t("settings.updateDb.status.optimizing")} + {/* No spinner for optimizing - only throbbing bar */} +
+
+
+
+
+ ); + } + + // Show progress when indexing (either from store or API) + if (isIndexing) { + // Prefer gamesIndex data if available (has detailed progress), otherwise use generic preparing state + const hasDetailedProgress = gamesIndex.indexing && gamesIndex.totalSteps && gamesIndex.totalSteps > 0; + return (
- {gamesIndex.currentStepDisplay + {hasDetailedProgress && gamesIndex.currentStepDisplay ? gamesIndex.currentStep === gamesIndex.totalSteps ? t("toast.writingDb") : gamesIndex.currentStepDisplay : t("toast.preparingDb")} - {isIndexing && } + {/* Only show spinner for system-specific steps (not preparing/writing) */} + {isIndexing && hasDetailedProgress && gamesIndex.currentStepDisplay && + gamesIndex.currentStep !== gamesIndex.totalSteps && ( + + )}
-
- {t("settings.updateDb.status.optimizing")} - {(isIndexing || isOptimizing) && } -
-
-
-
-
- ); - } - if (!databaseExists && !gamesIndex.indexing && !isOptimizing) { return (
@@ -205,7 +214,7 @@ export function MediaDatabaseCard() { label={t("settings.updateDb")} icon={} className="w-full" - disabled={!connected || gamesIndex.indexing} + disabled={!connected || isIndexing || isOptimizing} onClick={handleUpdateDatabase} /> diff --git a/src/components/SystemSelector.tsx b/src/components/SystemSelector.tsx index 3cff34e7..41d6ff78 100644 --- a/src/components/SystemSelector.tsx +++ b/src/components/SystemSelector.tsx @@ -145,7 +145,11 @@ export function SystemSelector({ if (gamesIndex.indexing) return; if (mode === "single") { - onSelect([systemId]); + if (systemId === "all") { + onSelect([]); + } else { + onSelect([systemId]); + } onClose(); } else { const newSelection = selectedSystems.includes(systemId) @@ -307,17 +311,55 @@ export function SystemSelector({
) : ( -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const system = filteredSystems[virtualItem.index]; - const isSelected = selectedSystems.includes(system.id); +
+ {/* Add "All Systems" option for single mode */} + {mode === "single" && selectedCategory === "all" && !debouncedSearchQuery.trim() && ( +
+ +
+ )} +
+
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const system = filteredSystems[virtualItem.index]; + const isSelected = selectedSystems.includes(system.id); return (
- ); - })} + ); + })} +
)} @@ -423,7 +466,7 @@ export function SystemSelectorTrigger({ if (!systemsData?.systems) return placeholder; if (selectedSystems.length === 0) { - return placeholder; + return mode === "single" ? t("systemSelector.allSystems") : placeholder; } if (selectedSystems.length === systemsData.systems.length) { diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index db942316..71363e41 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -39,6 +39,7 @@ export function TagSelector({ }: TagSelectorProps) { const { t } = useTranslation(); const scrollContainerRef = useRef(null); + const slideModalScrollRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); const [selectedType, setSelectedType] = useState("all"); @@ -64,10 +65,11 @@ export function TagSelector({ const gamesIndex = useStatusStore((state) => state.gamesIndex); // Fetch tags data - const { data: tagsData, isLoading } = useQuery({ + const { data: tagsData, isLoading, isError } = useQuery({ queryKey: ["tags", systems], queryFn: () => CoreAPI.mediaTags(systems.length > 0 ? systems : undefined), - enabled: isOpen // Only fetch when modal is open + enabled: isOpen, // Only fetch when modal is open + retry: false // Don't retry on error for backwards compatibility }); // Process and filter tags @@ -207,7 +209,7 @@ export function TagSelector({ close={onClose} title={title || t("tagSelector.title")} footer={footer} - scrollRef={scrollContainerRef} + scrollRef={slideModalScrollRef} fixedHeight="90vh" >
@@ -256,7 +258,7 @@ export function TagSelector({ {...tabsProps} > - {t("tagSelector.allTypes")} + {t("tagSelector.all", { defaultValue: "All" })} {types.map((type) => ( @@ -284,6 +286,12 @@ export function TagSelector({
{t("loading")}
+ ) : isError ? ( +
+ + {t("tagSelector.unavailable", { defaultValue: "Tags unavailable" })} + +
) : filteredTags.length === 0 ? (
@@ -293,7 +301,7 @@ export function TagSelector({
) : ( -
+
@@ -379,12 +387,14 @@ export function TagSelectorTrigger({ selectedTags, placeholder = "Select tags", className, - onClick + onClick, + disabled = false }: { selectedTags: string[]; placeholder?: string; className?: string; onClick: () => void; + disabled?: boolean; }) { const { t } = useTranslation(); @@ -406,8 +416,8 @@ export function TagSelectorTrigger({ }, [selectedTags, placeholder, t]); const handleClick = () => { - // Don't open selector while indexing - if (gamesIndex.indexing) return; + // Don't open selector while indexing or if disabled + if (gamesIndex.indexing || disabled) return; onClick(); }; @@ -417,13 +427,13 @@ export function TagSelectorTrigger({ className={classNames( "border-input text-foreground flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm transition-colors focus:ring-2 focus:ring-white/20 focus:outline-none", { - "hover:bg-white/10": !gamesIndex.indexing, - "opacity-50 cursor-not-allowed": gamesIndex.indexing + "hover:bg-white/10": !gamesIndex.indexing && !disabled, + "opacity-50 cursor-not-allowed": gamesIndex.indexing || disabled }, className )} style={{ backgroundColor: "var(--color-background)" }} - disabled={gamesIndex.indexing} + disabled={gamesIndex.indexing || disabled} type="button" > { - const [lastItem] = [...virtualizer.getVirtualItems()].reverse(); + const virtualItems = virtualizer.getVirtualItems(); + const [lastItem] = [...virtualItems].reverse(); if (!lastItem) return; @@ -104,7 +105,7 @@ export function VirtualSearchResults({ fetchNextPage, totalCount, isFetchingNextPage, - virtualizer.getVirtualItems() + virtualizer ]); // Screen reader announcement for search results diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index cb24dbdc..532c8ed8 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -11,7 +11,7 @@ const TabsList = React.forwardRef< { ref: React.RefObject; onMouseDown: (e: React.MouseEvent) => void; onScroll?: (e: React.UIEvent) => void; + onWheel?: (e: React.WheelEvent) => void; style: React.CSSProperties; className: string; }; @@ -22,6 +23,7 @@ interface SmartTabsReturn { * - Detects overflow and enables drag-to-scroll when needed * - Centers tabs when they fit within the container * - Provides scroll change callbacks for gradient indicators + * - Supports horizontal scrolling with mouse wheel and shift+scroll */ export function useSmartTabs({ onScrollChange @@ -50,6 +52,19 @@ export function useSmartTabs({ onScrollChange(element.scrollLeft, hasOverflow); }, [onScrollChange, hasOverflow]); + // Handle wheel events for horizontal scrolling + const handleWheel = useCallback((e: React.WheelEvent) => { + if (!hasOverflow) return; + + // Only handle horizontal scrolling or shift+vertical scroll + if (e.deltaX !== 0 || e.shiftKey) { + e.preventDefault(); + const element = e.currentTarget; + const scrollAmount = e.deltaX !== 0 ? e.deltaX : e.deltaY; + element.scrollLeft += scrollAmount; + } + }, [hasOverflow]); + // Set up resize observer to detect overflow changes useEffect(() => { const element = dragProps.ref.current; @@ -98,6 +113,7 @@ export function useSmartTabs({ const tabsProps = { ...dragProps, onScroll: onScrollChange ? handleScroll : undefined, + onWheel: handleWheel, className: getClassName() }; diff --git a/src/hooks/useVirtualInfiniteSearch.ts b/src/hooks/useVirtualInfiniteSearch.ts index 1d960b52..5cb950e9 100644 --- a/src/hooks/useVirtualInfiniteSearch.ts +++ b/src/hooks/useVirtualInfiniteSearch.ts @@ -62,7 +62,7 @@ export function useVirtualInfiniteSearch({ const totalCount = useMemo(() => { if (!searchQuery.data?.pages) return 0; return allItems.length; - }, [allItems.length]); + }, [allItems.length, searchQuery.data?.pages]); // Get loading states const isLoading = searchQuery.isLoading; diff --git a/src/lib/store.ts b/src/lib/store.ts index 9a131b51..5fbee780 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -106,6 +106,7 @@ export const useStatusStore = create()((set) => ({ gamesIndex: { exists: true, indexing: false, + optimizing: false, totalSteps: 0, currentStep: 0, currentStepDisplay: "", @@ -268,6 +269,7 @@ export const useStatusStore = create()((set) => ({ gamesIndex: { exists: true, indexing: false, + optimizing: false, totalSteps: 0, currentStep: 0, currentStepDisplay: "", diff --git a/src/routes/create.search.tsx b/src/routes/create.search.tsx index 6c62bcf4..36ce61cf 100644 --- a/src/routes/create.search.tsx +++ b/src/routes/create.search.tsx @@ -1,6 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; import { Preferences } from "@capacitor/preferences"; import classNames from "classnames"; import { VirtualSearchResults } from "@/components/VirtualSearchResults.tsx"; @@ -128,6 +129,15 @@ function Search() { }); }, [setGamesIndex]); + // Check if tags API is available for backwards compatibility + const { isError: tagsApiError } = useQuery({ + queryKey: ["tagsAvailable"], + queryFn: () => CoreAPI.mediaTags([]), + retry: false, + staleTime: 60000, // Cache for 1 minute + enabled: connected // Only check when connected + }); + const navigate = useNavigate(); const swipeHandlers = useSmartSwipe({ onSwipeRight: () => navigate({ to: "/create" }), @@ -217,6 +227,7 @@ function Search() { selectedTags={queryTags} placeholder={t("create.search.allTags")} onClick={() => setTagSelectorOpen(true)} + disabled={tagsApiError} className={classNames({ "opacity-50": !connected || !gamesIndex.exists || gamesIndex.indexing diff --git a/src/test-utils/index.tsx b/src/test-utils/index.tsx index 137969c5..b8ac4eac 100644 --- a/src/test-utils/index.tsx +++ b/src/test-utils/index.tsx @@ -1,6 +1,7 @@ import React from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { SlideModalProvider } from "../components/SlideModalProvider"; // Create a test query client const createTestQueryClient = () => @@ -26,7 +27,9 @@ function customRender( function Wrapper({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } From 0d422b329c71ea7ed094d69ba6527dedeb12f1ee Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 29 Sep 2025 18:46:05 +0800 Subject: [PATCH 07/42] better tag selector --- package.json | 1 + pnpm-lock.yaml | 62 ++++ src/components/TagSelector.tsx | 461 ++++++++++++++---------- src/components/VirtualSearchResults.tsx | 2 +- src/components/ui/accordion.tsx | 54 +++ src/routes/settings.index.tsx | 8 +- src/translations/en-US.json | 4 +- 7 files changed, 401 insertions(+), 191 deletions(-) create mode 100644 src/components/ui/accordion.tsx diff --git a/package.json b/package.json index f09da176..095e7525 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@capacitor/status-bar": "^7.0.1", "@capawesome-team/capacitor-nfc": "7.2.0", "@capawesome/capacitor-android-edge-to-edge-support": "^7.2.3", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a2734b7..a7cc62d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@capawesome/capacitor-android-edge-to-edge-support': specifier: ^7.2.3 version: 7.2.3(@capacitor/core@7.4.1) + '@radix-ui/react-accordion': + specifier: ^1.2.12 + version: 1.2.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-dialog': specifier: ^1.1.14 version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1238,6 +1241,32 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -6452,6 +6481,39 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.8)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index 71363e41..7fdb563a 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -2,17 +2,21 @@ import { useState, useMemo, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { Search, Check, X } from "lucide-react"; +import { Search, Check, X, ChevronUp, ChevronDown } from "lucide-react"; import { useDebounce } from "use-debounce"; import classNames from "classnames"; import { CoreAPI } from "@/lib/coreApi"; import { useStatusStore } from "@/lib/store"; -import { useSmartTabs } from "@/hooks/useSmartTabs"; import { TagInfo } from "@/lib/models"; import { SlideModal } from "./SlideModal"; import { Button } from "./wui/Button"; import { BackToTop } from "./BackToTop"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent +} from "./ui/accordion"; interface TagSelectorProps { isOpen: boolean; @@ -27,7 +31,7 @@ interface GroupedTags { [type: string]: TagInfo[]; } -const ITEM_HEIGHT = 56; // Height of each tag item in pixels +const ITEM_HEIGHT = 64; // Height of each tag item in pixels (increased for spacing) export function TagSelector({ isOpen, @@ -42,40 +46,29 @@ export function TagSelector({ const slideModalScrollRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); - const [selectedType, setSelectedType] = useState("all"); const [debouncedSearchQuery] = useDebounce(searchQuery, 300); - const [showLeftGradient, setShowLeftGradient] = useState(false); - const [showRightGradient, setShowRightGradient] = useState(true); - - // Smart tabs hook for overflow detection and drag scrolling - const { hasOverflow, tabsProps } = useSmartTabs({ - onScrollChange: (scrollLeft, overflow) => { - if (!overflow) return; - - const container = tabsProps.ref.current; - if (!container) return; - - const { scrollWidth, clientWidth } = container; - setShowLeftGradient(scrollLeft > 0); - setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); - } - }); + const [expandedSections, setExpandedSections] = useState([]); + const [allExpanded, setAllExpanded] = useState(false); // Get indexing state to disable selector when indexing is in progress const gamesIndex = useStatusStore((state) => state.gamesIndex); // Fetch tags data - const { data: tagsData, isLoading, isError } = useQuery({ + const { + data: tagsData, + isLoading, + isError + } = useQuery({ queryKey: ["tags", systems], queryFn: () => CoreAPI.mediaTags(systems.length > 0 ? systems : undefined), enabled: isOpen, // Only fetch when modal is open retry: false // Don't retry on error for backwards compatibility }); - // Process and filter tags - const { filteredTags, types } = useMemo(() => { + // Process and group tags + const { groupedTags, types, allTags } = useMemo(() => { if (!tagsData?.tags) { - return { filteredTags: [], types: [] }; + return { groupedTags: {}, types: [], allTags: [] }; } const tags = tagsData.tags; @@ -107,30 +100,40 @@ export function TagSelector({ return a.localeCompare(b); }); - // Filter tags based on search and type - let filtered: TagInfo[] = []; + // Sort tags within each group + Object.keys(grouped).forEach((type) => { + grouped[type].sort((a, b) => a.tag.localeCompare(b.tag)); + }); - if (selectedType === "all") { - filtered = tags; - } else { - filtered = grouped[selectedType] || []; - } + // Apply search filter if needed + let filteredGrouped = grouped; + let filteredAllTags = tags; - // Apply search filter if (debouncedSearchQuery.trim()) { const query = debouncedSearchQuery.toLowerCase(); - filtered = filtered.filter( - (tag) => - tag.tag.toLowerCase().includes(query) || - tag.type.toLowerCase().includes(query) - ); + filteredGrouped = {}; + filteredAllTags = []; + + Object.keys(grouped).forEach((type) => { + const filteredTags = grouped[type].filter( + (tag) => + tag.tag.toLowerCase().includes(query) || + tag.type.toLowerCase().includes(query) + ); + + if (filteredTags.length > 0) { + filteredGrouped[type] = filteredTags; + filteredAllTags.push(...filteredTags); + } + }); } - // Sort filtered tags by tag name - filtered.sort((a, b) => a.tag.localeCompare(b.tag)); - - return { filteredTags: filtered, types }; - }, [tagsData, debouncedSearchQuery, selectedType]); + return { + groupedTags: filteredGrouped, + types: types.filter((type) => filteredGrouped[type]?.length > 0), + allTags: filteredAllTags + }; + }, [tagsData, debouncedSearchQuery]); // Handle tag selection const handleTagSelect = useCallback( @@ -158,9 +161,29 @@ export function TagSelector({ onClose(); }, [onClose]); - // Set up virtualizer + // Handle expand/collapse all + const handleExpandCollapseAll = useCallback(() => { + if (allExpanded) { + setExpandedSections([]); + setAllExpanded(false); + } else { + setExpandedSections(types); + setAllExpanded(true); + } + }, [allExpanded, types]); + + // Handle accordion expand change + const handleAccordionChange = useCallback( + (expanded: string[]) => { + setExpandedSections(expanded); + setAllExpanded(expanded.length === types.length); + }, + [types.length] + ); + + // Set up virtualizer for all tags (used when search is active) const virtualizer = useVirtualizer({ - count: filteredTags.length, + count: allTags.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => ITEM_HEIGHT, overscan: 5 @@ -180,13 +203,11 @@ export function TagSelector({ {selectedTags.length > 0 && ( + )} +
-
- - - {t("tagSelector.all", { defaultValue: "All" })} - - {types.map((type) => ( - - {t(`tagSelector.type.${type}`, { defaultValue: type })} - - ))} - + {/* Content area */} +
+ {isLoading ? ( +
+ {t("loading")}
- - {/* Right gradient - only show when more content and overflowing */} - {hasOverflow && ( + ) : isError ? ( +
+ + {t("tagSelector.unavailable", { + defaultValue: "Tags unavailable" + })} + +
+ ) : allTags.length === 0 ? ( +
+ + {debouncedSearchQuery + ? t("tagSelector.noResults") + : t("tagSelector.noTags")} + +
+ ) : debouncedSearchQuery ? ( + // Search results - show virtualized list of all matching tags +
- )} -
- - - {isLoading ? ( -
- {t("loading")} -
- ) : isError ? ( -
- - {t("tagSelector.unavailable", { defaultValue: "Tags unavailable" })} - -
- ) : filteredTags.length === 0 ? ( -
- - {debouncedSearchQuery - ? t("tagSelector.noResults") - : t("tagSelector.noTags")} - -
- ) : ( -
-
- {virtualizer.getVirtualItems().map((virtualItem) => { - const tag = filteredTags[virtualItem.index]; - const isSelected = selectedTags.includes(tag.tag); - - return ( -
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const tag = allTags[virtualItem.index]; + const isSelected = selectedTags.includes(tag.tag); + + return ( +
+ -
- ); - })} -
+
+ + {tag.tag} + + + {t(`tagSelector.type.${tag.type}`, { + defaultValue: tag.type + })} + +
+
+ +
+ ); + })}
- )} - - +
+ ) : ( + // Accordion view for organized categories +
+ + {types.map((type) => { + const tagsInType = groupedTags[type] || []; + const selectedInType = tagsInType.filter((tag) => + selectedTags.includes(tag.tag) + ).length; + + return ( + + +
+ + {t(`tagSelector.type.${type}`, { + defaultValue: type + })}{" "} + ({tagsInType.length}) + + {selectedInType > 0 && ( + + {selectedInType} + + )} +
+
+ +
+ {tagsInType.map((tag) => { + const isSelected = selectedTags.includes(tag.tag); + + return ( + + ); + })} +
+
+
+ ); + })} +
+
+ )} +
{/* Scroll to top button */} ); -} \ No newline at end of file +} diff --git a/src/components/VirtualSearchResults.tsx b/src/components/VirtualSearchResults.tsx index 0a01f705..8e5bcd93 100644 --- a/src/components/VirtualSearchResults.tsx +++ b/src/components/VirtualSearchResults.tsx @@ -165,7 +165,7 @@ export function VirtualSearchResults({ if (isError) { return ( -
+

{t("create.search.searchError")}

+ + ))} +
+ +
+
+ + )} +
+ + ); +} \ No newline at end of file diff --git a/src/components/wui/HeaderButton.tsx b/src/components/wui/HeaderButton.tsx new file mode 100644 index 00000000..79b60279 --- /dev/null +++ b/src/components/wui/HeaderButton.tsx @@ -0,0 +1,96 @@ +import classNames from "classnames"; +import { ReactElement, useState, memo, useRef } from "react"; + +interface HeaderButtonProps { + onClick?: () => void; + icon: ReactElement; + disabled?: boolean; + active?: boolean; + title?: string; + "aria-label"?: string; + className?: string; +} + +export const HeaderButton = memo(function HeaderButton(props: HeaderButtonProps) { + const [isPressed, setIsPressed] = useState(false); + const touchStartPos = useRef<{ x: number; y: number } | null>(null); + const hasMoved = useRef(false); + + return ( + + ); +}); \ No newline at end of file diff --git a/src/hooks/useRecentSearches.ts b/src/hooks/useRecentSearches.ts new file mode 100644 index 00000000..11d107e0 --- /dev/null +++ b/src/hooks/useRecentSearches.ts @@ -0,0 +1,117 @@ +import { useState, useEffect, useCallback } from "react"; +import { Preferences } from "@capacitor/preferences"; + +export interface RecentSearch { + query: string; + system: string; + tags: string[]; + timestamp: number; +} + +const RECENT_SEARCHES_KEY = "recentSearches"; +const MAX_RECENT_SEARCHES = 10; + +export function useRecentSearches() { + const [recentSearches, setRecentSearches] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // Load recent searches from preferences on mount + useEffect(() => { + const loadRecentSearches = async () => { + try { + const { value } = await Preferences.get({ key: RECENT_SEARCHES_KEY }); + if (value) { + const parsed = JSON.parse(value) as RecentSearch[]; + setRecentSearches(parsed); + } + } catch (error) { + console.warn("Failed to load recent searches:", error); + } finally { + setIsLoading(false); + } + }; + + loadRecentSearches(); + }, []); + + // Save searches to preferences + const saveToPreferences = useCallback(async (searches: RecentSearch[]) => { + try { + await Preferences.set({ + key: RECENT_SEARCHES_KEY, + value: JSON.stringify(searches) + }); + } catch (error) { + console.warn("Failed to save recent searches:", error); + } + }, []); + + // Check if two searches are identical (ignoring timestamp) + const areSearchesEqual = useCallback((a: RecentSearch, b: RecentSearch) => { + return ( + a.query === b.query && + a.system === b.system && + a.tags.length === b.tags.length && + a.tags.every(tag => b.tags.includes(tag)) + ); + }, []); + + // Add a new search to recent searches + const addRecentSearch = useCallback(async (search: Omit) => { + // Skip if search has no meaningful parameters + if (!search.query.trim() && search.system === "all" && search.tags.length === 0) { + return; + } + + const newSearch: RecentSearch = { + ...search, + timestamp: Date.now() + }; + + setRecentSearches(current => { + // Remove any existing identical search + const filtered = current.filter(existing => !areSearchesEqual(existing, newSearch)); + + // Add new search at the beginning and limit to max items + const updated = [newSearch, ...filtered].slice(0, MAX_RECENT_SEARCHES); + + // Save to preferences + saveToPreferences(updated); + + return updated; + }); + }, [areSearchesEqual, saveToPreferences]); + + // Clear all recent searches + const clearRecentSearches = useCallback(async () => { + setRecentSearches([]); + await saveToPreferences([]); + }, [saveToPreferences]); + + // Get formatted display text for a search + const getSearchDisplayText = useCallback((search: RecentSearch) => { + const parts: string[] = []; + + if (search.query.trim()) { + parts.push(`"${search.query.trim()}"`); + } + + if (search.system !== "all") { + parts.push(`System: ${search.system}`); + } + + if (search.tags.length > 0) { + parts.push(`Tags: ${search.tags.join(", ")}`); + } + + return parts.length > 0 ? parts.join(" • ") : "All Media"; + }, []); + + return { + recentSearches, + isLoading, + addRecentSearch, + clearRecentSearches, + getSearchDisplayText + }; +} \ No newline at end of file diff --git a/src/routes/create.search.tsx b/src/routes/create.search.tsx index 36ce61cf..826218df 100644 --- a/src/routes/create.search.tsx +++ b/src/routes/create.search.tsx @@ -8,7 +8,7 @@ import { VirtualSearchResults } from "@/components/VirtualSearchResults.tsx"; import { CopyButton } from "@/components/CopyButton.tsx"; import { BackToTop } from "@/components/BackToTop.tsx"; import { CoreAPI } from "../lib/coreApi.ts"; -import { CreateIcon, PlayIcon, SearchIcon } from "../lib/images"; +import { CreateIcon, PlayIcon, SearchIcon, HistoryIcon } from "../lib/images"; import { useNfcWriter, WriteAction } from "../lib/writeNfcHook"; import { SearchResultGame, @@ -16,6 +16,7 @@ import { } from "../lib/models"; import { SlideModal } from "../components/SlideModal"; import { Button } from "../components/wui/Button"; +import { HeaderButton } from "../components/wui/HeaderButton"; import { useSmartSwipe } from "../hooks/useSmartSwipe"; import { useStatusStore } from "../lib/store"; import { TextInput } from "../components/wui/TextInput"; @@ -26,6 +27,8 @@ import { SystemSelectorTrigger } from "../components/SystemSelector"; import { TagSelector, TagSelectorTrigger } from "../components/TagSelector"; +import { useRecentSearches } from "../hooks/useRecentSearches"; +import { RecentSearchesModal } from "../components/RecentSearchesModal"; export const Route = createFileRoute("/create/search")({ loader: async (): Promise => { @@ -74,6 +77,7 @@ function Search() { const [query, setQuery] = useState(""); const [systemSelectorOpen, setSystemSelectorOpen] = useState(false); const [tagSelectorOpen, setTagSelectorOpen] = useState(false); + const [recentSearchesOpen, setRecentSearchesOpen] = useState(false); // State for tracking actual searched parameters and results const [searchParams, setSearchParams] = useState<{ @@ -85,6 +89,14 @@ function Search() { const scrollContainerRef = useRef(null); + // Recent searches hook + const { + recentSearches, + addRecentSearch, + clearRecentSearches, + getSearchDisplayText + } = useRecentSearches(); + // Manual search function const performSearch = async () => { if (!connected || !gamesIndex.exists || gamesIndex.indexing) { @@ -97,6 +109,13 @@ function Search() { system: querySystem, tags: queryTags }); + + // Add to recent searches + await addRecentSearch({ + query: query, + system: querySystem, + tags: queryTags + }); }; const [selectedResult, setSelectedResult] = useState( @@ -170,6 +189,30 @@ function Search() { ]); }; + // Handle selecting a recent search to prefill the form and execute search + const handleRecentSearchSelect = async (recentSearch: typeof recentSearches[0]) => { + setQuery(recentSearch.query); + setQuerySystem(recentSearch.system); + setQueryTags(recentSearch.tags); + setSelectedResult(null); + + // Save preferences to match the selected search + await Promise.all([ + Preferences.set({ key: "searchSystem", value: recentSearch.system }), + Preferences.set({ key: "searchTags", value: JSON.stringify(recentSearch.tags) }) + ]); + + // Automatically execute the search + if (connected && gamesIndex.exists && !gamesIndex.indexing) { + setIsSearching(true); + setSearchParams({ + query: recentSearch.query, + system: recentSearch.system, + tags: recentSearch.tags + }); + } + }; + return ( <> navigate({ to: "/create" })} scrollRef={scrollContainerRef} + headerRight={ + setRecentSearchesOpen(true)} + disabled={recentSearches.length === 0} + active={recentSearchesOpen} + icon={} + title={t("create.search.recentSearches")} + aria-label={t("create.search.recentSearches")} + /> + } >
+ setRecentSearchesOpen(false)} + recentSearches={recentSearches} + onSearchSelect={handleRecentSearchSelect} + onClearHistory={clearRecentSearches} + getSearchDisplayText={getSearchDisplayText} + /> ); } diff --git a/src/routes/settings.logs.tsx b/src/routes/settings.logs.tsx index 47fd28ca..44202d30 100644 --- a/src/routes/settings.logs.tsx +++ b/src/routes/settings.logs.tsx @@ -10,6 +10,7 @@ import { useStatusStore } from "../lib/store"; import { PageFrame } from "../components/PageFrame"; import { TextInput } from "../components/wui/TextInput"; import { BackIcon } from "../lib/images"; +import { HeaderButton } from "../components/wui/HeaderButton"; interface LogEntry { level: string; @@ -175,9 +176,10 @@ function Logs() { navigate({ to: "/settings" })} className="cursor-pointer"> - - + navigate({ to: "/settings" })} + icon={} + /> } headerCenter={

{t("settings.logs.title")}

@@ -186,30 +188,24 @@ function Logs() {
{logsQuery.data && ( <> - - + /> )} - + />
} scrollRef={scrollContainerRef} diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 7803d25d..bd36fbae 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -112,7 +112,11 @@ "tryDifferentSearch": "Try removing filters or searching with different terms", "tryDifferentTerms": "Try different search terms or check your spelling", "clearFilters": "Clear filters", - "tryAgain": "Try again" + "tryAgain": "Try again", + "recentSearches": "Recent Searches", + "noRecentSearches": "No recent searches yet", + "noRecentSearchesHint": "Start searching to build your search history", + "clearHistory": "Clear History" }, "custom": { "title": "Custom ZapScript", From c6a2f7a86061d13178b017625aa73db28a587194 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Mon, 20 Oct 2025 19:36:39 +0800 Subject: [PATCH 09/42] disable gestures on desktop --- src/hooks/useSmartSwipe.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/hooks/useSmartSwipe.ts b/src/hooks/useSmartSwipe.ts index 99e0dd5e..2195ef51 100644 --- a/src/hooks/useSmartSwipe.ts +++ b/src/hooks/useSmartSwipe.ts @@ -1,4 +1,5 @@ import { useSwipeable, SwipeableHandlers } from 'react-swipeable'; +import { useMediaQuery } from '@uidotdev/usehooks'; interface SmartSwipeOptions { onSwipeLeft?: () => void; @@ -8,9 +9,17 @@ interface SmartSwipeOptions { preventScrollOnSwipe?: boolean; swipeThreshold?: number; velocityThreshold?: number; + /** + * Force enable mouse-based swipe gestures even on desktop. + * By default, mouse tracking is only enabled on mobile-sized screens (< 640px). + * Touch events always work regardless of this setting. + */ + forceEnable?: boolean; } export function useSmartSwipe(options: SmartSwipeOptions = {}): SwipeableHandlers { + // Tailwind 'sm' breakpoint is 640px - only enable mouse tracking on smaller screens + const isMobile = useMediaQuery('(max-width: 639px)'); const { onSwipeLeft, onSwipeRight, @@ -18,9 +27,13 @@ export function useSmartSwipe(options: SmartSwipeOptions = {}): SwipeableHandler onSwipeDown, preventScrollOnSwipe = false, swipeThreshold = 50, - velocityThreshold = 0.3 + velocityThreshold = 0.3, + forceEnable = false } = options; + // Enable mouse tracking only on mobile-sized screens, unless forced + const enableMouseTracking = forceEnable || isMobile; + return useSwipeable({ onSwipedLeft: onSwipeLeft ? (eventData) => { if (Math.abs(eventData.deltaX) > swipeThreshold && eventData.velocity > velocityThreshold) { @@ -45,9 +58,9 @@ export function useSmartSwipe(options: SmartSwipeOptions = {}): SwipeableHandler onSwipeDown(); } } : undefined, - + preventScrollOnSwipe, delta: 10, - trackMouse: true + trackMouse: enableMouseTracking }); } \ No newline at end of file From 34db1a2462d3a8740d0f0275056d8248621554a0 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 7 Nov 2025 09:54:22 +0800 Subject: [PATCH 10/42] remove redundant search parameter checks --- src/routes/create.search.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/routes/create.search.tsx b/src/routes/create.search.tsx index 826218df..e9b9f652 100644 --- a/src/routes/create.search.tsx +++ b/src/routes/create.search.tsx @@ -124,8 +124,6 @@ function Search() { // Check if search has valid parameters const canSearch = connected && gamesIndex.exists && !gamesIndex.indexing; - const hasSearchParameters = - query.trim() !== "" || querySystem !== "all" || queryTags.length > 0; const nfcWriter = useNfcWriter(); const [writeOpen, setWriteOpen] = useState(false); @@ -247,7 +245,7 @@ function Search() { onKeyUp={(e) => { if (e.key === "Enter" || e.keyCode === 13) { e.currentTarget.blur(); - if (canSearch && hasSearchParameters) { + if (canSearch) { performSearch(); } } @@ -293,7 +291,7 @@ function Search() { label={t("create.search.searchButton")} icon={} onClick={performSearch} - disabled={!canSearch || !hasSearchParameters || isSearching} + disabled={!canSearch || isSearching} className="w-full" />
From 4e669603cb3ab4fc53a4dc0c26353578b87e9472 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 7 Nov 2025 10:54:03 +0800 Subject: [PATCH 11/42] fix pagination bug and add regression test --- .../components/VirtualSearchResults.test.tsx | 287 ++++++++++++++++++ src/components/VirtualSearchResults.tsx | 11 +- 2 files changed, 293 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/unit/components/VirtualSearchResults.test.tsx diff --git a/src/__tests__/unit/components/VirtualSearchResults.test.tsx b/src/__tests__/unit/components/VirtualSearchResults.test.tsx new file mode 100644 index 00000000..d8ef6037 --- /dev/null +++ b/src/__tests__/unit/components/VirtualSearchResults.test.tsx @@ -0,0 +1,287 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { vi, beforeEach, describe, it, expect } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { VirtualSearchResults } from "@/components/VirtualSearchResults"; +import { CoreAPI } from "@/lib/coreApi"; +import "@/test-setup"; + +// Mock dependencies +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("@/lib/store", () => ({ + useStatusStore: vi.fn((selector) => { + const state = { + connected: true, + gamesIndex: { exists: true, indexing: false }, + }; + return selector ? selector(state) : state; + }), +})); + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ children, to }: any) => {children}, +})); + +// Create mock for virtualizer +const mockGetVirtualItems = vi.fn(); +const mockMeasureElement = vi.fn(); + +vi.mock("@tanstack/react-virtual", () => ({ + useVirtualizer: () => ({ + getVirtualItems: mockGetVirtualItems, + getTotalSize: () => 5000, + measureElement: mockMeasureElement, + }), +})); + +describe("VirtualSearchResults - Infinite Scrolling Regression Tests", () => { + let queryClient: QueryClient; + + // Helper to create mock search results + const createMockResults = (count: number, startIndex: number = 0) => { + return Array.from({ length: count }, (_, i) => ({ + name: `Game ${startIndex + i}`, + path: `/games/game${startIndex + i}.rom`, + system: { + id: "snes", + name: "Super Nintendo", + category: "Console", + manufacturer: "Nintendo", + releaseDate: "1990-11-21", + }, + tags: [], + })); + }; + + beforeEach(() => { + vi.clearAllMocks(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + mockGetVirtualItems.mockReturnValue([]); + }); + + const renderComponent = (props = {}) => { + const defaultProps = { + query: "test", + systems: [], + tags: [], + selectedResult: null, + setSelectedResult: vi.fn(), + hasSearched: true, + scrollContainerRef: { current: document.createElement("div") }, + ...props, + }; + + return render( + + + + ); + }; + + it("should trigger fetchNextPage when scrolling near the end of current results", async () => { + // Mock first page response + const firstPageResults = createMockResults(100); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-2", + hasNextPage: true, + pageSize: 100, + }, + }); + + renderComponent(); + + // Wait for initial data to load + await waitFor(() => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + // Simulate scrolling to position 96 (within 5 items of the end at index 99) + mockGetVirtualItems.mockReturnValue([ + { index: 96, key: 96, start: 9600, size: 100 }, + { index: 97, key: 97, start: 9700, size: 100 }, + { index: 98, key: 98, start: 9800, size: 100 }, + { index: 99, key: 99, start: 9900, size: 100 }, + ]); + + // Mock second page response + const secondPageResults = createMockResults(100, 100); + mediaSearchSpy.mockResolvedValueOnce({ + results: secondPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-3", + hasNextPage: true, + pageSize: 100, + }, + }); + + // Re-render to trigger the effect with new virtualItems + renderComponent(); + + // Wait for second page to be fetched + await waitFor( + () => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(2); + }, + { timeout: 3000 } + ); + + // Verify the second call included the cursor + expect(mediaSearchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + cursor: "cursor-page-2", + }) + ); + }); + + it("should NOT fetch next page when scrolled but not near the end", async () => { + const firstPageResults = createMockResults(100); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-2", + hasNextPage: true, + pageSize: 100, + }, + }); + + renderComponent(); + + await waitFor(() => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + // Simulate scrolling to position 50 (not near the end) + mockGetVirtualItems.mockReturnValue([ + { index: 50, key: 50, start: 5000, size: 100 }, + { index: 51, key: 51, start: 5100, size: 100 }, + { index: 52, key: 52, start: 5200, size: 100 }, + ]); + + renderComponent(); + + // Wait a bit to ensure no additional calls are made + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should still only be 1 call (the initial one) + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + it("should NOT fetch next page when hasNextPage is false", async () => { + const firstPageResults = createMockResults(50); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + // Mock response with NO next page + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 50, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: 100, + }, + }); + + renderComponent(); + + await waitFor(() => { + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + // Simulate scrolling to the end + mockGetVirtualItems.mockReturnValue([ + { index: 48, key: 48, start: 4800, size: 100 }, + { index: 49, key: 49, start: 4900, size: 100 }, + ]); + + renderComponent(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should still only be 1 call + expect(mediaSearchSpy).toHaveBeenCalledTimes(1); + }); + + it("should show loading indicator at the end when hasNextPage is true", async () => { + const firstPageResults = createMockResults(100); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 100, + pagination: { + nextCursor: "cursor-page-2", + hasNextPage: true, + pageSize: 100, + }, + }); + + // Mock virtual items to include the loading sentinel + mockGetVirtualItems.mockReturnValue([ + { index: 98, key: 98, start: 9800, size: 100 }, + { index: 99, key: 99, start: 9900, size: 100 }, + { index: 100, key: 100, start: 10000, size: 60 }, // Loading sentinel + ]); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId("search-results")).toBeInTheDocument(); + }); + + // The loading indicator should be present (it's at index >= totalCount) + const loadingIndicators = screen.getAllByText("create.search.loading"); + expect(loadingIndicators.length).toBeGreaterThan(0); + }); + + it("should NOT show loading indicator when hasNextPage is false", async () => { + const firstPageResults = createMockResults(50); + const mediaSearchSpy = vi.spyOn(CoreAPI, "mediaSearch"); + + mediaSearchSpy.mockResolvedValueOnce({ + results: firstPageResults, + total: 50, + pagination: { + nextCursor: null, + hasNextPage: false, + pageSize: 100, + }, + }); + + // Mock virtual items (no loading sentinel should be added) + mockGetVirtualItems.mockReturnValue([ + { index: 48, key: 48, start: 4800, size: 100 }, + { index: 49, key: 49, start: 4900, size: 100 }, + ]); + + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId("search-results")).toBeInTheDocument(); + }); + + // Should not have a loading indicator in the results + const loadingTexts = screen.queryAllByText("create.search.loading"); + // Filter out the one in the aria-live region if present + const visibleLoading = loadingTexts.filter( + (el) => !el.classList.contains("sr-only") + ); + expect(visibleLoading.length).toBe(0); + }); +}); diff --git a/src/components/VirtualSearchResults.tsx b/src/components/VirtualSearchResults.tsx index 8e5bcd93..5a9a69af 100644 --- a/src/components/VirtualSearchResults.tsx +++ b/src/components/VirtualSearchResults.tsx @@ -87,25 +87,26 @@ export function VirtualSearchResults({ }); // Fetch next page when approaching the end + const virtualItems = virtualizer.getVirtualItems(); + useEffect(() => { - const virtualItems = virtualizer.getVirtualItems(); const [lastItem] = [...virtualItems].reverse(); if (!lastItem) return; if ( - lastItem.index >= totalCount - 1 && + lastItem.index >= totalCount - 5 && hasNextPage && !isFetchingNextPage ) { fetchNextPage(); } }, [ + virtualItems, hasNextPage, fetchNextPage, totalCount, - isFetchingNextPage, - virtualizer + isFetchingNextPage ]); // Screen reader announcement for search results @@ -237,7 +238,7 @@ export function VirtualSearchResults({ }} data-testid="search-results" > - {virtualizer.getVirtualItems().map((virtualItem) => { + {virtualItems.map((virtualItem) => { const isLoading = virtualItem.index >= totalCount; const game = allItems[virtualItem.index]; From 205b9e2e00eb3b53de9e9e51f000d439d26cade4 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Fri, 7 Nov 2025 13:35:50 +0800 Subject: [PATCH 12/42] feat: enhance tag management and UI adjustments - Add comprehensive list of tag types in translations. - Update tag handling to use "type:value" format for better clarity. - Adjust tag selection UI for consistency across components. - Improve responsive styling for `BackToTop` and `SlideModal` components. --- src/components/BackToTop.tsx | 2 +- src/components/SearchResults.tsx | 2 +- src/components/SlideModal.tsx | 2 +- src/components/TagSelector.tsx | 29 ++++++++++------ src/components/VirtualSearchResults.tsx | 2 +- src/translations/en-US.json | 46 ++++++++++++++++++++++--- 6 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx index 46daca09..c6be7c1d 100644 --- a/src/components/BackToTop.tsx +++ b/src/components/BackToTop.tsx @@ -51,7 +51,7 @@ export function BackToTop({ return (
- {tag.tag} + {tag.type}:{tag.tag} ))} {game.tags.length > 4 && ( diff --git a/src/components/SlideModal.tsx b/src/components/SlideModal.tsx index 728f06e7..808638d6 100644 --- a/src/components/SlideModal.tsx +++ b/src/components/SlideModal.tsx @@ -125,7 +125,7 @@ export function SlideModal(props: { className="h-[5px] w-[80px] rounded-full bg-[#00E0FF]" >
-
+

{props.title}

{/* Desktop close button */}
+ } + headerCenter={ +

{t("settings.app.title")}

+ } + > + + + {Capacitor.isNativePlatform() && hasLocalNFC && ( +
+ +
+ )} + + ); +} diff --git a/src/routes/settings.advanced.tsx b/src/routes/settings.core.tsx similarity index 68% rename from src/routes/settings.advanced.tsx rename to src/routes/settings.core.tsx index 2b45a2ee..8068d4b2 100644 --- a/src/routes/settings.advanced.tsx +++ b/src/routes/settings.core.tsx @@ -1,10 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { Capacitor } from "@capacitor/core"; -import { useEffect, useState } from "react"; -import { Nfc } from "@capawesome-team/capacitor-nfc"; -import { Preferences } from "@capacitor/preferences"; import classNames from "classnames"; import { CoreAPI } from "../lib/coreApi.ts"; import { ToggleSwitch } from "../components/wui/ToggleSwitch"; @@ -12,51 +8,14 @@ import { useSmartSwipe } from "../hooks/useSmartSwipe"; import { useStatusStore } from "../lib/store"; import { PageFrame } from "../components/PageFrame"; import { UpdateSettingsRequest } from "../lib/models.ts"; -import { useAppSettings } from "../hooks/useAppSettings"; import { BackIcon, CheckIcon } from "../lib/images"; -interface LoaderData { - restartScan: boolean; - launchOnScan: boolean; - launcherAccess: boolean; - preferRemoteWriter: boolean; -} - -export const Route = createFileRoute("/settings/advanced")({ - loader: async (): Promise => { - const [restartResult, launchResult, accessResult, remoteWriterResult] = - await Promise.all([ - Preferences.get({ key: "restartScan" }), - Preferences.get({ key: "launchOnScan" }), - Preferences.get({ key: "launcherAccess" }), - Preferences.get({ key: "preferRemoteWriter" }), - ]); - - return { - restartScan: restartResult.value === "true", - launchOnScan: launchResult.value !== "false", - launcherAccess: accessResult.value === "true", - preferRemoteWriter: remoteWriterResult.value === "true", - }; - }, - component: Advanced +export const Route = createFileRoute("/settings/core")({ + component: CoreSettings }); -function Advanced() { - const initData = Route.useLoaderData(); +function CoreSettings() { const connected = useStatusStore((state) => state.connected); - const [hasLocalNFC, setHasLocalNFC] = useState(false); - - const { preferRemoteWriter, setPreferRemoteWriter } = useAppSettings({ initData }); - - useEffect(() => { - // Check if local NFC is available on native platforms - if (Capacitor.isNativePlatform()) { - Nfc.isAvailable() - .then((result) => setHasLocalNFC(result.nfc)) - .catch(() => setHasLocalNFC(false)); - } - }, []); const { data, refetch } = useQuery({ queryKey: ["settings"], @@ -86,13 +45,13 @@ function Advanced() { } headerCenter={ -

{t("settings.advanced.title")}

+

{t("settings.core.title")}

} >
update.mutate({ audioScanFeedback: v })} disabled={!connected} /> @@ -100,8 +59,8 @@ function Advanced() {
update.mutate({ readersAutoDetect: v })} disabled={!connected} /> @@ -109,23 +68,13 @@ function Advanced() {
update.mutate({ debugLogging: v })} disabled={!connected} />
- {Capacitor.isNativePlatform() && hasLocalNFC && ( -
- -
- )} -
{t("settings.modeLabel")}
@@ -205,7 +154,7 @@ function Advanced() { {/*
*/} {/* */} diff --git a/src/routes/settings.index.tsx b/src/routes/settings.index.tsx index 62b99233..2e3b2742 100644 --- a/src/routes/settings.index.tsx +++ b/src/routes/settings.index.tsx @@ -12,8 +12,6 @@ import { } from "@/components/ProPurchase.tsx"; import { SlideModal } from "@/components/SlideModal.tsx"; import { Button as SCNButton } from "@/components/ui/button"; -import { ScanSettings } from "@/components/home/ScanSettings.tsx"; -import { useAppSettings } from "@/hooks/useAppSettings.ts"; import i18n from "../i18n"; import { PageFrame } from "../components/PageFrame"; import { useStatusStore } from "../lib/store"; @@ -24,27 +22,15 @@ import { getDeviceAddress, setDeviceAddress, CoreAPI } from "../lib/coreApi.ts"; import { MediaDatabaseCard } from "../components/MediaDatabaseCard"; interface LoaderData { - restartScan: boolean; - launchOnScan: boolean; launcherAccess: boolean; - preferRemoteWriter: boolean; } export const Route = createFileRoute("/settings/")({ loader: async (): Promise => { - const [restartResult, launchResult, accessResult, remoteWriterResult] = - await Promise.all([ - Preferences.get({ key: "restartScan" }), - Preferences.get({ key: "launchOnScan" }), - Preferences.get({ key: "launcherAccess" }), - Preferences.get({ key: "preferRemoteWriter" }), - ]); + const accessResult = await Preferences.get({ key: "launcherAccess" }); return { - restartScan: restartResult.value === "true", - launchOnScan: launchResult.value !== "false", launcherAccess: accessResult.value === "true", - preferRemoteWriter: remoteWriterResult.value === "true", }; }, component: Settings @@ -56,7 +42,6 @@ function Settings() { const { PurchaseModal, setProPurchaseModalOpen, proAccess } = useProPurchase(initData.launcherAccess); - const connected = useStatusStore((state) => state.connected); const connectionError = useStatusStore((state) => state.connectionError); // const loggedInUser = useStatusStore((state) => state.loggedInUser); const deviceHistory = useStatusStore((state) => state.deviceHistory); @@ -86,9 +71,6 @@ function Settings() { }); }, [setDeviceHistory]); - const { restartScan, setRestartScan, launchOnScan, setLaunchOnScan } = - useAppSettings({ initData }); - const handleDeviceAddressChange = (newAddress: string) => { // Set the new device address setDeviceAddress(newAddress); @@ -173,14 +155,6 @@ function Settings() { )} - -
@@ -254,9 +228,16 @@ function Settings() {
- + +
+

{t("settings.app.title")}

+ +
+ + +
-

{t("settings.advanced.title")}

+

{t("settings.core.title")}

diff --git a/src/translations/de-DE.json b/src/translations/de-DE.json index aeaaa456..be46cedb 100644 --- a/src/translations/de-DE.json +++ b/src/translations/de-DE.json @@ -163,14 +163,15 @@ "wizzodev": "Wizzo.dev Patrons", "joinPatreon": "Werde Patreon" }, - "advanced": { + "core": { "title": "Erweiterte Einstellungen", "soundEffects": "Soundeffekte beim Scannen abspielen", "autoDetect": "Externes NFC-Lesegerät automatisch erkennen", "debug": "Debug-Modus", "nfcDriver": "NFC-Treiber-Verbindungszeichenfolge", - "insertModeBlocklist": "Insert-Modus-Core-Blockliste", - "downloadLog": "Zaparoo-Protokolldatei herunterladen", + "insertModeBlocklist": "Insert-Modus-Core-Blockliste" + }, + "app": { "restorePurchases": "Käufe wiederherstellen", "restoreSuccess": "Käufe wurden wiederhergestellt", "restoreFail": "Käufe konnten nicht wiederhergestellt werden" diff --git a/src/translations/en-US.json b/src/translations/en-US.json index b1601035..a7ae0dec 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -232,18 +232,20 @@ "wizzodev": "Wizzo.dev Patrons", "joinPatreon": "Join the Patreon" }, - "advanced": { - "title": "Advanced Settings", + "core": { + "title": "Core Settings", "soundEffects": "Play sounds effects on scan", "autoDetect": "Auto-detect external NFC reader", "debug": "Debug mode", "nfcDriver": "NFC driver connection string", - "insertModeBlocklist": "Insert mode core blocklist", - "downloadLog": "Download Zaparoo log file", + "insertModeBlocklist": "Insert mode core blocklist" + }, + "app": { + "title": "App Settings", + "preferRemoteWriter": "Prefer connected reader for writing", "restorePurchases": "Restore purchases", "restoreSuccess": "Purchases have been restored", - "restoreFail": "Failed to restore purchases", - "preferRemoteWriter": "Prefer connected reader for writing" + "restoreFail": "Failed to restore purchases" }, "logs": { "title": "Logs", diff --git a/src/translations/fr-FR.json b/src/translations/fr-FR.json index 1feef34c..c8ce1df6 100644 --- a/src/translations/fr-FR.json +++ b/src/translations/fr-FR.json @@ -98,14 +98,15 @@ "contributors": "Contributeurs Zaparoo", "wizzodev": "Wizzo.dev Patrons" }, - "advanced": { + "core": { "title": "Réglages avancées", "soundEffects": "Jouer un son lors du scan", "autoDetect": "Détection auto. du lecteur externe NFC", "debug": "Mode débug", "nfcDriver": "Code de connection pour le driver NFC", - "insertModeBlocklist": "Insert mode core blocklist", - "downloadLog": "Télécharger les logs de Zaparoo", + "insertModeBlocklist": "Insert mode core blocklist" + }, + "app": { "restorePurchases": "Restaurer les achats", "restoreSuccess": "Achats restaurés", "restoreFail": "Echec lors de la restauration des achats" diff --git a/src/translations/ja-JP.json b/src/translations/ja-JP.json index dc8e7472..9db8049c 100644 --- a/src/translations/ja-JP.json +++ b/src/translations/ja-JP.json @@ -129,14 +129,15 @@ "wizzodev": "Wizzo.dev Patrons", "joinPatreon": "Patreonに参加する" }, - "advanced": { + "core": { "title": "詳細設定", "soundEffects": "スキャン時にサウンドエフェクトを再生する", "autoDetect": "外部NFCリーダーを自動検出する", "debug": "デバッグモード", "nfcDriver": "NFCドライバー接続文字列", - "insertModeBlocklist": "挿入モードのコアブロックリスト", - "downloadLog": "Zaparooログファイルをダウンロードする", + "insertModeBlocklist": "挿入モードのコアブロックリスト" + }, + "app": { "restorePurchases": "購入を復元する", "restoreSuccess": "購入が復元されました", "restoreFail": "購入の復元に失敗しました" diff --git a/src/translations/ko-KR.json b/src/translations/ko-KR.json index 356d9063..bb8fc358 100644 --- a/src/translations/ko-KR.json +++ b/src/translations/ko-KR.json @@ -98,14 +98,15 @@ "contributors": "탭토에 기여한", "wizzodev": "wizzo.dev Patrons" }, - "advanced": { + "core": { "title": "고급 설정", "soundEffects": "스캔시 소리 효과", "autoDetect": "외부 NFC 리더 자동 감지", "debug": "개발자모드", "nfcDriver": "NFC 드라이버 연결 문자열", - "insertModeBlocklist": "인서트 모드 코어 차단 목록", - "downloadLog": "탭토 로그 파일 다운로드", + "insertModeBlocklist": "인서트 모드 코어 차단 목록" + }, + "app": { "restorePurchases": "구매 복구", "restoreSuccess": "구매를 복구했습니다.", "restoreFail": "구매에 실패했습니다." diff --git a/src/translations/nl-NL.json b/src/translations/nl-NL.json index becb02da..2b5d25cd 100644 --- a/src/translations/nl-NL.json +++ b/src/translations/nl-NL.json @@ -115,14 +115,15 @@ "contributors": "Zaparoo-bijdragers", "wizzodev": "Wizzo.dev Patrons" }, - "advanced": { + "core": { "title": "Geavanceerde instellingen", "soundEffects": "Geluidseffecten afspelen bij scannen", "autoDetect": "Automatisch externe NFC-lezer detecteren", "debug": "Debug-modus", "nfcDriver": "NFC-driver verbindingsreeks", - "insertModeBlocklist": "Insert-modus core-blocklist", - "downloadLog": "Download Zaparoo-logbestand", + "insertModeBlocklist": "Insert-modus core-blocklist" + }, + "app": { "restorePurchases": "Aankopen herstellen", "restoreSuccess": "Aankopen zijn hersteld", "restoreFail": "Aankopen herstellen mislukt" diff --git a/src/translations/zh-CN.json b/src/translations/zh-CN.json index d7a0f3e6..90e4780e 100644 --- a/src/translations/zh-CN.json +++ b/src/translations/zh-CN.json @@ -97,14 +97,15 @@ "translationsBy": "翻译于", "contributors": "Zaparoo开发者" }, - "advanced": { + "core": { "title": "高级设置", "soundEffects": "扫描音效", "autoDetect": "自动选择外部读卡器", "debug": "除错模式", "nfcDriver": "NFC设备连接模式", - "insertModeBlocklist": "插入模式核心列表", - "downloadLog": "下载Zaparoo log文件", + "insertModeBlocklist": "插入模式核心列表" + }, + "app": { "restorePurchases": "恢复购买权限", "restoreSuccess": "购买权限已被恢复", "restoreFail": "购买权限恢复失败" From e88d63d93fe12df1715fabbfa1d898580211fd31 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 11 Nov 2025 14:28:10 +0800 Subject: [PATCH 18/42] refactor: remove grace period logic and update related components/tests - Eliminate grace period functionality from `useStatusStore` and related tests. - Simplify WebSocket state management by removing `setConnectionStateWithGracePeriod` and `clearGracePeriod` methods. - Refactor `CoreApiWebSocket` to directly handle WebSocket state transitions without grace period. - Update WebSocketManager to include message queuing and jitter for reconnection attempts. - Adjust unit tests for `store`, `websocketManager`, and related components to reflect these changes. --- capacitor.config.ts | 2 +- .../CoreApiWebSocket.gracePeriod.test.tsx | 289 ------------------ .../unit/components/ProPurchase.test.tsx | 2 +- .../unit/lib/store.gracePeriod.test.ts | 182 ----------- src/__tests__/unit/lib/store.test.ts | 18 -- .../unit/lib/websocketManager.test.ts | 9 +- .../unit/routes/settings.index.test.tsx | 7 +- src/components/CoreApiWebSocket.tsx | 14 +- src/lib/store.ts | 82 ----- src/lib/websocketManager.ts | 125 ++++++-- 10 files changed, 113 insertions(+), 617 deletions(-) delete mode 100644 src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx delete mode 100644 src/__tests__/unit/lib/store.gracePeriod.test.ts diff --git a/capacitor.config.ts b/capacitor.config.ts index efa68964..b547d80d 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -30,7 +30,7 @@ const config: CapacitorConfig = { providers: ["google.com"] }, EdgeToEdge: { - backgroundColor: '#111928' + backgroundColor: "#111928" } } }; diff --git a/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx b/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx deleted file mode 100644 index d11d11d8..00000000 --- a/src/__tests__/unit/components/CoreApiWebSocket.gracePeriod.test.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useStatusStore, ConnectionState } from "../../../lib/store"; -import { CoreApiWebSocket } from "../../../components/CoreApiWebSocket"; - -// Mock the coreApi functions -const { mockGetDeviceAddress, mockGetWsUrl } = vi.hoisted(() => ({ - mockGetDeviceAddress: vi.fn(() => "192.168.1.100:7497"), - mockGetWsUrl: vi.fn(() => "ws://192.168.1.100:7497") -})); - -// Mock WebSocket -const mockWebSocket = { - onerror: vi.fn(), - onopen: vi.fn(), - onclose: vi.fn(), - onmessage: vi.fn(), - send: vi.fn(), - close: vi.fn() -}; - -vi.mock("websocket-heartbeat-js", () => ({ - default: vi.fn().mockImplementation(() => mockWebSocket) -})); - -// Mock WebSocketManager -const mockWebSocketManager = { - connect: vi.fn(), - destroy: vi.fn(), - send: vi.fn(), - callbacks: {} as import("../../../lib/websocketManager").WebSocketManagerCallbacks -}; - -vi.mock("../../../lib/websocketManager", () => ({ - WebSocketManager: vi.fn().mockImplementation((_, callbacks) => { - // Store callbacks for testing - mockWebSocketManager.callbacks = callbacks; - - // Update connect mock to trigger onStateChange - mockWebSocketManager.connect.mockImplementation(() => { - if (callbacks.onStateChange) { - callbacks.onStateChange("CONNECTING"); - } - }); - - return mockWebSocketManager; - }), - WebSocketState: { - CONNECTING: "CONNECTING", - CONNECTED: "CONNECTED", - RECONNECTING: "RECONNECTING", - ERROR: "ERROR", - DISCONNECTED: "DISCONNECTED" - } -})); - -vi.mock("../../../lib/coreApi", () => ({ - getDeviceAddress: mockGetDeviceAddress, - getWsUrl: mockGetWsUrl, - CoreAPI: { - setSend: vi.fn(), - setWsInstance: vi.fn(), - flushQueue: vi.fn(), - processReceived: vi.fn().mockResolvedValue(null), - media: vi.fn().mockResolvedValue({ database: {}, active: [] }), - tokens: vi.fn().mockResolvedValue({ last: null }) - } -})); - -// Mock Preferences -vi.mock("@capacitor/preferences", () => ({ - Preferences: { - get: vi.fn().mockResolvedValue({ value: null }) - } -})); - -// Mock react-hot-toast -vi.mock("react-hot-toast", () => ({ - default: { - error: vi.fn() - } -})); - -// Mock i18next -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key - }) -})); - -// Mock the store -vi.mock("../../../lib/store", () => ({ - useStatusStore: vi.fn(), - ConnectionState: { - IDLE: "IDLE", - CONNECTING: "CONNECTING", - CONNECTED: "CONNECTED", - RECONNECTING: "RECONNECTING", - ERROR: "ERROR", - DISCONNECTED: "DISCONNECTED" - } -})); - -// Test helper factory for creating mock store state -const createMockStoreState = (overrides = {}) => ({ - retryCount: 0, - setConnected: vi.fn(), - setConnectionState: vi.fn(), - setConnectionStateWithGracePeriod: vi.fn(), - clearGracePeriod: vi.fn(), - setConnectionError: vi.fn(), - setPlaying: vi.fn(), - setGamesIndex: vi.fn(), - setLastToken: vi.fn(), - runQueue: null, - setRunQueue: vi.fn(), - writeQueue: "", - setWriteQueue: vi.fn(), - addDeviceHistory: vi.fn(), - setDeviceHistory: vi.fn(), - ...overrides -}); - -// Helper to render CoreApiWebSocket with QueryClient -const renderWithQueryClient = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false }, mutations: { retry: false } } - }); - return render( - - - - ); -}; - -describe("CoreApiWebSocket Grace Period", () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - // Reset WebSocket mock handlers - mockWebSocket.onerror = vi.fn(); - mockWebSocket.onopen = vi.fn(); - mockWebSocket.onclose = vi.fn(); - mockWebSocket.onmessage = vi.fn(); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllTimers(); - }); - - it("should use grace period for onclose events", () => { - const mockSetConnectionStateWithGracePeriod = vi.fn(); - const mockClearGracePeriod = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionStateWithGracePeriod: mockSetConnectionStateWithGracePeriod, - clearGracePeriod: mockClearGracePeriod - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - renderWithQueryClient(); - - // Simulate WebSocket close event via WebSocketManager callback - if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onClose) { - mockWebSocketManager.callbacks.onClose(); - } - - expect(mockSetConnectionStateWithGracePeriod).toHaveBeenCalledWith(ConnectionState.RECONNECTING); - expect(mockClearGracePeriod).not.toHaveBeenCalled(); - }); - - it("should clear grace period on successful connection", () => { - const mockSetConnectionStateWithGracePeriod = vi.fn(); - const mockClearGracePeriod = vi.fn(); - const mockSetConnectionError = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionStateWithGracePeriod: mockSetConnectionStateWithGracePeriod, - clearGracePeriod: mockClearGracePeriod, - setConnectionError: mockSetConnectionError - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - renderWithQueryClient(); - - // Simulate WebSocket open event via WebSocketManager callback - if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onOpen) { - mockWebSocketManager.callbacks.onOpen(); - } - - expect(mockClearGracePeriod).toHaveBeenCalled(); - expect(mockSetConnectionStateWithGracePeriod).toHaveBeenCalledWith(ConnectionState.CONNECTED); - expect(mockSetConnectionError).toHaveBeenCalledWith(""); - }); - - it("should bypass grace period for error states", () => { - const mockSetConnectionState = vi.fn(); - const mockSetConnectionError = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionState: mockSetConnectionState, - setConnectionError: mockSetConnectionError - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - renderWithQueryClient(); - - // Simulate WebSocket error event via WebSocketManager callback - const errorEvent = new Event("error"); - if (mockWebSocketManager.callbacks && mockWebSocketManager.callbacks.onError) { - mockWebSocketManager.callbacks.onError(errorEvent); - } - - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.ERROR); - expect(mockSetConnectionError).toHaveBeenCalledWith( - expect.stringContaining("Error communicating with server") - ); - }); - - it("should clear grace period on component cleanup", () => { - const mockClearGracePeriod = vi.fn(); - const mockSetConnectionState = vi.fn(); - - const mockState = createMockStoreState({ - clearGracePeriod: mockClearGracePeriod, - setConnectionState: mockSetConnectionState - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - const { unmount } = renderWithQueryClient(); - - unmount(); - - expect(mockClearGracePeriod).toHaveBeenCalled(); - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.DISCONNECTED); - }); - - it("should use regular setConnectionState for initial connecting state", () => { - const mockSetConnectionState = vi.fn(); - - const mockState = createMockStoreState({ - setConnectionState: mockSetConnectionState - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - renderWithQueryClient(); - - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.CONNECTING); - }); - - it("should use regular setConnectionState for configuration errors", () => { - const mockSetConnectionState = vi.fn(); - const mockSetConnectionError = vi.fn(); - - // Mock no device address configured - mockGetDeviceAddress.mockReturnValueOnce(""); - - const mockState = createMockStoreState({ - setConnectionState: mockSetConnectionState, - setConnectionError: mockSetConnectionError - }); - - vi.mocked(useStatusStore).mockImplementation((selector: any) => - selector(mockState) - ); - - renderWithQueryClient(); - - expect(mockSetConnectionState).toHaveBeenCalledWith(ConnectionState.ERROR); - expect(mockSetConnectionError).toHaveBeenCalledWith("No device address configured"); - }); -}); \ No newline at end of file diff --git a/src/__tests__/unit/components/ProPurchase.test.tsx b/src/__tests__/unit/components/ProPurchase.test.tsx index 37d207ce..fbca03d9 100644 --- a/src/__tests__/unit/components/ProPurchase.test.tsx +++ b/src/__tests__/unit/components/ProPurchase.test.tsx @@ -91,7 +91,7 @@ describe('RestorePuchasesButton', () => { const button = screen.getByTestId('button'); expect(button).toBeInTheDocument(); - expect(button).toHaveTextContent('settings.advanced.restorePurchases'); + expect(button).toHaveTextContent('settings.app.restorePurchases'); }); it('should handle successful restore with active entitlement', async () => { diff --git a/src/__tests__/unit/lib/store.gracePeriod.test.ts b/src/__tests__/unit/lib/store.gracePeriod.test.ts deleted file mode 100644 index f5b9fb94..00000000 --- a/src/__tests__/unit/lib/store.gracePeriod.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { useStatusStore, ConnectionState } from "../../../lib/store"; - -describe("Store Grace Period Logic", () => { - beforeEach(() => { - vi.useFakeTimers(); - // Reset store to initial state - useStatusStore.setState({ - connectionState: ConnectionState.IDLE, - connected: false, - pendingDisconnection: false - }); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.clearAllTimers(); - }); - - describe("setConnectionStateWithGracePeriod", () => { - it("should immediately set CONNECTED state without grace period", () => { - const store = useStatusStore.getState(); - - store.setConnectionStateWithGracePeriod(ConnectionState.CONNECTED); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should immediately set ERROR state without grace period", () => { - const store = useStatusStore.getState(); - - store.setConnectionStateWithGracePeriod(ConnectionState.ERROR); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.ERROR); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should immediately set CONNECTING state without grace period", () => { - const store = useStatusStore.getState(); - - store.setConnectionStateWithGracePeriod(ConnectionState.CONNECTING); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTING); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should delay RECONNECTING state during grace period when previously connected", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Should not change connection state immediately - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(true); - }); - - it("should apply RECONNECTING state after grace period expires", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Fast-forward past grace period - vi.advanceTimersByTime(2000); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.RECONNECTING); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should cancel grace period if reconnected before expiration", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Before grace period expires, reconnect - vi.advanceTimersByTime(1000); - store.setConnectionStateWithGracePeriod(ConnectionState.CONNECTED); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - - // Even after original grace period would have expired - vi.advanceTimersByTime(2000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - }); - - it("should delay DISCONNECTED state during grace period when previously connected", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.DISCONNECTED); - - // Should not change connection state immediately - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - expect(useStatusStore.getState().connected).toBe(true); - expect(useStatusStore.getState().pendingDisconnection).toBe(true); - }); - - it("should immediately set RECONNECTING if not previously connected", () => { - // Start from non-connected state - useStatusStore.setState({ - connectionState: ConnectionState.IDLE, - connected: false - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.RECONNECTING); - expect(useStatusStore.getState().connected).toBe(false); - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - }); - - it("should use fixed 2 second grace period", () => { - // First set to connected - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - // Should still be connected after 1 second - vi.advanceTimersByTime(1000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - - // Should disconnect after 2 second grace period - vi.advanceTimersByTime(1000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.RECONNECTING); - }); - }); - - describe("clearGracePeriod", () => { - it("should cancel pending disconnection", () => { - // First set to connected, then trigger grace period - useStatusStore.setState({ - connectionState: ConnectionState.CONNECTED, - connected: true - }); - - const store = useStatusStore.getState(); - store.setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); - - expect(useStatusStore.getState().pendingDisconnection).toBe(true); - - store.clearGracePeriod(); - - expect(useStatusStore.getState().pendingDisconnection).toBe(false); - - // Timer should be cancelled - advancing time should not trigger state change - vi.advanceTimersByTime(3000); - expect(useStatusStore.getState().connectionState).toBe(ConnectionState.CONNECTED); - }); - }); - -}); \ No newline at end of file diff --git a/src/__tests__/unit/lib/store.test.ts b/src/__tests__/unit/lib/store.test.ts index 83a22b3c..18d7da37 100644 --- a/src/__tests__/unit/lib/store.test.ts +++ b/src/__tests__/unit/lib/store.test.ts @@ -153,8 +153,6 @@ describe("StatusStore", () => { expect(resetState.lastConnectionTime).toBe(null); expect(resetState.connectionError).toBe(""); expect(resetState.retryCount).toBe(0); - expect(resetState.pendingDisconnection).toBe(false); - expect(resetState.gracePeriodTimer).toBeUndefined(); expect(resetState.runQueue).toBe(null); expect(resetState.writeQueue).toBe(""); @@ -183,22 +181,6 @@ describe("StatusStore", () => { }); }); - it("should clear grace period timer if one exists", () => { - const store = useStatusStore.getState(); - - // Simulate a grace period timer being set - const mockTimer = setTimeout(() => {}, 1000); - useStatusStore.setState({ gracePeriodTimer: mockTimer }); - - // Reset connection state - store.resetConnectionState(); - - // Verify timer is cleared - const state = useStatusStore.getState(); - expect(state.gracePeriodTimer).toBeUndefined(); - expect(state.pendingDisconnection).toBe(false); - }); - it("should not affect non-connection-related state", () => { const store = useStatusStore.getState(); diff --git a/src/__tests__/unit/lib/websocketManager.test.ts b/src/__tests__/unit/lib/websocketManager.test.ts index ba036a35..97877a10 100644 --- a/src/__tests__/unit/lib/websocketManager.test.ts +++ b/src/__tests__/unit/lib/websocketManager.test.ts @@ -279,8 +279,13 @@ describe('WebSocketManager', () => { expect(mockWs.send).toHaveBeenCalledWith('test message'); }); - it('should throw error when not connected', () => { - expect(() => manager.send('test message')).toThrow(/Cannot send message: WebSocket is not open/); + it('should queue message when not connected (default behavior)', () => { + // Should not throw when queuing is enabled (default) + expect(() => manager.send('test message')).not.toThrow(); + }); + + it('should throw error when not connected and queuing disabled', () => { + expect(() => manager.send('test message', { queue: false })).toThrow(/Cannot send message: WebSocket is not open/); }); }); diff --git a/src/__tests__/unit/routes/settings.index.test.tsx b/src/__tests__/unit/routes/settings.index.test.tsx index 373ca83b..3bd917f1 100644 --- a/src/__tests__/unit/routes/settings.index.test.tsx +++ b/src/__tests__/unit/routes/settings.index.test.tsx @@ -184,7 +184,8 @@ describe("Settings Index Route", () => { expect(settingsSource).toMatch(/TextInput/); expect(settingsSource).toMatch(/settings\.device/); expect(settingsSource).toMatch(/settings\.designer/); - expect(settingsSource).toMatch(/settings\.advanced\.title/); + expect(settingsSource).toMatch(/settings\.app\.title/); + expect(settingsSource).toMatch(/settings\.core\.title/); expect(settingsSource).toMatch(/settings\.logs\.title/); expect(settingsSource).toMatch(/settings\.help\.title/); expect(settingsSource).toMatch(/settings\.about\.title/); @@ -201,8 +202,8 @@ describe("Settings Index Route", () => { // Should have the main flex column container expect(settingsSource).toMatch(/className="flex flex-col gap-5"/); - // ScanSettings component should still be present - expect(settingsSource).toMatch(/ { diff --git a/src/components/CoreApiWebSocket.tsx b/src/components/CoreApiWebSocket.tsx index 3ad6646b..1937d8bb 100644 --- a/src/components/CoreApiWebSocket.tsx +++ b/src/components/CoreApiWebSocket.tsx @@ -28,8 +28,6 @@ export function CoreApiWebSocket() { const { setConnectionState, - setConnectionStateWithGracePeriod, - clearGracePeriod, setConnectionError, setPlaying, setGamesIndex, @@ -39,8 +37,6 @@ export function CoreApiWebSocket() { } = useStatusStore( useShallow((state) => ({ setConnectionState: state.setConnectionState, - setConnectionStateWithGracePeriod: state.setConnectionStateWithGracePeriod, - clearGracePeriod: state.clearGracePeriod, setConnectionError: state.setConnectionError, setPlaying: state.setPlaying, setGamesIndex: state.setGamesIndex, @@ -183,8 +179,7 @@ export function CoreApiWebSocket() { }, { onOpen: () => { - clearGracePeriod(); - setConnectionStateWithGracePeriod(ConnectionState.CONNECTED); + setConnectionState(ConnectionState.CONNECTED); setConnectionError(""); // Flush any queued requests @@ -243,7 +238,7 @@ export function CoreApiWebSocket() { }); }, onClose: () => { - setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); + setConnectionState(ConnectionState.RECONNECTING); }, onError: (error) => { const errorMsg = "Error communicating with server: " + wsUrl; @@ -301,7 +296,7 @@ export function CoreApiWebSocket() { // This is handled in onOpen callback break; case WebSocketState.RECONNECTING: - setConnectionStateWithGracePeriod(ConnectionState.RECONNECTING); + setConnectionState(ConnectionState.RECONNECTING); break; case WebSocketState.ERROR: setConnectionState(ConnectionState.ERROR); @@ -327,17 +322,14 @@ export function CoreApiWebSocket() { console.log("CoreApiWebSocket cleanup: destroying WebSocket manager"); wsManager.destroy(); wsManagerRef.current = null; - clearGracePeriod(); setConnectionState(ConnectionState.DISCONNECTED); }; }, [ deviceAddress, wsUrl, addDeviceHistory, - clearGracePeriod, setConnectionError, setConnectionState, - setConnectionStateWithGracePeriod, setDeviceHistory, setGamesIndex, setLastToken, diff --git a/src/lib/store.ts b/src/lib/store.ts index 5fbee780..c06af9ce 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -73,11 +73,6 @@ interface StatusState { writeQueue: string; setWriteQueue: (writeQueue: string) => void; - // Grace period for connection state changes - pendingDisconnection: boolean; - gracePeriodTimer?: ReturnType; - setConnectionStateWithGracePeriod: (state: ConnectionState) => void; - clearGracePeriod: () => void; resetConnectionState: () => void; } @@ -177,82 +172,7 @@ export const useStatusStore = create()((set) => ({ writeQueue: "", setWriteQueue: (writeQueue) => set({ writeQueue }), - // Grace period state and methods - pendingDisconnection: false, - - setConnectionStateWithGracePeriod: (state) => { - const currentState = useStatusStore.getState(); - const GRACE_PERIOD_MS = 2000; // 2 second grace period - - // Immediate state changes that bypass grace period - if (state === ConnectionState.CONNECTED || - state === ConnectionState.ERROR || - state === ConnectionState.CONNECTING) { - // Clear any pending disconnection - if (currentState.gracePeriodTimer) { - clearTimeout(currentState.gracePeriodTimer); - } - set({ - connectionState: state, - connected: state === ConnectionState.CONNECTED, - pendingDisconnection: false, - gracePeriodTimer: undefined - }); - return; - } - - // Only apply grace period for disconnection states when currently connected - if ((state === ConnectionState.RECONNECTING || state === ConnectionState.DISCONNECTED) && - currentState.connectionState === ConnectionState.CONNECTED) { - - // Clear any existing timer - if (currentState.gracePeriodTimer) { - clearTimeout(currentState.gracePeriodTimer); - } - - // Set pending disconnection but don't change UI state yet - const timer = setTimeout(() => { - set({ - connectionState: state, - connected: false, - pendingDisconnection: false, - gracePeriodTimer: undefined - }); - }, GRACE_PERIOD_MS); - - set({ - pendingDisconnection: true, - gracePeriodTimer: timer - }); - } else { - // Immediate change for other cases (e.g., not previously connected) - set({ - connectionState: state, - connected: false, // These states are never connected - pendingDisconnection: false - }); - } - }, - - clearGracePeriod: () => { - const currentState = useStatusStore.getState(); - if (currentState.gracePeriodTimer) { - clearTimeout(currentState.gracePeriodTimer); - } - set({ - pendingDisconnection: false, - gracePeriodTimer: undefined - }); - }, - resetConnectionState: () => { - const currentState = useStatusStore.getState(); - - // Clear any existing grace period timer - if (currentState.gracePeriodTimer) { - clearTimeout(currentState.gracePeriodTimer); - } - // Reset all connection-related state set({ connected: false, @@ -260,8 +180,6 @@ export const useStatusStore = create()((set) => ({ lastConnectionTime: null, connectionError: "", retryCount: 0, - pendingDisconnection: false, - gracePeriodTimer: undefined, runQueue: null, writeQueue: "", // Reset media-related state that will be refetched on reconnect diff --git a/src/lib/websocketManager.ts b/src/lib/websocketManager.ts index e964bbb0..a0f08b09 100644 --- a/src/lib/websocketManager.ts +++ b/src/lib/websocketManager.ts @@ -1,10 +1,10 @@ export enum WebSocketState { - IDLE = 'idle', - CONNECTING = 'connecting', - CONNECTED = 'connected', - RECONNECTING = 'reconnecting', - DISCONNECTED = 'disconnected', - ERROR = 'error' + IDLE = "idle", + CONNECTING = "connecting", + CONNECTED = "connected", + RECONNECTING = "reconnecting", + DISCONNECTED = "disconnected", + ERROR = "error" } export interface WebSocketManagerConfig { @@ -36,8 +36,13 @@ export class WebSocketManager { private pongTimer?: ReturnType; private reconnectTimer?: ReturnType; private isDestroyed = false; + private messageQueue: string[] = []; + private readonly MAX_QUEUE_SIZE = 100; - constructor(config: WebSocketManagerConfig, callbacks: WebSocketManagerCallbacks = {}) { + constructor( + config: WebSocketManagerConfig, + callbacks: WebSocketManagerCallbacks = {} + ) { this.config = { url: config.url, pingInterval: config.pingInterval ?? 15000, @@ -46,18 +51,21 @@ export class WebSocketManager { maxReconnectAttempts: config.maxReconnectAttempts ?? Infinity, reconnectBackoffMultiplier: config.reconnectBackoffMultiplier ?? 1.5, maxReconnectInterval: config.maxReconnectInterval ?? 30000, - pingMessage: config.pingMessage ?? 'ping' + pingMessage: config.pingMessage ?? "ping" }; this.callbacks = callbacks; } connect(): void { if (this.isDestroyed) { - console.warn('Cannot connect: WebSocketManager has been destroyed'); + console.warn("Cannot connect: WebSocketManager has been destroyed"); return; } - if (this.state === WebSocketState.CONNECTING || this.state === WebSocketState.CONNECTED) { + if ( + this.state === WebSocketState.CONNECTING || + this.state === WebSocketState.CONNECTED + ) { return; } @@ -76,14 +84,54 @@ export class WebSocketManager { this.setState(WebSocketState.IDLE); } - send(data: string): void { + send(data: string, options?: { queue?: boolean }): void { + const shouldQueue = options?.queue ?? true; + if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(data); + } else if (shouldQueue) { + // Check if queue is at capacity + if (this.messageQueue.length >= this.MAX_QUEUE_SIZE) { + console.warn( + `Message queue full (${this.MAX_QUEUE_SIZE} messages). Discarding oldest message.` + ); + this.messageQueue.shift(); // Remove oldest message + } + // Queue the message for sending once reconnected + console.debug("WebSocket not open, queuing message"); + this.messageQueue.push(data); } else { - throw new Error(`Cannot send message: WebSocket is not open (state: ${this.ws?.readyState})`); + throw new Error( + `Cannot send message: WebSocket is not open (state: ${this.ws?.readyState})` + ); } } + private flushMessageQueue(): void { + if (this.messageQueue.length === 0) { + return; + } + + console.debug(`Flushing ${this.messageQueue.length} queued messages`); + const messages = [...this.messageQueue]; + this.messageQueue = []; + + messages.forEach((message) => { + try { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(message); + } else { + // If connection closed during flush, re-queue remaining messages + this.messageQueue.push(message); + } + } catch (error) { + console.error("Failed to send queued message:", error); + // Re-queue failed message + this.messageQueue.push(message); + } + }); + } + get readyState(): number | undefined { return this.ws?.readyState; } @@ -93,7 +141,10 @@ export class WebSocketManager { } get isConnected(): boolean { - return this.state === WebSocketState.CONNECTED && this.ws?.readyState === WebSocket.OPEN; + return ( + this.state === WebSocketState.CONNECTED && + this.ws?.readyState === WebSocket.OPEN + ); } private createWebSocket(): void { @@ -101,7 +152,7 @@ export class WebSocketManager { this.ws = new WebSocket(this.config.url); this.setupEventHandlers(); } catch (error) { - console.error('Failed to create WebSocket:', error); + console.error("Failed to create WebSocket:", error); this.handleConnectionError(); } } @@ -110,22 +161,23 @@ export class WebSocketManager { if (!this.ws) return; this.ws.onopen = () => { - console.debug('WebSocket connected'); + console.debug("WebSocket connected"); this.reconnectAttempts = 0; this.setState(WebSocketState.CONNECTED); this.startHeartbeat(); + this.flushMessageQueue(); this.callbacks.onOpen?.(); }; this.ws.onclose = () => { - console.debug('WebSocket closed'); + console.debug("WebSocket closed"); this.stopHeartbeat(); this.callbacks.onClose?.(); this.handleDisconnection(); }; this.ws.onerror = (error) => { - console.error('WebSocket error:', error); + console.error("WebSocket error:", error); this.callbacks.onError?.(error); this.handleConnectionError(); }; @@ -135,7 +187,7 @@ export class WebSocketManager { this.resetHeartbeat(); // Handle pong messages - if (event.data === 'pong') { + if (event.data === "pong") { this.clearPongTimeout(); return; } @@ -167,17 +219,31 @@ export class WebSocketManager { } private scheduleReconnect(): void { - if (this.isDestroyed || this.reconnectAttempts >= this.config.maxReconnectAttempts) { + if ( + this.isDestroyed || + this.reconnectAttempts >= this.config.maxReconnectAttempts + ) { this.setState(WebSocketState.DISCONNECTED); return; } - // Calculate backoff delay + // Calculate backoff delay with jitter const baseDelay = this.config.reconnectInterval; - const backoffDelay = baseDelay * Math.pow(this.config.reconnectBackoffMultiplier, this.reconnectAttempts); - const delay = Math.min(backoffDelay, this.config.maxReconnectInterval); - - console.debug(`Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms`); + const backoffDelay = + baseDelay * + Math.pow(this.config.reconnectBackoffMultiplier, this.reconnectAttempts); + const cappedDelay = Math.min( + backoffDelay, + this.config.maxReconnectInterval + ); + + // Add random jitter (0-50% of the delay) to prevent synchronized reconnection attempts + const jitter = Math.random() * cappedDelay * 0.5; + const delay = Math.floor(cappedDelay + jitter); + + console.debug( + `Scheduling reconnect attempt ${this.reconnectAttempts + 1} in ${delay}ms (base: ${cappedDelay}ms + jitter: ${Math.floor(jitter)}ms)` + ); this.reconnectTimer = setTimeout(() => { if (this.isDestroyed) return; @@ -196,7 +262,7 @@ export class WebSocketManager { this.ws.send(this.config.pingMessage); this.startPongTimeout(); } catch (error) { - console.error('Failed to send ping:', error); + console.error("Failed to send ping:", error); this.handleConnectionError(); } } @@ -223,7 +289,7 @@ export class WebSocketManager { private startPongTimeout(): void { this.clearPongTimeout(); this.pongTimer = setTimeout(() => { - console.warn('Pong timeout - closing connection'); + console.warn("Pong timeout - closing connection"); this.ws?.close(); }, this.config.pongTimeout); } @@ -250,10 +316,13 @@ export class WebSocketManager { this.ws.onerror = null; this.ws.onmessage = null; - if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + if ( + this.ws.readyState === WebSocket.OPEN || + this.ws.readyState === WebSocket.CONNECTING + ) { this.ws.close(); } this.ws = null; } } -} \ No newline at end of file +} From f5fa8ca4e075bf395ca8a5e869cf78148d7f8c04 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 11 Nov 2025 14:42:32 +0800 Subject: [PATCH 19/42] refactor: improve readability and consistency in `SlideModal` component - Standardize use of double quotes in event handlers/styling. - Simplify conditional logic for modal height and footer rendering. - Adjust styling for desktop close button and fix spacing inconsistencies. --- src/components/SlideModal.tsx | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/components/SlideModal.tsx b/src/components/SlideModal.tsx index 808638d6..7ab4451f 100644 --- a/src/components/SlideModal.tsx +++ b/src/components/SlideModal.tsx @@ -28,7 +28,7 @@ export function SlideModal(props: { // Handle Android back button useBackButtonHandler( - 'slide-modal', + "slide-modal", () => { if (props.isOpen) { props.close(); @@ -71,7 +71,7 @@ export function SlideModal(props: { pointerEvents: props.isOpen ? "auto" : "none" }} onClick={props.close} - onKeyDown={(e) => e.key === 'Escape' && props.close()} + onKeyDown={(e) => e.key === "Escape" && props.close()} role="button" tabIndex={0} aria-label="Close modal" @@ -107,8 +107,7 @@ export function SlideModal(props: { transition: "bottom 0.2s ease-in-out", ...(props.fixedHeight ? { height: props.fixedHeight } - : { maxHeight: `calc(100vh - ${safeInsets.top} - 75px)` } - ) + : { maxHeight: `calc(100vh - ${safeInsets.top} - 75px)` }) }} >
e.key === 'Enter' && props.close()} + onKeyDown={(e) => e.key === "Enter" && props.close()} role="button" tabIndex={0} aria-label="Drag to close" className="h-[5px] w-[80px] rounded-full bg-[#00E0FF]" >
-
+

{props.title}

{/* Desktop close button */}
-
+
{props.children}
- {props.footer && ( -
- {props.footer} -
- )} + {props.footer &&
{props.footer}
}
); From ab87bd4a9b5821916e48ef8f1907959077874fd1 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 11 Nov 2025 16:19:39 +0800 Subject: [PATCH 20/42] feat: add "Show more/Show less" toggle for logs and refactor filters - Introduce expandable/collapsible functionality for log messages and additional fields. - Replace button-based filters with `ToggleChip` components for improved state management and UI consistency. - Extend translations with new keys for "Show more" and "Show less". --- src/routes/settings.logs.tsx | 120 ++++++++++++++++++++++++++++------- src/translations/en-US.json | 4 +- 2 files changed, 99 insertions(+), 25 deletions(-) diff --git a/src/routes/settings.logs.tsx b/src/routes/settings.logs.tsx index 44202d30..9a616e67 100644 --- a/src/routes/settings.logs.tsx +++ b/src/routes/settings.logs.tsx @@ -11,6 +11,7 @@ import { PageFrame } from "../components/PageFrame"; import { TextInput } from "../components/wui/TextInput"; import { BackIcon } from "../lib/images"; import { HeaderButton } from "../components/wui/HeaderButton"; +import { ToggleChip } from "../components/wui/ToggleChip"; interface LogEntry { level: string; @@ -36,6 +37,8 @@ function Logs() { warn: true, error: true }); + const [expandedEntries, setExpandedEntries] = useState>(new Set()); + const [expandedFields, setExpandedFields] = useState>(new Set()); const swipeHandlers = useSmartSwipe({ onSwipeRight: () => navigate({ to: "/settings" }), @@ -164,11 +167,31 @@ function Logs() { } }; - const toggleLevelFilter = (level: keyof typeof levelFilters) => { - setLevelFilters(prev => ({ - ...prev, - [level]: !prev[level] - })); + const MESSAGE_TRUNCATE_LENGTH = 200; + + const toggleExpandEntry = (index: number) => { + setExpandedEntries(prev => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const toggleExpandField = (entryIndex: number, fieldKey: string) => { + const fieldId = `${entryIndex}_${fieldKey}`; + setExpandedFields(prev => { + const newSet = new Set(prev); + if (newSet.has(fieldId)) { + newSet.delete(fieldId); + } else { + newSet.add(fieldId); + } + return newSet; + }); }; return ( @@ -225,19 +248,26 @@ function Logs() { {/* Filters and Entry Count */}
- {Object.entries(levelFilters).map(([level, enabled]) => ( - - ))} + setLevelFilters(prev => ({ ...prev, debug: state }))} + /> + setLevelFilters(prev => ({ ...prev, info: state }))} + /> + setLevelFilters(prev => ({ ...prev, warn: state }))} + /> + setLevelFilters(prev => ({ ...prev, error: state }))} + />
{logsQuery.data && logEntries.length > 0 && (
@@ -284,16 +314,58 @@ function Logs() {
- {entry.message} + {entry.message && entry.message.length > MESSAGE_TRUNCATE_LENGTH ? ( + <> + {expandedEntries.has(entry._index) + ? entry.message + : `${entry.message.slice(0, MESSAGE_TRUNCATE_LENGTH)}...`} + + + ) : ( + entry.message + )}
{/* Additional fields */} {Object.entries(entry).filter(([key]) => !['level', 'time', 'caller', 'message', '_index'].includes(key) - ).map(([key, value]) => ( -
- {key}: {JSON.stringify(value)} -
- ))} + ).map(([key, value]) => { + const fieldId = `${entry._index}_${key}`; + const valueStr = JSON.stringify(value); + const isExpanded = expandedFields.has(fieldId); + const needsTruncation = valueStr.length > MESSAGE_TRUNCATE_LENGTH; + + return ( +
+ {key}:{" "} + {needsTruncation ? ( + <> + {isExpanded + ? valueStr + : `${valueStr.slice(0, MESSAGE_TRUNCATE_LENGTH)}...`} + + + ) : ( + valueStr + )} +
+ ); + })}
))}
diff --git a/src/translations/en-US.json b/src/translations/en-US.json index a7ae0dec..c8fe7f8c 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -261,7 +261,9 @@ "filteredEntries": "Showing", "noEntriesFound": "No log entries match your filters", "fetchError": "Failed to fetch logs", - "notConnected": "Connect to a Zaparoo device to access logs" + "notConnected": "Connect to a Zaparoo device to access logs", + "showMore": "Show more", + "showLess": "Show less" }, "help": { "title": "Help", From 65ea7febdb00406ff0baca8f8b9f503ca22d65d4 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 11 Nov 2025 16:44:18 +0800 Subject: [PATCH 21/42] feat: add cross-env for environment-specific commands and enable dev server URL configuration - Introduce `cross-env` dependency to set environment variables consistently across platforms. - Add `dev:server` and `build:server` scripts for server-specific builds. - Update `capacitor.config.ts` to allow dynamic dev server URL resolution based on `NODE_ENV` and `DEV_SERVER_IP`. - Update `pnpm-lock.yaml` to reflect new dependencies. --- capacitor.config.ts | 9 ++++----- package.json | 3 +++ pnpm-lock.yaml | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/capacitor.config.ts b/capacitor.config.ts index b547d80d..eecb55b0 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -10,11 +10,10 @@ const config: CapacitorConfig = { appName: "Zaparoo", backgroundColor: "#111928", server: { - // url: `http://${process.env.DEV_SERVER_IP}:8100`, - // url: - // process.env.NODE_ENV === "development" && process.env.DEV_SERVER_IP - // ? `http://${process.env.DEV_SERVER_IP}:8100` - // : undefined, + url: + process.env.NODE_ENV === "development" && process.env.DEV_SERVER_IP + ? `http://${process.env.DEV_SERVER_IP}:8100` + : undefined, androidScheme: "http", cleartext: true }, diff --git a/package.json b/package.json index 095e7525..1d319138 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev:server": "cross-env NODE_ENV=development vite", "build": "tsc --noEmit -p tsconfig.build.json && vite build && pnpm exec cap sync", + "build:server": "tsc --noEmit -p tsconfig.build.json && vite build && cross-env NODE_ENV=development cap sync", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 3", "eslist": "pnpm run lint", "preview": "vite preview", @@ -83,6 +85,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vitest/ui": "^3.2.4", "autoprefixer": "^10.4.21", + "cross-env": "^10.1.0", "eslint": "^9.34.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import-x": "^4.16.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7cc62d4..d0e869f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.6) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 eslint: specifier: ^9.34.0 version: 9.34.0(jiti@2.4.2) @@ -540,6 +543,9 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.6': resolution: {integrity: sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==} engines: {node: '>=18'} @@ -2618,6 +2624,11 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -5697,6 +5708,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.6': optional: true @@ -7918,6 +7931,11 @@ snapshots: create-require@1.1.1: {} + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 From b0fb05cac00ec614af047b54ba2a6560cec3199a Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 11 Nov 2025 17:05:03 +0800 Subject: [PATCH 22/42] feat: add NFC share functionality and improve i18n support - Introduce `@capacitor/share` to enable NFC tag data sharing with fallbacks to clipboard. - Enhance `ReadTab` component to include translations for all NFC-related labels and messages. - Add localization support via `react-i18next` for improved multi-language compatibility. - Update `Podfile`, Android Gradle files, and `pnpm-lock.yaml` to reflect new dependencies. --- android/app/capacitor.build.gradle | 1 + android/capacitor.settings.gradle | 3 + ios/App/Podfile | 1 + package.json | 1 + pnpm-lock.yaml | 12 +++ src/components/nfc/ReadTab.tsx | 131 +++++++++++------------------ src/components/nfc/ToolsTab.tsx | 18 ++-- src/translations/en-US.json | 35 ++++++++ 8 files changed, 113 insertions(+), 89 deletions(-) diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 58e747ab..61382c7b 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -16,6 +16,7 @@ dependencies { implementation project(':capacitor-browser') implementation project(':capacitor-clipboard') implementation project(':capacitor-preferences') + implementation project(':capacitor-share') implementation project(':capacitor-status-bar') implementation project(':capawesome-team-capacitor-nfc') implementation project(':capawesome-capacitor-android-edge-to-edge-support') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 2154feab..c5495db9 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -23,6 +23,9 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@ca include ':capacitor-preferences' project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@capacitor+preferences@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/preferences/android') +include ':capacitor-share' +project(':capacitor-share').projectDir = new File('../node_modules/.pnpm/@capacitor+share@7.0.2_@capacitor+core@7.4.1/node_modules/@capacitor/share/android') + include ':capacitor-status-bar' project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@capacitor+status-bar@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/status-bar/android') diff --git a/ios/App/Podfile b/ios/App/Podfile index bda74eb8..22dbac2e 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -18,6 +18,7 @@ def capacitor_pods pod 'CapacitorBrowser', :path => '../../node_modules/.pnpm/@capacitor+browser@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/browser' pod 'CapacitorClipboard', :path => '../../node_modules/.pnpm/@capacitor+clipboard@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/clipboard' pod 'CapacitorPreferences', :path => '../../node_modules/.pnpm/@capacitor+preferences@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/preferences' + pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@7.0.2_@capacitor+core@7.4.1/node_modules/@capacitor/share' pod 'CapacitorStatusBar', :path => '../../node_modules/.pnpm/@capacitor+status-bar@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/status-bar' pod 'CapawesomeTeamCapacitorNfc', :path => '../../node_modules/.pnpm/@capawesome-team+capacitor-nfc@7.2.0_@capacitor+core@7.4.1/node_modules/@capawesome-team/capacitor-nfc' pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/.pnpm/@revenuecat+purchases-capacitor@10.3.7_@capacitor+core@7.4.1/node_modules/@revenuecat/purchases-capacitor' diff --git a/package.json b/package.json index 1d319138..664f2c23 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@capacitor/core": "^7.4.1", "@capacitor/ios": "^7.4.1", "@capacitor/preferences": "^7.0.1", + "@capacitor/share": "^7.0.2", "@capacitor/status-bar": "^7.0.1", "@capawesome-team/capacitor-nfc": "7.2.0", "@capawesome/capacitor-android-edge-to-edge-support": "^7.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0e869f6..140e74e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@capacitor/preferences': specifier: ^7.0.1 version: 7.0.1(@capacitor/core@7.4.1) + '@capacitor/share': + specifier: ^7.0.2 + version: 7.0.2(@capacitor/core@7.4.1) '@capacitor/status-bar': specifier: ^7.0.1 version: 7.0.1(@capacitor/core@7.4.1) @@ -515,6 +518,11 @@ packages: peerDependencies: '@capacitor/core': '>=7.0.0' + '@capacitor/share@7.0.2': + resolution: {integrity: sha512-VyNPo/9831xnL17IMDeft5yNdBjoKNb451P95sRcr69hulRDqHc+kndqOVaMXnaA6IyBdWnnFv/n1HUf4cXpGw==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + '@capacitor/status-bar@7.0.1': resolution: {integrity: sha512-iDv3mXYo9CdxYRVwt3/pRyuk25p7Sn4GfaS/zMZyVIqTzsvKLCIIH3GdKK+ta+nsNcAVpCw/t5jFEBt1D18ctA==} peerDependencies: @@ -5676,6 +5684,10 @@ snapshots: dependencies: '@capacitor/core': 7.4.1 + '@capacitor/share@7.0.2(@capacitor/core@7.4.1)': + dependencies: + '@capacitor/core': 7.4.1 + '@capacitor/status-bar@7.0.1(@capacitor/core@7.4.1)': dependencies: '@capacitor/core': 7.4.1 diff --git a/src/components/nfc/ReadTab.tsx b/src/components/nfc/ReadTab.tsx index 4e62b1b0..d78fd2a7 100644 --- a/src/components/nfc/ReadTab.tsx +++ b/src/components/nfc/ReadTab.tsx @@ -4,13 +4,12 @@ import { EyeIcon, EyeOffIcon, ShareIcon, - WifiIcon, - GlobeIcon, - MessageCircleIcon, - FileTextIcon, NfcIcon } from "lucide-react"; import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { Clipboard } from "@capacitor/clipboard"; +import { Share } from "@capacitor/share"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/wui/Button"; import { Result } from "@/lib/nfc"; @@ -21,46 +20,38 @@ interface ReadTabProps { } export function ReadTab({ result, onScan }: ReadTabProps) { + const { t } = useTranslation(); const [showRawData, setShowRawData] = useState(false); const copyToClipboard = async (text: string, label: string) => { try { - await navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); + await Clipboard.write({ string: text }); + toast.success(t("create.nfc.readTab.copiedToClipboard", { label })); } catch { - toast.error("Failed to copy to clipboard"); + toast.error(t("create.nfc.readTab.copyFailed")); } }; const shareTagData = async () => { if (!result?.info?.tag) return; - const shareData = { - title: "NFC Tag Data", - text: `UID: ${result.info.tag.uid}\nText: ${result.info.tag.text}` - }; + const shareText = t("create.nfc.readTab.shareText", { + uid: result.info.tag.uid, + text: result.info.tag.text + }); - if (navigator.share) { - try { - await navigator.share(shareData); - } catch { - copyToClipboard(shareData.text, "Tag data"); - } - } else { - copyToClipboard(shareData.text, "Tag data"); + try { + await Share.share({ + title: t("create.nfc.readTab.shareTitle"), + text: shareText, + dialogTitle: t("create.nfc.readTab.shareTitle") + }); + } catch { + // Fallback to copy if share fails or is cancelled + copyToClipboard(shareText, t("create.nfc.readTab.tagData")); } }; - const getContentTypeIcon = (text: string) => { - if (text.startsWith("http")) - return ; - if (text.startsWith("wifi:")) - return ; - if (text.includes("@")) - return ; - return ; - }; - const { tag, rawTag } = result?.info || { tag: null, rawTag: null }; return ( @@ -69,28 +60,27 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
{tag?.uid || "-"} @@ -98,37 +88,20 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {tag?.uid && (
- +
{tag?.text ? ( - <> -
- {getContentTypeIcon(tag.text)} - - {tag.text.startsWith("http") && "URL"} - {tag.text.startsWith("wifi:") && "WiFi Credentials"} - {tag.text.includes("@") && - !tag.text.startsWith("http") && - "Email"} - {!tag.text.startsWith("http") && - !tag.text.startsWith("wifi:") && - !tag.text.includes("@") && - "Text"} - -
-
{tag.text}
- +
{tag.text}
) : (
-
)} @@ -136,10 +109,9 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {tag?.text && (
@@ -149,11 +121,11 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Technical Details Card */}
-

Technical Details

+

{t("create.nfc.readTab.technicalDetails")}

- +
{rawTag?.isWritable !== undefined ? ( - {rawTag.isWritable ? "Yes" : "No"} + {rawTag.isWritable ? t("create.nfc.readTab.yes") : t("create.nfc.readTab.no")} ) : ( - @@ -172,14 +144,14 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
- {rawTag?.maxSize ? `${rawTag.maxSize} bytes` : "-"} + {rawTag?.maxSize ? `${rawTag.maxSize} ${t("create.nfc.readTab.bytes")}` : "-"}
- +
{rawTag?.canMakeReadOnly !== undefined ? ( - {rawTag.canMakeReadOnly ? "Yes" : "No"} + {rawTag.canMakeReadOnly ? t("create.nfc.readTab.yes") : t("create.nfc.readTab.no")} ) : ( - @@ -198,7 +170,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
{rawTag?.manufacturerCode !== undefined ? rawTag.manufacturerCode @@ -207,7 +179,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- +
{rawTag?.techTypes ? ( rawTag.techTypes.map((tech, index) => ( @@ -226,28 +198,28 @@ export function ReadTab({ result, onScan }: ReadTabProps) {
- + {rawTag?.message?.records && rawTag.message.records.length > 0 ? ( <>
{rawTag.message.records.map((record, index) => (
- Record {index + 1} + {t("create.nfc.readTab.record", { index: index + 1 })}
- TNF: {record.tnf} + {t("create.nfc.readTab.tnf")} {record.tnf}
{record.type && record.type.length > 0 && (
- Type:{" "} + {t("create.nfc.readTab.type")}{" "} {record.type[0].toString(16)}
)} {record.payload && (
- Payload: + {t("create.nfc.readTab.payload")}
{showRawData ? record.payload @@ -269,7 +241,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { icon={ showRawData ? : } - label={showRawData ? "Hide Hex" : "Show Hex"} + label={showRawData ? t("create.nfc.readTab.hideHex") : t("create.nfc.readTab.showHex")} onClick={() => setShowRawData(!showRawData)} size="sm" variant="outline" @@ -284,12 +256,12 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Low-Level Tag Data Card */}
-

Low-Level Tag Data

+

{t("create.nfc.readTab.lowLevelTagData")}

{/* Tag ID */}
- +
{rawTag?.id @@ -310,12 +282,11 @@ export function ReadTab({ result, onScan }: ReadTabProps) { rawTag .id!.map((byte) => byte.toString(16).padStart(2, "0")) .join(""), - "Tag ID" + t("create.nfc.readTab.tagId") ) } size="sm" variant="outline" - className="!px-3" /> )}
@@ -323,7 +294,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* ATQA bytes (Android NFC-A) */}
- +
{rawTag?.atqa ? showRawData @@ -337,7 +308,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* SAK bytes (Android NFC-A) */}
- +
{rawTag?.sak ? showRawData @@ -351,7 +322,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Application Data (NFC-B) */}
- +
{rawTag?.applicationData ? showRawData @@ -365,7 +336,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Historical Bytes (ISO-DEP) */}
- +
{rawTag?.historicalBytes ? showRawData @@ -379,7 +350,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* Manufacturer bytes (NFC-F) */}
- +
{rawTag?.manufacturer ? showRawData @@ -393,7 +364,7 @@ export function ReadTab({ result, onScan }: ReadTabProps) { {/* System Code (NFC-F) */}
- +
{rawTag?.systemCode ? showRawData diff --git a/src/components/nfc/ToolsTab.tsx b/src/components/nfc/ToolsTab.tsx index 56fdd192..8e4276cd 100644 --- a/src/components/nfc/ToolsTab.tsx +++ b/src/components/nfc/ToolsTab.tsx @@ -81,15 +81,6 @@ export function ToolsTab({ onToolAction, isProcessing }: ToolsTabProps) { {tool.description}

- {tool.dangerous && ( -
- -

- {tool.warning} -

-
- )} -
); diff --git a/src/translations/en-US.json b/src/translations/en-US.json index c8fe7f8c..8bd9306c 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -150,6 +150,41 @@ "tools": "Tools", "history": "History" }, + "readTab": { + "scanTag": "Scan tag", + "tagInformation": "Tag Information", + "uid": "UID", + "content": "Content", + "technicalDetails": "Technical Details", + "writable": "Writable", + "yes": "Yes", + "no": "No", + "maxSize": "Max Size", + "bytes": "bytes", + "canMakeReadOnly": "Can Make Read-Only", + "manufacturerCode": "Manufacturer Code", + "technologyTypes": "Technology Types", + "ndefRecords": "NDEF Records ({{count}})", + "record": "Record {{index}}", + "tnf": "TNF:", + "type": "Type:", + "payload": "Payload:", + "showHex": "Show Hex", + "hideHex": "Hide Hex", + "lowLevelTagData": "Low-Level Tag Data", + "tagId": "Tag ID (UID)", + "atqa": "ATQA/SENS_RES (NFC-A)", + "sak": "SAK/SEL_RES (NFC-A)", + "applicationData": "Application Data (NFC-B)", + "historicalBytes": "Historical Bytes (ISO-DEP)", + "manufacturer": "Manufacturer (NFC-F)", + "systemCode": "System Code (NFC-F)", + "copiedToClipboard": "{{label}} copied to clipboard", + "copyFailed": "Failed to copy to clipboard", + "tagData": "Tag data", + "shareTitle": "NFC Tag Data", + "shareText": "UID: {{uid}}\nText: {{text}}" + }, "write": { "templates": { "text": "Text", From 502704699b7860af002502e8e980a96489178e6d Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Tue, 11 Nov 2025 17:13:36 +0800 Subject: [PATCH 23/42] feat: conditionally render "App Settings" link for native platforms - Wrap "App Settings" link in a native platform check using `Capacitor.isNativePlatform()` to ensure it is only displayed on supported environments. --- src/routes/settings.index.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/routes/settings.index.tsx b/src/routes/settings.index.tsx index 2e3b2742..1964a4e1 100644 --- a/src/routes/settings.index.tsx +++ b/src/routes/settings.index.tsx @@ -228,12 +228,14 @@ function Settings() {
- -
-

{t("settings.app.title")}

- -
- + {Capacitor.isNativePlatform() && ( + +
+

{t("settings.app.title")}

+ +
+ + )}
From 911bd839ea7dfe3b6eaf6401a916b2f2f2bd071a Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 12 Nov 2025 06:06:21 +0800 Subject: [PATCH 24/42] feat: implement shake detection for triggering actions - Add `useShakeDetection` hook for handling shake gestures and triggering zap scripts. - Extend `useAppSettings` to include shake-related settings (`shakeEnabled`, `shakeMode`, `shakeZapscript`). - Update `AppSettings` and `index` routes to integrate and manage shake settings. - Introduce new translations for shake detection UI and functionality. - Add `@capgo/capacitor-shake` dependency and update native project files (`Podfile`, Gradle). - Enhance unit tests to cover new shake detection features. --- android/app/capacitor.build.gradle | 1 + android/capacitor.settings.gradle | 3 + ios/App/Podfile | 1 + package.json | 1 + pnpm-lock.yaml | 12 + .../unit/hooks/useAppSettings.test.ts | 15 +- src/components/wui/ToggleSwitch.tsx | 16 +- src/hooks/useAppSettings.ts | 32 ++- src/hooks/useShakeDetection.ts | 74 ++++++ src/routes/index.tsx | 24 +- src/routes/settings.app.tsx | 233 ++++++++++++++++-- src/translations/en-US.json | 8 +- 12 files changed, 388 insertions(+), 32 deletions(-) create mode 100644 src/hooks/useShakeDetection.ts diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 61382c7b..f1174a10 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -20,6 +20,7 @@ dependencies { implementation project(':capacitor-status-bar') implementation project(':capawesome-team-capacitor-nfc') implementation project(':capawesome-capacitor-android-edge-to-edge-support') + implementation project(':capgo-capacitor-shake') implementation project(':revenuecat-purchases-capacitor') } diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index c5495db9..e048ba8d 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -35,5 +35,8 @@ project(':capawesome-team-capacitor-nfc').projectDir = new File('../node_modules include ':capawesome-capacitor-android-edge-to-edge-support' project(':capawesome-capacitor-android-edge-to-edge-support').projectDir = new File('../node_modules/.pnpm/@capawesome+capacitor-android-edge-to-edge-support@7.2.3_@capacitor+core@7.4.1/node_modules/@capawesome/capacitor-android-edge-to-edge-support/android') +include ':capgo-capacitor-shake' +project(':capgo-capacitor-shake').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-shake@7.2.14_@capacitor+core@7.4.1/node_modules/@capgo/capacitor-shake/android') + include ':revenuecat-purchases-capacitor' project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/.pnpm/@revenuecat+purchases-capacitor@10.3.7_@capacitor+core@7.4.1/node_modules/@revenuecat/purchases-capacitor/android') diff --git a/ios/App/Podfile b/ios/App/Podfile index 22dbac2e..9a294028 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -21,6 +21,7 @@ def capacitor_pods pod 'CapacitorShare', :path => '../../node_modules/.pnpm/@capacitor+share@7.0.2_@capacitor+core@7.4.1/node_modules/@capacitor/share' pod 'CapacitorStatusBar', :path => '../../node_modules/.pnpm/@capacitor+status-bar@7.0.1_@capacitor+core@7.4.1/node_modules/@capacitor/status-bar' pod 'CapawesomeTeamCapacitorNfc', :path => '../../node_modules/.pnpm/@capawesome-team+capacitor-nfc@7.2.0_@capacitor+core@7.4.1/node_modules/@capawesome-team/capacitor-nfc' + pod 'CapgoCapacitorShake', :path => '../../node_modules/.pnpm/@capgo+capacitor-shake@7.2.14_@capacitor+core@7.4.1/node_modules/@capgo/capacitor-shake' pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/.pnpm/@revenuecat+purchases-capacitor@10.3.7_@capacitor+core@7.4.1/node_modules/@revenuecat/purchases-capacitor' end diff --git a/package.json b/package.json index 664f2c23..3fe829be 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@capacitor/status-bar": "^7.0.1", "@capawesome-team/capacitor-nfc": "7.2.0", "@capawesome/capacitor-android-edge-to-edge-support": "^7.2.3", + "@capgo/capacitor-shake": "^7.2.14", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140e74e7..0335e93a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@capawesome/capacitor-android-edge-to-edge-support': specifier: ^7.2.3 version: 7.2.3(@capacitor/core@7.4.1) + '@capgo/capacitor-shake': + specifier: ^7.2.14 + version: 7.2.14(@capacitor/core@7.4.1) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -538,6 +541,11 @@ packages: peerDependencies: '@capacitor/core': '>=7.0.0' + '@capgo/capacitor-shake@7.2.14': + resolution: {integrity: sha512-9d/mr7+DVVqZO2d03q4WzFQivLSuT+ZFpN4Fhn58uZzc6JNA+pBoglexXXMMDFEhzsmwr0K2VnfCmq7tuGDgrw==} + peerDependencies: + '@capacitor/core': '>=7.0.0' + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -5700,6 +5708,10 @@ snapshots: dependencies: '@capacitor/core': 7.4.1 + '@capgo/capacitor-shake@7.2.14(@capacitor/core@7.4.1)': + dependencies: + '@capacitor/core': 7.4.1 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 diff --git a/src/__tests__/unit/hooks/useAppSettings.test.ts b/src/__tests__/unit/hooks/useAppSettings.test.ts index c064c2d4..2aad2880 100644 --- a/src/__tests__/unit/hooks/useAppSettings.test.ts +++ b/src/__tests__/unit/hooks/useAppSettings.test.ts @@ -13,10 +13,10 @@ describe("useAppSettings", () => { }); it("should initialize with provided initData", () => { - const initData = { restartScan: true, launchOnScan: false, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: true, launchOnScan: false, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); - + expect(result.current.restartScan).toBe(true); expect(result.current.launchOnScan).toBe(false); expect(result.current.launcherAccess).toBe(false); @@ -27,7 +27,10 @@ describe("useAppSettings", () => { restartScan: true, launchOnScan: false, launcherAccess: true, - preferRemoteWriter: true + preferRemoteWriter: true, + shakeEnabled: false, + shakeMode: "random" as const, + shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); @@ -40,7 +43,7 @@ describe("useAppSettings", () => { }); it("should persist restartScan setting when changed", async () => { - const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); @@ -57,7 +60,7 @@ describe("useAppSettings", () => { }); it("should persist launchOnScan setting when changed", async () => { - const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); @@ -74,7 +77,7 @@ describe("useAppSettings", () => { }); it("should persist preferRemoteWriter setting when changed", async () => { - const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false }; + const initData = { restartScan: false, launchOnScan: true, launcherAccess: false, preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random" as const, shakeZapscript: "" }; const { result } = renderHook(() => useAppSettings({ initData })); diff --git a/src/components/wui/ToggleSwitch.tsx b/src/components/wui/ToggleSwitch.tsx index f5bb4850..e3023623 100644 --- a/src/components/wui/ToggleSwitch.tsx +++ b/src/components/wui/ToggleSwitch.tsx @@ -1,13 +1,25 @@ import classNames from "classnames"; +import React from "react"; export function ToggleSwitch(props: { - label: string; + label: string | React.ReactNode; value: boolean | undefined; setValue: (value: boolean) => void; disabled?: boolean; + onDisabledClick?: () => void; }) { + const handleClick = (e: React.MouseEvent) => { + if (props.disabled && props.onDisabledClick) { + e.preventDefault(); + props.onDisabledClick(); + } + }; + return ( -
setIsSearching(false)} scrollContainerRef={scrollContainerRef} @@ -159,17 +150,6 @@ export function MediaSearchModal(props: { bottomOffset="1rem" /> - - setSystemSelectorOpen(false)} - onSelect={handleSystemSelect} - selectedSystems={selectedSystem === "all" ? [] : [selectedSystem]} - mode="single" - title={t("create.search.selectSystem")} - includeAllOption={false} - defaultSelection="all" - /> ); } diff --git a/src/components/SimpleSystemSelect.tsx b/src/components/SimpleSystemSelect.tsx new file mode 100644 index 00000000..00a3824c --- /dev/null +++ b/src/components/SimpleSystemSelect.tsx @@ -0,0 +1,94 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; +import { CoreAPI } from "@/lib/coreApi"; +import { useStatusStore } from "@/lib/store"; + +interface SimpleSystemSelectProps { + value: string; // The currently selected system ID (or "all") + onSelect: (systemId: string) => void; + placeholder?: string; + includeAllOption?: boolean; + className?: string; +} + +export function SimpleSystemSelect({ + value, + onSelect, + placeholder, + includeAllOption = false, + className +}: SimpleSystemSelectProps) { + const { t } = useTranslation(); + + // Get indexing state to disable selector when indexing is in progress + const gamesIndex = useStatusStore((state) => state.gamesIndex); + + // Fetch systems data + const { data: systemsData, isLoading } = useQuery({ + queryKey: ["systems"], + queryFn: () => CoreAPI.systems() + }); + + // Group systems by category and sort + const groupedSystems = (systemsData?.systems || []).reduce( + (acc, system) => { + const category = system.category || "Other"; + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(system); + return acc; + }, + {} as Record> + ); + + // Sort categories alphabetically and systems within each category + const sortedCategories = Object.entries(groupedSystems) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, systems]) => ({ + category, + systems: systems.sort((a, b) => a.name.localeCompare(b.name)) + })); + + const handleChange = (e: { target: { value: string } }) => { + onSelect(e.target.value); + }; + + return ( + + ); +} From 3359846e7a46d1b66ad8dca9268ec349593d30f5 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 12 Nov 2025 15:43:24 +0800 Subject: [PATCH 31/42] feat: add `customText` to preferences store and refactor `create.custom` route - Introduce `customText` property and corresponding setter in `usePreferencesStore` for managing user-defined text. - Refactor `create.custom` route to utilize centralized Zustand store instead of `Preferences` API. - Remove redundant `customText` loader and persist logic from `create.custom` route. --- src/lib/preferencesStore.ts | 18 +++++++++++++++--- src/routes/create.custom.tsx | 29 +++++------------------------ 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/src/lib/preferencesStore.ts b/src/lib/preferencesStore.ts index 1555ac90..15f76b29 100644 --- a/src/lib/preferencesStore.ts +++ b/src/lib/preferencesStore.ts @@ -39,6 +39,9 @@ export interface PreferencesState { shakeMode: "random" | "custom"; shakeZapscript: string; + // Custom zapscript page text (separate from shake zapscript) + customText: string; + // Hydration tracking (internal, not persisted) _hasHydrated: boolean; setHasHydrated: (state: boolean) => void; @@ -65,6 +68,7 @@ export interface PreferencesActions { setShakeEnabled: (value: boolean) => void; setShakeMode: (value: "random" | "custom") => void; setShakeZapscript: (value: string) => void; + setCustomText: (value: string) => void; } export type PreferencesStore = PreferencesState & PreferencesActions; @@ -87,7 +91,8 @@ const DEFAULT_PREFERENCES: Omit< preferRemoteWriter: false, shakeEnabled: false, shakeMode: "random", - shakeZapscript: "" + shakeZapscript: "", + customText: "" }; export const usePreferencesStore = create()( @@ -129,7 +134,8 @@ export const usePreferencesStore = create()( // Clear zapscript when mode changes set({ shakeMode: value, shakeZapscript: "" }); }, - setShakeZapscript: (value) => set({ shakeZapscript: value }) + setShakeZapscript: (value) => set({ shakeZapscript: value }), + setCustomText: (value) => set({ customText: value }) }), { name: "app-preferences", @@ -143,7 +149,8 @@ export const usePreferencesStore = create()( preferRemoteWriter: state.preferRemoteWriter, shakeEnabled: state.shakeEnabled, shakeMode: state.shakeMode, - shakeZapscript: state.shakeZapscript + shakeZapscript: state.shakeZapscript, + customText: state.customText }), // Callback when hydration completes @@ -194,3 +201,8 @@ export const selectShakeSettings = (state: PreferencesStore) => ({ setShakeMode: state.setShakeMode, setShakeZapscript: state.setShakeZapscript }); + +export const selectCustomText = (state: PreferencesStore) => ({ + customText: state.customText, + setCustomText: state.setCustomText +}); diff --git a/src/routes/create.custom.tsx b/src/routes/create.custom.tsx index 0f5aa2f4..0b640b5a 100644 --- a/src/routes/create.custom.tsx +++ b/src/routes/create.custom.tsx @@ -1,7 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Preferences } from "@capacitor/preferences"; +import { useShallow } from "zustand/react/shallow"; import { ZapScriptInput } from "@/components/ZapScriptInput.tsx"; import { CreateIcon } from "../lib/images"; import { Button } from "../components/wui/Button"; @@ -9,28 +9,16 @@ import { useSmartSwipe } from "../hooks/useSmartSwipe"; import { WriteModal } from "../components/WriteModal"; import { useNfcWriter, WriteAction } from "../lib/writeNfcHook"; import { PageFrame } from "../components/PageFrame"; - -interface LoaderData { - customText: string; -} +import { usePreferencesStore, selectCustomText } from "../lib/preferencesStore"; export const Route = createFileRoute("/create/custom")({ - loader: async (): Promise => { - const customTextPreference = await Preferences.get({ key: "customText" }); - - return { - customText: customTextPreference.value || "" - }; - }, - // Disable caching to ensure fresh preference is always read - staleTime: 0, - gcTime: 0, component: CustomText }); function CustomText() { - const loaderData = Route.useLoaderData(); - const [customText, setCustomText] = useState(loaderData.customText); + const { customText, setCustomText } = usePreferencesStore( + useShallow(selectCustomText) + ); const nfcWriter = useNfcWriter(); const [writeOpen, setWriteOpen] = useState(false); const closeWriteModal = async () => { @@ -45,13 +33,6 @@ function CustomText() { } }, [nfcWriter]); - useEffect(() => { - const savePreference = async () => { - await Preferences.set({ key: "customText", value: customText }); - }; - savePreference(); - }, [customText]); - const navigate = useNavigate(); const swipeHandlers = useSmartSwipe({ onSwipeRight: () => navigate({ to: "/create" }), From 10423571d70f33abbf7ac4e8f0b8840768f731cf Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Wed, 12 Nov 2025 16:39:03 +0800 Subject: [PATCH 32/42] feat: add hardware availability checks and conditional rendering for camera and accelerometer - Introduce `useCameraAvailabilityCheck` and `useAccelerometerAvailabilityCheck` hooks for detecting camera and accelerometer capabilities. - Update `preferencesStore` to cache and manage states for camera and accelerometer availability. - Modify `App` to hydrate hardware states before rendering, preventing layout shifts. - Adjust feature usage and UI rendering to conditionally depend on hardware availability. - Update AndroidManifest to declare camera and accelerometer features as optional. --- android/app/src/main/AndroidManifest.xml | 13 ++++- src/App.tsx | 16 +++++-- src/components/home/ScanControls.tsx | 4 +- .../useAccelerometerAvailabilityCheck.ts | 46 ++++++++++++++++++ src/hooks/useCameraAvailabilityCheck.ts | 36 ++++++++++++++ src/lib/preferencesStore.ts | 48 ++++++++++++++++++- src/routes/settings.app.tsx | 5 +- 7 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 src/hooks/useAccelerometerAvailabilityCheck.ts create mode 100644 src/hooks/useCameraAvailabilityCheck.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9484b1aa..f7bd9d01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -46,11 +46,20 @@ - - + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index 0aef6229..785535f6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,8 @@ import { SlideModalProvider } from "./components/SlideModalProvider"; import { usePreferencesStore } from "./lib/preferencesStore"; import { useProAccessCheck } from "./hooks/useProAccessCheck"; import { useNfcAvailabilityCheck } from "./hooks/useNfcAvailabilityCheck"; +import { useCameraAvailabilityCheck } from "./hooks/useCameraAvailabilityCheck"; +import { useAccelerometerAvailabilityCheck } from "./hooks/useAccelerometerAvailabilityCheck"; import { useRunQueueProcessor } from "./hooks/useRunQueueProcessor"; import { useWriteQueueProcessor } from "./hooks/useWriteQueueProcessor"; import { useShakeDetection } from "./hooks/useShakeDetection"; @@ -64,13 +66,21 @@ export default function App() { const nfcAvailabilityHydrated = usePreferencesStore( (state) => state._nfcAvailabilityHydrated ); + const cameraAvailabilityHydrated = usePreferencesStore( + (state) => state._cameraAvailabilityHydrated + ); + const accelerometerAvailabilityHydrated = usePreferencesStore( + (state) => state._accelerometerAvailabilityHydrated + ); // Initialize data cache early in app lifecycle useDataCache(); // Check Pro access status once at app startup useProAccessCheck(); - // Check NFC availability once at app startup + // Check hardware availability once at app startup useNfcAvailabilityCheck(); + useCameraAvailabilityCheck(); + useAccelerometerAvailabilityCheck(); const playing = useStatusStore((state) => state.playing); const prevPlaying = usePrevious(playing); @@ -141,8 +151,8 @@ export default function App() { } }, [playing, prevPlaying, t]); - // Block rendering until preferences, Pro access, and NFC availability are hydrated to prevent layout shifts - if (!hasHydrated || !proAccessHydrated || !nfcAvailabilityHydrated) { + // Block rendering until preferences, Pro access, and hardware availability are hydrated to prevent layout shifts + if (!hasHydrated || !proAccessHydrated || !nfcAvailabilityHydrated || !cameraAvailabilityHydrated || !accelerometerAvailabilityHydrated) { return null; // Keep splash screen visible } diff --git a/src/components/home/ScanControls.tsx b/src/components/home/ScanControls.tsx index 0cbfee9d..3903462f 100644 --- a/src/components/home/ScanControls.tsx +++ b/src/components/home/ScanControls.tsx @@ -4,6 +4,7 @@ import { QrCodeIcon } from "lucide-react"; import { ScanSpinner } from "../ScanSpinner"; import { Button } from "../wui/Button"; import { ScanResult } from "../../lib/models"; +import { usePreferencesStore } from "../../lib/preferencesStore"; interface ScanControlsProps { scanSession: boolean; @@ -21,6 +22,7 @@ export function ScanControls({ onCameraScan }: ScanControlsProps) { const { t } = useTranslation(); + const cameraAvailable = usePreferencesStore((state) => state.cameraAvailable); return ( <> @@ -39,7 +41,7 @@ export function ScanControls({
)} - {connected && Capacitor.isNativePlatform() && ( + {connected && Capacitor.isNativePlatform() && cameraAvailable && (
diff --git a/src/components/MediaDatabaseCard.tsx b/src/components/MediaDatabaseCard.tsx index 8a0c219a..ede901df 100644 --- a/src/components/MediaDatabaseCard.tsx +++ b/src/components/MediaDatabaseCard.tsx @@ -211,13 +211,15 @@ export function MediaDatabaseCard() { onClick={() => setSystemSelectorOpen(true)} /> -
{renderStatus()}
diff --git a/src/components/TourInitializer.tsx b/src/components/TourInitializer.tsx new file mode 100644 index 00000000..8750844d --- /dev/null +++ b/src/components/TourInitializer.tsx @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; +import { usePreferencesStore } from "@/lib/preferencesStore"; +import { useStatusStore } from "@/lib/store"; +import { createAppTour } from "@/lib/tourService"; + +export function TourInitializer() { + const navigate = useNavigate(); + const { t } = useTranslation(); + const tourCompleted = usePreferencesStore((state) => state.tourCompleted); + const setTourCompleted = usePreferencesStore( + (state) => state.setTourCompleted + ); + + useEffect(() => { + // Only start tour if not completed and after a delay for DOM readiness + if (!tourCompleted) { + const timer = setTimeout(() => { + // Check connection state right before starting tour (after 1s delay) + const isConnected = useStatusStore.getState().connected; + const tour = createAppTour(navigate, t, isConnected); + + // Mark as completed when tour finishes or is cancelled + tour.on("complete", () => { + setTourCompleted(true); + }); + + tour.on("cancel", () => { + setTourCompleted(true); + }); + + tour.start(); + }, 1000); + + return () => clearTimeout(timer); + } + }, [tourCompleted, navigate, setTourCompleted, t]); + + return null; +} diff --git a/src/index.css b/src/index.css index 832f0d85..2c5b2478 100644 --- a/src/index.css +++ b/src/index.css @@ -430,4 +430,214 @@ body { @apply bg-background text-foreground; } + + /* Shepherd.js Tour Custom Styling - Complete custom theme without default CSS */ + + /* Main tour element wrapper */ + .shepherd-element { + position: absolute; + z-index: 9999; + } + + /* Modal overlay backdrop - full screen */ + .shepherd-modal-overlay-container { + position: fixed !important; + top: 0 !important; + left: 0 !important; + right: 0 !important; + bottom: 0 !important; + width: 100vw !important; + height: 100vh !important; + z-index: 9998; + pointer-events: none; + } + + .shepherd-modal-overlay-container.shepherd-modal-is-visible { + opacity: 1; + transition: opacity 0.3s; + } + + /* SVG overlay element - also needs to be full screen */ + .shepherd-modal-overlay-container svg { + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: 100vh !important; + pointer-events: auto; + } + + /* SVG mask path styling */ + .shepherd-modal-overlay-container path { + fill: rgba(0, 0, 0, 0.5); + pointer-events: auto; + } + + /* Step element - remove any white backgrounds */ + .zaparoo-tour-step { + background: transparent; + } + + /* Content container - matches shadcn Dialog */ + .zaparoo-tour-step .shepherd-content { + background: hsl(210 22% 15%); /* --card */ + border: 1px solid hsl(0 0% 100% / 13%); /* --border */ + border-radius: 0.75rem; /* rounded-xl */ + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); /* drop-shadow-2xl */ + color: hsl(0 0% 100%); + font-family: 'Open Sans', sans-serif; + min-width: 280px; + max-width: 300px; + padding: 0; + margin: 0; + overflow: hidden; /* Clip content to rounded corners */ + } + + /* Header section */ + .zaparoo-tour-step .shepherd-header { + background: transparent; + padding: 1.5rem 1.5rem 0 1.5rem; + position: relative; + } + + /* Title text */ + .zaparoo-tour-step .shepherd-title { + color: hsl(0 0% 100%); + font-size: 1.125rem; /* text-lg */ + font-weight: 600; + line-height: 1; + margin: 0; + padding-right: 2rem; /* Space for close button */ + } + + /* Body text */ + .zaparoo-tour-step .shepherd-text { + color: hsl(215 32% 73%); /* --muted-foreground */ + font-size: 0.875rem; /* text-sm */ + line-height: 1.5; + padding: 1rem 1.5rem; + margin: 0; + } + + /* Close button */ + .zaparoo-tour-step .shepherd-cancel-icon { + position: absolute; + right: 1rem; + top: 1rem; + width: 1.5rem; + height: 1.5rem; + padding: 0; + background: transparent; + border: none; + color: hsl(0 0% 100%); + opacity: 0.7; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; + font-size: 1.25rem; + line-height: 1; + } + + .zaparoo-tour-step .shepherd-cancel-icon:hover { + opacity: 1; + } + + /* Footer section */ + .zaparoo-tour-step .shepherd-footer { + background: transparent; + padding: 0 1.5rem 1.5rem 1.5rem; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 0.5rem; + } + + /* Primary button - matches wui Button fill variant */ + .zaparoo-tour-step .shepherd-button { + background-image: radial-gradient(164.29% 190% at center top, #34c8e8 0%, #4e4af2 100%); + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 20px; + color: hsl(0 0% 100%); + cursor: pointer; + font-family: 'Open Sans', sans-serif; + font-size: 0.875rem; + font-weight: 500; + padding: 0.375rem 1.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 0.1s, transform 0.1s; + touch-action: manipulation; + white-space: nowrap; + } + + .zaparoo-tour-step .shepherd-button:active { + transform: scale(0.95); + } + + /* Secondary button - matches wui Button outline variant */ + .zaparoo-tour-step .shepherd-button.shepherd-button-secondary { + background: transparent; + background-image: none; + border: 1px solid hsl(0 0% 100% / 13%); + color: hsl(0 0% 100%); + } + + /* Arrow pointer */ + .zaparoo-tour-step .shepherd-arrow { + position: absolute; + width: 16px; + height: 16px; + pointer-events: none; + } + + .zaparoo-tour-step .shepherd-arrow::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + background: hsl(210 22% 15%); /* --card */ + border: 1px solid hsl(0 0% 100% / 13%); /* --border */ + transform: rotate(45deg); + } + + /* Arrow positioning */ + .zaparoo-tour-step[data-popper-placement^='top'] .shepherd-arrow { + bottom: -8px; + } + + .zaparoo-tour-step[data-popper-placement^='top'] .shepherd-arrow::before { + border-top: none; + border-left: none; + } + + .zaparoo-tour-step[data-popper-placement^='bottom'] .shepherd-arrow { + top: -8px; + } + + .zaparoo-tour-step[data-popper-placement^='bottom'] .shepherd-arrow::before { + border-bottom: none; + border-right: none; + } + + .zaparoo-tour-step[data-popper-placement^='left'] .shepherd-arrow { + right: -8px; + } + + .zaparoo-tour-step[data-popper-placement^='left'] .shepherd-arrow::before { + border-left: none; + border-bottom: none; + } + + .zaparoo-tour-step[data-popper-placement^='right'] .shepherd-arrow { + left: -8px; + } + + .zaparoo-tour-step[data-popper-placement^='right'] .shepherd-arrow::before { + border-right: none; + border-top: none; + } } \ No newline at end of file diff --git a/src/lib/preferencesStore.ts b/src/lib/preferencesStore.ts index 6f5e8fd4..d925c1c8 100644 --- a/src/lib/preferencesStore.ts +++ b/src/lib/preferencesStore.ts @@ -42,6 +42,9 @@ export interface PreferencesState { // Custom zapscript page text (separate from shake zapscript) customText: string; + // Tour completion tracking + tourCompleted: boolean; + // Hydration tracking (internal, not persisted) _hasHydrated: boolean; setHasHydrated: (state: boolean) => void; @@ -85,6 +88,7 @@ export interface PreferencesActions { setShakeMode: (value: "random" | "custom") => void; setShakeZapscript: (value: string) => void; setCustomText: (value: string) => void; + setTourCompleted: (value: boolean) => void; } export type PreferencesStore = PreferencesState & PreferencesActions; @@ -116,7 +120,8 @@ const DEFAULT_PREFERENCES: Omit< shakeEnabled: false, shakeMode: "random", shakeZapscript: "", - customText: "" + customText: "", + tourCompleted: false }; export const usePreferencesStore = create()( @@ -177,7 +182,8 @@ export const usePreferencesStore = create()( set({ shakeMode: value, shakeZapscript: "" }); }, setShakeZapscript: (value) => set({ shakeZapscript: value }), - setCustomText: (value) => set({ customText: value }) + setCustomText: (value) => set({ customText: value }), + setTourCompleted: (value) => set({ tourCompleted: value }) }), { name: "app-preferences", @@ -192,7 +198,8 @@ export const usePreferencesStore = create()( shakeEnabled: state.shakeEnabled, shakeMode: state.shakeMode, shakeZapscript: state.shakeZapscript, - customText: state.customText + customText: state.customText, + tourCompleted: state.tourCompleted }), // Callback when hydration completes diff --git a/src/lib/tourService.ts b/src/lib/tourService.ts new file mode 100644 index 00000000..8fdeb9a6 --- /dev/null +++ b/src/lib/tourService.ts @@ -0,0 +1,188 @@ +import Shepherd from "shepherd.js"; +import type { NavigateOptions } from "@tanstack/react-router"; +import type { TFunction } from "i18next"; + +export interface TourNavigate { + (opts: NavigateOptions): Promise; +} + +/** + * Creates the Zaparoo app onboarding tour + * + * A quick 4-5 step passive guidance tour that introduces first-time users to: + * - Connecting to Zaparoo Core + * - Updating the media database + * - Creating cards + */ +export const createAppTour = (navigate: TourNavigate, t: TFunction, connected: boolean) => { + const TOTAL_STEPS = connected ? 4 : 5; + + // Helper to format text with step indicator and progress bar + const formatText = (step: number, text: string) => { + const indicator = t("tour.stepIndicator", { current: step, total: TOTAL_STEPS }); + const progress = (step / TOTAL_STEPS) * 100; + return ` +
+
+
+
+ ${indicator} +
+ ${text} + `; + }; + + const tour = new Shepherd.Tour({ + useModalOverlay: true, + defaultStepOptions: { + cancelIcon: { enabled: true }, + classes: "zaparoo-tour-step", + scrollTo: { behavior: "smooth", block: "center" }, + }, + }); + + // Step 1: Welcome + tour.addStep({ + id: "welcome", + title: t("tour.welcome.title"), + text: formatText(1, t("tour.welcome.text")), + buttons: [ + { + text: t("tour.buttons.skip"), + action: tour.cancel, + secondary: true, + }, + { + text: t("tour.buttons.getStarted"), + action: tour.next, + }, + ], + }); + + // Step 2: Device Address (only if not connected) + if (!connected) { + tour.addStep({ + id: "device-address", + title: t("tour.deviceAddress.title"), + text: formatText(2, t("tour.deviceAddress.text")), + attachTo: { + element: '[data-tour="device-address"]', + on: "bottom", + }, + beforeShowPromise: function () { + return new Promise((resolve) => { + navigate({ to: "/settings" }).then(() => { + // Small delay to ensure DOM is ready + setTimeout(resolve, 300); + }); + }); + }, + buttons: [ + { + text: t("tour.buttons.back"), + action: () => { + tour.hide(); + navigate({ to: "/" }).then(() => { + setTimeout(() => tour.back(), 300); + }); + }, + secondary: true, + }, + { + text: t("tour.buttons.next"), + action: tour.next, + }, + ], + }); + } + + // Step 3 (or 2 if connected): Media Database Update + tour.addStep({ + id: "media-database", + title: t("tour.mediaDatabase.title"), + text: formatText(connected ? 2 : 3, t("tour.mediaDatabase.text")), + attachTo: { + element: '[data-tour="update-database"]', + on: "top", + }, + beforeShowPromise: connected ? function () { + return new Promise((resolve) => { + navigate({ to: "/settings" }).then(() => { + setTimeout(resolve, 300); + }); + }); + } : undefined, + buttons: [ + { + text: t("tour.buttons.back"), + action: connected ? () => { + tour.hide(); + navigate({ to: "/" }).then(() => { + setTimeout(() => tour.back(), 300); + }); + } : tour.back, + secondary: true, + }, + { + text: t("tour.buttons.next"), + action: tour.next, + }, + ], + }); + + // Step 4 (or 3 if connected): Card Creation/Search + tour.addStep({ + id: "create-cards", + title: t("tour.createCards.title"), + text: formatText(connected ? 3 : 4, t("tour.createCards.text")), + attachTo: { + element: '[data-tour="create-search"]', + on: "bottom", + }, + beforeShowPromise: function () { + return new Promise((resolve) => { + navigate({ to: "/create" }).then(() => { + setTimeout(resolve, 300); + }); + }); + }, + buttons: [ + { + text: t("tour.buttons.back"), + action: () => { + tour.hide(); + navigate({ to: "/settings" }).then(() => { + setTimeout(() => tour.back(), 300); + }); + }, + secondary: true, + }, + { + text: t("tour.buttons.next"), + action: tour.next, + }, + ], + }); + + // Step 5 (or 4 if connected): Complete - Navigate back to home + tour.addStep({ + id: "complete", + title: t("tour.complete.title"), + text: formatText(connected ? 4 : 5, t("tour.complete.text")), + beforeShowPromise: function () { + return new Promise((resolve) => { + navigate({ to: "/" }).then(() => { + setTimeout(resolve, 300); + }); + }); + }, + buttons: [ + { + text: t("tour.buttons.finish"), + action: tour.complete, + }, + ], + }); + + return tour; +}; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 55c5bc00..f146eded 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -4,6 +4,7 @@ import React from "react"; import { SafeAreaHandler } from "@/lib/safeArea"; import { ErrorComponent } from "@/components/ErrorComponent.tsx"; import { BottomNav } from "../components/BottomNav"; +import { TourInitializer } from "../components/TourInitializer"; import { useBackButtonHandler } from "../hooks/useBackButtonHandler"; function BackHandler() { @@ -48,6 +49,7 @@ export const Route = createRootRoute({
+
diff --git a/src/routes/create.index.tsx b/src/routes/create.index.tsx index ec7a253d..819e7824 100644 --- a/src/routes/create.index.tsx +++ b/src/routes/create.index.tsx @@ -39,7 +39,7 @@ function Create() { <>
- +
-
- ); - } - if (isError) { return ( diff --git a/src/translations/en-US.json b/src/translations/en-US.json index df4fa346..7bc91d29 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -25,7 +25,7 @@ "holdTag": "Hold tag to phone", "holdTagReader": "Hold tag to reader", "scanning": "Scanning", - "pressToScan": "Press to zap", + "pressToScan": "Press to scan", "writeSuccess": "Tag written successfully", "writeFailed": "Failed to write tag", "readSuccess": "Tag read successfully", From 76c225271dbe346dcb4a3a9041069d82f876cd55 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 16 Nov 2025 07:13:48 +0800 Subject: [PATCH 41/42] **feat: add onKeyUp support to TextInput and improve Enter key handling** - Introduce `onKeyUp` prop to `TextInput` for handling keyup events. - Add unit test to verify `Enter` key triggers `onKeyUp`. - Update `settings.index.tsx` to save address changes on `Enter` press. --- .../unit/components/wui/TextInput.test.tsx | 30 +++++++++++++++---- src/routes/settings.index.tsx | 5 ++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/__tests__/unit/components/wui/TextInput.test.tsx b/src/__tests__/unit/components/wui/TextInput.test.tsx index c8426fd5..cb6cbd86 100644 --- a/src/__tests__/unit/components/wui/TextInput.test.tsx +++ b/src/__tests__/unit/components/wui/TextInput.test.tsx @@ -37,21 +37,41 @@ describe('TextInput', () => { it('renders save button when saveValue prop is provided', async () => { const mockSaveValue = vi.fn(); const user = userEvent.setup(); - + render( - ); - + const input = screen.getByDisplayValue('initial'); await user.clear(input); await user.type(input, 'modified text'); - + const saveButton = screen.getByRole('button'); await user.click(saveButton); - + expect(mockSaveValue).toHaveBeenCalledWith('modified text'); }); + + it('calls onKeyUp handler when Enter key is pressed', async () => { + const mockOnKeyUp = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByPlaceholderText('Enter text'); + await user.type(input, 'test{Enter}'); + + expect(mockOnKeyUp).toHaveBeenCalled(); + const lastCall = mockOnKeyUp.mock.calls[mockOnKeyUp.mock.calls.length - 1][0]; + expect(lastCall.key).toBe('Enter'); + }); }); \ No newline at end of file diff --git a/src/routes/settings.index.tsx b/src/routes/settings.index.tsx index ad84f7be..cf7e688b 100644 --- a/src/routes/settings.index.tsx +++ b/src/routes/settings.index.tsx @@ -93,6 +93,11 @@ function Settings() { value={address} setValue={setAddress} saveValue={handleDeviceAddressChange} + onKeyUp={(e) => { + if (e.key === "Enter" && address !== getDeviceAddress()) { + handleDeviceAddressChange(address); + } + }} />
From cee6ea64a9635761e64c0145d3436ace4184bd35 Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sun, 23 Nov 2025 18:57:46 +0800 Subject: [PATCH 42/42] **feat: add playtime limits support with configuration and notification enhancements** - Introduce playtime limits configuration in settings (`daily`, `session`, `session reset` options). - Add playtime status and limit tracking in settings UI. - Implement notifications for playtime warnings and limit reach events. - Extend Core API to include playtime-related endpoints. - Update translations to include playtime-related strings. - Add utility functions for duration parsing and formatting. --- src/components/CoreApiWebSocket.tsx | 32 +++ src/lib/coreApi.ts | 65 ++++++ src/lib/models.ts | 49 ++++- src/lib/utils.ts | 83 ++++++++ src/routes/settings.core.tsx | 294 ++++++++++++++++++++++++++++ src/translations/en-US.json | 24 ++- 6 files changed, 544 insertions(+), 3 deletions(-) diff --git a/src/components/CoreApiWebSocket.tsx b/src/components/CoreApiWebSocket.tsx index c60a6035..397596f9 100644 --- a/src/components/CoreApiWebSocket.tsx +++ b/src/components/CoreApiWebSocket.tsx @@ -10,6 +10,8 @@ import { IndexResponse, Notification, PlayingResponse, + PlaytimeLimitReachedParams, + PlaytimeLimitWarningParams, TokenResponse } from "../lib/models.ts"; import { @@ -19,6 +21,7 @@ import { NotificationRequest } from "../lib/coreApi.ts"; import { useStatusStore, ConnectionState } from "../lib/store.ts"; +import { formatDurationDisplay } from "../lib/utils.ts"; export function CoreApiWebSocket() { const { t } = useTranslation(); @@ -358,6 +361,35 @@ export function CoreApiWebSocket() { case Notification.TokensScanned: activeToken(v.params as TokenResponse); break; + case Notification.PlaytimeLimitWarning: { + const warningParams = v.params as PlaytimeLimitWarningParams; + const remainingTime = formatDurationDisplay(warningParams.remaining); + const limitType = warningParams.type === "daily" ? t("settings.core.playtime.dailyLimit") : t("settings.core.playtime.sessionLimit"); + toast( + t("settings.core.playtime.warningToast", { + remaining: remainingTime, + type: limitType + }), + { + icon: "⏰", + duration: 5000 + } + ); + break; + } + case Notification.PlaytimeLimitReached: { + const reachedParams = v.params as PlaytimeLimitReachedParams; + const limitType = reachedParams.reason === "daily" ? t("settings.core.playtime.dailyLimit") : t("settings.core.playtime.sessionLimit"); + toast.error( + t("settings.core.playtime.reachedToast", { + type: limitType + }), + { + duration: 6000 + } + ); + break; + } default: console.warn("Unknown notification method:", v.method); } diff --git a/src/lib/coreApi.ts b/src/lib/coreApi.ts index 5f1ed056..9dfd7b27 100644 --- a/src/lib/coreApi.ts +++ b/src/lib/coreApi.ts @@ -13,6 +13,9 @@ import { MediaTagsResponse, Method, Notification, + PlaytimeLimitsConfig, + PlaytimeLimitsUpdateRequest, + PlaytimeStatus, ReadersResponse, SearchParams, SearchResultsResponse, @@ -940,6 +943,68 @@ class CoreApi { settingsLogsDownload(): Promise { return this.call(Method.SettingsLogsDownload) as Promise; } + + playtime(): Promise { + return new Promise((resolve, reject) => { + this.call(Method.Playtime) + .then((result) => { + try { + const response = result as PlaytimeStatus; + console.debug(response); + resolve(response); + } catch (e) { + console.error("Error processing playtime response:", e); + reject( + new Error( + `Failed to process playtime response: ${e instanceof Error ? e.message : String(e)}` + ) + ); + } + }) + .catch((error) => { + console.error("Playtime API call failed:", error); + reject(error); + }); + }); + } + + playtimeLimits(): Promise { + return new Promise((resolve, reject) => { + this.call(Method.PlaytimeLimits) + .then((result) => { + try { + const response = result as PlaytimeLimitsConfig; + console.debug(response); + resolve(response); + } catch (e) { + console.error("Error processing playtime limits response:", e); + reject( + new Error( + `Failed to process playtime limits response: ${e instanceof Error ? e.message : String(e)}` + ) + ); + } + }) + .catch((error) => { + console.error("Playtime limits API call failed:", error); + reject(error); + }); + }); + } + + playtimeLimitsUpdate(params: PlaytimeLimitsUpdateRequest): Promise { + console.debug("playtime limits update", params); + return new Promise((resolve, reject) => { + this.call(Method.PlaytimeLimitsUpdate, params) + .then(() => { + resolve(); + }) + .catch((error) => { + console.error("Playtime limits update API call failed:", error); + reject(error); + }); + }); + } } export const CoreAPI = new CoreApi(); diff --git a/src/lib/models.ts b/src/lib/models.ts index 1503fd15..3292a622 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -24,7 +24,10 @@ export enum Method { Readers = "readers", ReadersWrite = "readers.write", ReadersWriteCancel = "readers.write.cancel", - Version = "version" + Version = "version", + Playtime = "playtime", + PlaytimeLimits = "settings.playtime.limits", + PlaytimeLimitsUpdate = "settings.playtime.limits.update" } export enum Notification { @@ -35,7 +38,9 @@ export enum Notification { TokensRemoved = "tokens.removed", MediaStarted = "media.started", MediaStopped = "media.stopped", - MediaIndexing = "media.indexing" + MediaIndexing = "media.indexing", + PlaytimeLimitWarning = "playtime.limit.warning", + PlaytimeLimitReached = "playtime.limit.reached" } export interface VersionResponse { @@ -239,3 +244,43 @@ export interface MediaActiveUpdateRequest { mediaPath: string; mediaName: string; } + +export interface PlaytimeLimitsConfig { + enabled: boolean; + daily: string; + session: string; + sessionReset: string; + warnings: string[]; + retention: number; +} + +export interface PlaytimeStatus { + state: "reset" | "active" | "cooldown"; + sessionActive: boolean; + sessionStarted: string; + sessionDuration: string; + sessionCumulativeTime: string; + sessionRemaining: string; + cooldownRemaining?: string; + dailyUsageToday: string; + dailyRemaining: string; + limitsEnabled: boolean; +} + +export interface PlaytimeLimitsUpdateRequest { + enabled?: boolean; + daily?: string; + session?: string; + sessionReset?: string; + warnings?: string[]; + retention?: number; +} + +export interface PlaytimeLimitWarningParams { + remaining: string; + type: "daily" | "session"; +} + +export interface PlaytimeLimitReachedParams { + reason: "daily" | "session"; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index bd0c391d..290031c3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,3 +4,86 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** + * Parse Go duration format string to hours and minutes + * Examples: "4h" => {hours: 4, minutes: 0}, "1h30m" => {hours: 1, minutes: 30}, "45m" => {hours: 0, minutes: 45} + * Also handles decimals: "39.945s" => {hours: 0, minutes: 1}, "2h0m48.945s" => {hours: 2, minutes: 1} + */ +export function parseDuration(duration: string): { hours: number; minutes: number } { + if (!duration || duration === "0") { + return { hours: 0, minutes: 0 }; + } + + let totalSeconds = 0; + + // Match hours (e.g., "4h", "2h") + const hoursMatch = duration.match(/([\d.]+)h/); + if (hoursMatch) { + totalSeconds += parseFloat(hoursMatch[1]) * 3600; + } + + // Match minutes (e.g., "30m", "0m") + const minutesMatch = duration.match(/([\d.]+)m/); + if (minutesMatch) { + totalSeconds += parseFloat(minutesMatch[1]) * 60; + } + + // Match seconds (e.g., "48.945s", "39s") + const secondsMatch = duration.match(/([\d.]+)s/); + if (secondsMatch) { + totalSeconds += parseFloat(secondsMatch[1]); + } + + const totalMinutes = Math.ceil(totalSeconds / 60); + + return { + hours: Math.floor(totalMinutes / 60), + minutes: totalMinutes % 60 + }; +} + +/** + * Format hours and minutes to Go duration string + * Examples: {hours: 4, minutes: 0} => "4h", {hours: 1, minutes: 30} => "1h30m", {hours: 0, minutes: 45} => "45m" + */ +export function formatDuration({ hours, minutes }: { hours: number; minutes: number }): string { + if (hours === 0 && minutes === 0) { + return "0"; + } + + const parts: string[] = []; + + if (hours > 0) { + parts.push(`${hours}h`); + } + + if (minutes > 0) { + parts.push(`${minutes}m`); + } + + return parts.join(""); +} + +/** + * Format Go duration string for human-readable display + * Examples: "4h" => "4h", "1h30m" => "1h 30m", "45m" => "45m", "90s" => "2m" + */ +export function formatDurationDisplay(duration: string): string { + if (!duration || duration === "0") { + return "0m"; + } + + const { hours, minutes } = parseDuration(duration); + const parts: string[] = []; + + if (hours > 0) { + parts.push(`${hours}h`); + } + + if (minutes > 0) { + parts.push(`${minutes}m`); + } + + return parts.length > 0 ? parts.join(" ") : "0m"; +} diff --git a/src/routes/settings.core.tsx b/src/routes/settings.core.tsx index 8068d4b2..2b6b2452 100644 --- a/src/routes/settings.core.tsx +++ b/src/routes/settings.core.tsx @@ -1,6 +1,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { useState, useEffect, useRef } from "react"; import classNames from "classnames"; import { CoreAPI } from "../lib/coreApi.ts"; import { ToggleSwitch } from "../components/wui/ToggleSwitch"; @@ -9,6 +10,8 @@ import { useStatusStore } from "../lib/store"; import { PageFrame } from "../components/PageFrame"; import { UpdateSettingsRequest } from "../lib/models.ts"; import { BackIcon, CheckIcon } from "../lib/images"; +import { TextInput } from "../components/wui/TextInput"; +import { formatDuration, formatDurationDisplay, parseDuration } from "../lib/utils"; export const Route = createFileRoute("/settings/core")({ component: CoreSettings @@ -163,6 +166,297 @@ function CoreSettings() { {/* disabled={!connected}*/} {/* />*/} {/*
*/} + + {/* Playtime Limits Section */} + ); } + +function PlaytimeLimitsSection({ connected }: { connected: boolean }) { + const { t } = useTranslation(); + + // Local state for form inputs + const [dailyHours, setDailyHours] = useState("0"); + const [dailyMinutes, setDailyMinutes] = useState("0"); + const [sessionHours, setSessionHours] = useState("0"); + const [sessionMinutes, setSessionMinutes] = useState("0"); + const [resetMinutes, setResetMinutes] = useState("0"); + + // Debounce timers + const dailyTimeoutRef = useRef | undefined>(undefined); + const sessionTimeoutRef = useRef | undefined>(undefined); + const resetTimeoutRef = useRef | undefined>(undefined); + + // Fetch playtime limits configuration + const { data: limitsConfig, refetch: refetchLimits } = useQuery({ + queryKey: ["playtime", "limits"], + queryFn: () => CoreAPI.playtimeLimits(), + enabled: connected, + refetchInterval: false + }); + + // Fetch playtime status (for display) + const { data: playtimeStatus } = useQuery({ + queryKey: ["playtime", "status"], + queryFn: () => CoreAPI.playtime(), + enabled: connected && limitsConfig?.enabled === true, + refetchInterval: limitsConfig?.enabled ? 30000 : false // Refresh every 30s when enabled + }); + + // Update mutation + const updateMutation = useMutation({ + mutationFn: CoreAPI.playtimeLimitsUpdate.bind(CoreAPI), + onSuccess: () => { + refetchLimits(); + } + }); + + // Initialize form values when data loads + useEffect(() => { + if (limitsConfig) { + const daily = parseDuration(limitsConfig.daily); + setDailyHours(String(daily.hours)); + setDailyMinutes(String(daily.minutes)); + + const session = parseDuration(limitsConfig.session); + setSessionHours(String(session.hours)); + setSessionMinutes(String(session.minutes)); + + const reset = parseDuration(limitsConfig.sessionReset); + setResetMinutes(String(reset.hours * 60 + reset.minutes)); + } + }, [limitsConfig]); + + // Auto-update daily limit when values change + useEffect(() => { + if (!limitsConfig) return; + + if (dailyTimeoutRef.current) { + clearTimeout(dailyTimeoutRef.current); + } + + dailyTimeoutRef.current = setTimeout(() => { + const daily = formatDuration({ + hours: parseInt(dailyHours) || 0, + minutes: parseInt(dailyMinutes) || 0 + }); + updateMutation.mutate({ daily }); + }, 500); + + return () => { + if (dailyTimeoutRef.current) { + clearTimeout(dailyTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dailyHours, dailyMinutes]); + + // Auto-update session limit when values change + useEffect(() => { + if (!limitsConfig) return; + + if (sessionTimeoutRef.current) { + clearTimeout(sessionTimeoutRef.current); + } + + sessionTimeoutRef.current = setTimeout(() => { + const session = formatDuration({ + hours: parseInt(sessionHours) || 0, + minutes: parseInt(sessionMinutes) || 0 + }); + updateMutation.mutate({ session }); + }, 500); + + return () => { + if (sessionTimeoutRef.current) { + clearTimeout(sessionTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionHours, sessionMinutes]); + + // Auto-update session reset when value changes + useEffect(() => { + if (!limitsConfig) return; + + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + + resetTimeoutRef.current = setTimeout(() => { + const resetMins = parseInt(resetMinutes) || 0; + const sessionReset = formatDuration({ + hours: Math.floor(resetMins / 60), + minutes: resetMins % 60 + }); + updateMutation.mutate({ sessionReset }); + }, 500); + + return () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetMinutes]); + + if (!connected) { + return null; + } + + const handleEnabledToggle = (enabled: boolean) => { + updateMutation.mutate({ enabled }); + }; + + const getStateBadgeColor = (state: string) => { + switch (state) { + case "active": + return "bg-green-500/20 text-green-400"; + case "cooldown": + return "bg-yellow-500/20 text-yellow-400"; + case "reset": + default: + return "bg-gray-500/20 text-gray-400"; + } + }; + + const getStateLabel = (state: string) => { + switch (state) { + case "active": + return t("settings.core.playtime.stateActive"); + case "cooldown": + return t("settings.core.playtime.stateCooldown"); + case "reset": + default: + return t("settings.core.playtime.stateReset"); + } + }; + + return ( +
+

{t("settings.core.playtime.title")}

+ + + + {limitsConfig?.enabled && ( + <> + {/* Status Display */} + {playtimeStatus && ( +
+ {/* Session Status */} +
+
+ {t("settings.core.playtime.currentSession")} + + {getStateLabel(playtimeStatus.state)} + +
+
+ {t("settings.core.playtime.sessionDuration")} + {formatDurationDisplay(playtimeStatus.sessionDuration)} +
+
+ {t("settings.core.playtime.sessionRemaining")} + {formatDurationDisplay(playtimeStatus.sessionRemaining)} +
+ {playtimeStatus.cooldownRemaining && playtimeStatus.state === "cooldown" && ( +
+ {t("settings.core.playtime.cooldownRemaining")} + {formatDurationDisplay(playtimeStatus.cooldownRemaining)} +
+ )} +
+ + {/* Daily Status */} +
+ {t("settings.core.playtime.dailyUsage")} +
+ {t("settings.core.playtime.dailyUsageToday")} + {formatDurationDisplay(playtimeStatus.dailyUsageToday)} +
+
+ {t("settings.core.playtime.dailyRemaining")} + {formatDurationDisplay(playtimeStatus.dailyRemaining)} +
+
+
+ )} + + {/* Configuration Inputs */} +
+ {/* Daily Limit */} +
+ +
+ + +
+
+ + {/* Session Limit */} +
+ +
+ + +
+
+ + {/* Session Reset Timeout */} +
+ + + + {t("settings.core.playtime.neverReset")} + +
+
+ + )} +
+ ); +} diff --git a/src/translations/en-US.json b/src/translations/en-US.json index 7bc91d29..fee91c24 100644 --- a/src/translations/en-US.json +++ b/src/translations/en-US.json @@ -277,7 +277,29 @@ "autoDetect": "Auto-detect external NFC reader", "debug": "Debug mode", "nfcDriver": "NFC driver connection string", - "insertModeBlocklist": "Insert mode core blocklist" + "insertModeBlocklist": "Insert mode core blocklist", + "playtime": { + "title": "Playtime Limits", + "enabled": "Enable playtime limits", + "dailyLimit": "Daily limit", + "sessionLimit": "Session limit", + "sessionReset": "Session reset timeout", + "hours": "Hours", + "minutes": "Minutes", + "neverReset": "Never reset (0 = keeps session active until daily limit)", + "currentSession": "Current Session", + "dailyUsage": "Daily Usage", + "stateReset": "Reset", + "stateActive": "Active", + "stateCooldown": "Cooldown", + "sessionDuration": "Duration", + "sessionRemaining": "Remaining", + "dailyUsageToday": "Used today", + "dailyRemaining": "Remaining", + "cooldownRemaining": "Cooldown ends in", + "warningToast": "{{remaining}} remaining until {{type}} limit", + "reachedToast": "{{type}} limit reached" + } }, "app": { "title": "App Settings",