diff --git a/frontend/app/components/KeyboardShortcutsModal.tsx b/frontend/app/components/KeyboardShortcutsModal.tsx new file mode 100644 index 00000000..78a3341a --- /dev/null +++ b/frontend/app/components/KeyboardShortcutsModal.tsx @@ -0,0 +1,70 @@ +"use client"; + +import React, { useRef } from "react"; +import { X, Keyboard } from "lucide-react"; +import { useFocusTrap } from "../hooks/useFocusTrap"; +import { SHORTCUTS } from "../hooks/useKeyboardShortcuts"; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +export default function KeyboardShortcutsModal({ isOpen, onClose }: Props) { + const modalRef = useRef(null); + const closeRef = useRef(null); + + useFocusTrap({ isOpen, containerRef: modalRef, initialFocusRef: closeRef, onEscape: onClose }); + + if (!isOpen) return null; + + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+
+ +

+ Keyboard Shortcuts +

+
+ +
+ +
    + {SHORTCUTS.map((s) => ( +
  • + {s.description} + + {s.label} + +
  • + ))} +
+ +

+ Shortcuts are disabled when focus is inside a text field. +

+
+
+ ); +} diff --git a/frontend/app/components/ThemeToggle.tsx b/frontend/app/components/ThemeToggle.tsx index 67691329..64bb90b5 100644 --- a/frontend/app/components/ThemeToggle.tsx +++ b/frontend/app/components/ThemeToggle.tsx @@ -45,11 +45,11 @@ export default function ThemeToggle({ const { theme, resolvedTheme, setTheme } = useTheme(); const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); + const menuRef = useRef(null); + const triggerRef = useRef(null); useEffect(() => { - if (!isOpen) { - return; - } + if (!isOpen) return; const handlePointerDown = (event: MouseEvent) => { if (!containerRef.current?.contains(event.target as Node)) { @@ -57,21 +57,33 @@ export default function ThemeToggle({ } }; - const handleEscape = (event: KeyboardEvent) => { - if (event.key === "Escape") { - setIsOpen(false); - } - }; - document.addEventListener("mousedown", handlePointerDown); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousedown", handlePointerDown); - document.removeEventListener("keydown", handleEscape); - }; + return () => document.removeEventListener("mousedown", handlePointerDown); }, [isOpen]); + // Arrow-key navigation inside the open menu + const handleMenuKeyDown = (e: React.KeyboardEvent) => { + const items = Array.from( + menuRef.current?.querySelectorAll('[role="menuitemradio"]') ?? [] + ); + const focused = document.activeElement as HTMLElement; + const idx = items.indexOf(focused as HTMLButtonElement); + + if (e.key === "ArrowDown") { + e.preventDefault(); + items[(idx + 1) % items.length]?.focus(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + items[(idx - 1 + items.length) % items.length]?.focus(); + } else if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + triggerRef.current?.focus(); + } else if (e.key === "Tab") { + setIsOpen(false); + } + }; + const activeOption = useMemo( () => themeOptions.find((option) => option.value === theme) ?? themeOptions[2], [theme] @@ -83,8 +95,12 @@ export default function ThemeToggle({ return (
))} +
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2336314c..0a4b54d4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -241,3 +241,19 @@ body { animation: none; } } + +/* Dark-mode image utilities */ +[data-theme="dark"] .img-invert-on-dark, +.dark .img-invert-on-dark { + filter: invert(1) hue-rotate(180deg); +} + +[data-theme="dark"] .img-dim-on-dark, +.dark .img-dim-on-dark { + filter: brightness(0.85) saturate(0.9); +} + +/* Keyboard shortcut badge */ +kbd { + font-family: var(--font-mono, ui-monospace, monospace); +} diff --git a/frontend/app/hooks/useKeyboardShortcuts.ts b/frontend/app/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..4330983a --- /dev/null +++ b/frontend/app/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,117 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export interface ShortcutDef { + key: string; + label: string; + description: string; + global?: boolean; // fires even inside inputs +} + +export const SHORTCUTS: ShortcutDef[] = [ + { key: "Ctrl+K", label: "Ctrl+K", description: "Open search / command palette" }, + { key: "g d", label: "G then D", description: "Go to Dashboard" }, + { key: "g s", label: "G then S", description: "Go to Savings" }, + { key: "g p", label: "G then P", description: "Go to Portfolio" }, + { key: "g t", label: "G then T", description: "Go to Transactions" }, + { key: "?", label: "?", description: "Show keyboard shortcuts" }, + { key: "Escape", label: "Esc", description: "Close modals / dropdowns", global: true }, + { key: "Ctrl+/", label: "Ctrl+/", description: "Toggle theme" }, + { key: "n", label: "N", description: "New goal (on Savings page)" }, +]; + +interface Options { + onSearch?: () => void; + onToggleTheme?: () => void; + onShowHelp?: () => void; + onNewGoal?: () => void; + pathname?: string; +} + +export function useKeyboardShortcuts({ + onSearch, + onToggleTheme, + onShowHelp, + onNewGoal, + pathname = "", +}: Options) { + const router = useRouter(); + + useEffect(() => { + let pending: string | null = null; + let pendingTimer: ReturnType | null = null; + + const clear = () => { + pending = null; + if (pendingTimer) clearTimeout(pendingTimer); + }; + + const handler = (e: KeyboardEvent) => { + const active = document.activeElement; + const inInput = + active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement || + (active as HTMLElement)?.isContentEditable; + + // Ctrl+K — always fires + if ((e.ctrlKey || e.metaKey) && e.key === "k") { + e.preventDefault(); + onSearch?.(); + return; + } + + // Ctrl+/ — toggle theme + if ((e.ctrlKey || e.metaKey) && e.key === "/") { + e.preventDefault(); + onToggleTheme?.(); + return; + } + + if (inInput) return; + + // ? — show help + if (e.key === "?") { + e.preventDefault(); + onShowHelp?.(); + return; + } + + // N — new goal on savings page + if (e.key === "n" && pathname.startsWith("/savings")) { + e.preventDefault(); + onNewGoal?.(); + return; + } + + // Two-key sequences: g then d/s/p/t + if (e.key === "g") { + pending = "g"; + pendingTimer = setTimeout(clear, 1000); + return; + } + + if (pending === "g") { + clear(); + const routes: Record = { + d: "/dashboard", + s: "/savings", + p: "/dashboard/portfolio", + t: "/dashboard/transactions", + }; + const route = routes[e.key.toLowerCase()]; + if (route) { + e.preventDefault(); + router.push(route); + } + } + }; + + document.addEventListener("keydown", handler); + return () => { + document.removeEventListener("keydown", handler); + clear(); + }; + }, [onSearch, onToggleTheme, onShowHelp, onNewGoal, pathname, router]); +} diff --git a/frontend/app/hooks/useUndoRedo.ts b/frontend/app/hooks/useUndoRedo.ts new file mode 100644 index 00000000..cdf1c273 --- /dev/null +++ b/frontend/app/hooks/useUndoRedo.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +const MAX_HISTORY = 20; + +export function useUndoRedo(initialState: T) { + const [history, setHistory] = useState([initialState]); + const [index, setIndex] = useState(0); + const key = useRef(`undo-redo-${Math.random().toString(36).slice(2)}`); + + const state = history[index]; + const canUndo = index > 0; + const canRedo = index < history.length - 1; + + const addToHistory = useCallback((newState: T) => { + setHistory((prev: T[]) => { + const trimmed = prev.slice(0, index + 1); + const next = [...trimmed, newState].slice(-MAX_HISTORY); + try { sessionStorage.setItem(key.current, JSON.stringify(next)); } catch {} + return next; + }); + setIndex((prev: number) => Math.min(prev + 1, MAX_HISTORY - 1)); + }, [index]); + + const undo = useCallback(() => { + if (canUndo) setIndex((i: number) => i - 1); + }, [canUndo]); + + const redo = useCallback(() => { + if (canRedo) setIndex((i: number) => i + 1); + }, [canRedo]); + + // Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const active = document.activeElement; + const inInput = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement; + if (inInput) return; + if ((e.ctrlKey || e.metaKey) && e.key === "z") { + e.preventDefault(); + if (e.shiftKey) redo(); else undo(); + } + if ((e.ctrlKey || e.metaKey) && e.key === "y") { + e.preventDefault(); + redo(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [undo, redo]); + + return { state, addToHistory, undo, redo, canUndo, canRedo }; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index e8d666c3..8335f405 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -6,6 +6,7 @@ import { WalletProvider } from "./context/WalletContext"; import { ToastProvider } from "./context/ToastContext"; import QueryProvider from "./providers/QueryProvider"; import ErrorBoundary from "./components/ErrorBoundary"; +import KeyboardShortcutsProvider from "./providers/KeyboardShortcutsProvider"; const BASE_URL = "https://nestera.app"; @@ -59,7 +60,9 @@ export default function RootLayout({ -
{children}
+ +
{children}
+
diff --git a/frontend/app/providers/KeyboardShortcutsProvider.tsx b/frontend/app/providers/KeyboardShortcutsProvider.tsx new file mode 100644 index 00000000..d5dcaf9c --- /dev/null +++ b/frontend/app/providers/KeyboardShortcutsProvider.tsx @@ -0,0 +1,33 @@ +"use client"; + +import React, { useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; +import { useTheme } from "../context/ThemeContext"; +import KeyboardShortcutsModal from "../components/KeyboardShortcutsModal"; + +export default function KeyboardShortcutsProvider({ children }: { children: React.ReactNode }) { + const [showHelp, setShowHelp] = useState(false); + const { toggleTheme } = useTheme(); + const pathname = usePathname(); + const router = useRouter(); + + useKeyboardShortcuts({ + onSearch: () => { + // Focus the first search input on the page, or open a future command palette + const input = document.querySelector('input[type="search"], input[placeholder*="earch"]'); + input?.focus(); + }, + onToggleTheme: toggleTheme, + onShowHelp: () => setShowHelp(true), + onNewGoal: () => router.push("/savings/create-goal"), + pathname, + }); + + return ( + <> + {children} + setShowHelp(false)} /> + + ); +} diff --git a/frontend/app/savings/page.tsx b/frontend/app/savings/page.tsx index b2a1ae50..394e6336 100644 --- a/frontend/app/savings/page.tsx +++ b/frontend/app/savings/page.tsx @@ -15,16 +15,38 @@ import { Home, Airplay, ShoppingBag, + Undo2, + Redo2, } from "lucide-react"; import GoalCard, { GoalStatus } from "./components/GoalCard"; import Button from "../components/ui/Button"; +import { useUndoRedo } from "../hooks/useUndoRedo"; +import { useToast } from "../context/ToastContext"; + +type FilterState = { searchQuery: string; statusFilter: string; sortBy: string }; // export const metadata = { title: "Goal-Based Savings - Nestera" }; export default function GoalBasedSavingsPage() { - const [searchQuery, setSearchQuery] = React.useState(""); - const [statusFilter, setStatusFilter] = React.useState("All"); - const [sortBy, setSortBy] = React.useState("Progress"); + const toast = useToast(); + const { state: filters, addToHistory, undo, redo, canUndo, canRedo } = useUndoRedo({ + searchQuery: "", + statusFilter: "All", + sortBy: "Progress", + }); + const { searchQuery, statusFilter, sortBy } = filters; + + const setFilter = (patch: Partial) => addToHistory({ ...filters, ...patch }); + + const handleUndo = () => { + undo(); + toast.info("Undone", "Filter change undone"); + }; + const handleRedo = () => { + redo(); + toast.info("Redone", "Filter change redone"); + }; + const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); const goals = [ { @@ -103,7 +125,7 @@ export default function GoalBasedSavingsPage() { : b.progressPercent - a.progressPercent, ); return filtered; - }, [goals, searchQuery, sortBy, statusFilter]); + }, [searchQuery, sortBy, statusFilter]); return (
@@ -226,9 +248,9 @@ export default function GoalBasedSavingsPage() { size={18} /> setSearchQuery(e.target.value)} + onChange={(e) => setFilter({ searchQuery: e.target.value })} placeholder="Search goals..." className="w-full bg-[#0e2330] border border-white/5 rounded-xl py-3 pl-12 pr-4 text-white placeholder:text-[#4e7a86] focus:outline-hidden focus:border-cyan-500/50 transition-colors" /> @@ -236,7 +258,7 @@ export default function GoalBasedSavingsPage() {
-
+
+ {/* Undo / Redo */} +
+ + +