From 1c336e51d132ec9502f0a93b6ad4811fb2ee63a5 Mon Sep 17 00:00:00 2001 From: brightpixel Date: Fri, 16 Oct 2026 04:31:46 +0100 Subject: [PATCH] feat: ConfirmModal, debounce leading edge, lazy PostHog, Ctrl+K search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #751: Add ConfirmModal component using the app's Modal for branded confirmations with focus trap, ARIA, and confirm/cancel actions. Supports destructive variant with red styling. - #750: Add leading option to useDebounce hook — fires the first change immediately and debounces subsequent ones, matching lodash.debounce semantics. - #741: Lazy-load PostHog via dynamic import() deferred to requestIdleCallback (1.5s fallback) so analytics never blocks the main thread during first paint. Pageview tracking unchanged. - #740: Register global Ctrl+K/Cmd+K shortcut to focus the search input from anywhere. Show shortcut hint in placeholder. Add entry to KeyboardShortcutHelpModal. --- src/components/Analytics.tsx | 65 +++++++++++++------- src/components/ConfirmModal.tsx | 55 +++++++++++++++++ src/components/KeyboardShortcutHelpModal.tsx | 1 + src/components/SearchBar.tsx | 17 ++++- src/hooks/useDebounce.ts | 51 +++++++++++++-- 5 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 src/components/ConfirmModal.tsx diff --git a/src/components/Analytics.tsx b/src/components/Analytics.tsx index ec9527d..76718bf 100644 --- a/src/components/Analytics.tsx +++ b/src/components/Analytics.tsx @@ -2,35 +2,58 @@ import { useEffect } from 'react'; import { usePathname } from 'next/navigation'; -import posthog from 'posthog-js'; import { setPostHogReady, trackPageview } from '@/lib/analytics'; +/** + * Lazily initialize PostHog after the page becomes interactive, so its + * script download and init don't block the main thread during first paint. + * Defers to `requestIdleCallback` (with a `setTimeout` fallback) and runs + * at least 1.5s after mount so the critical rendering path is unblocked. + */ export function Analytics() { const pathname = usePathname(); useEffect(() => { if (typeof window === 'undefined') return; - const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; - const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com'; - - // Check if PostHog is already initialized via 'capture' method existence - const ph = posthog as typeof posthog & { __initialized?: boolean }; - if (posthogKey && typeof posthog.capture === 'function' && !ph.__initialized) { - posthog.init(posthogKey, { - api_host: posthogHost, - capture_pageview: false, - loaded: (ph) => { - ph.identify(); - setPostHogReady(true); - }, - }); - // Mark as initialized to avoid redundant init calls - Object.defineProperty(ph, '__initialized', { value: true, configurable: false }); - } else if (posthogKey) { - // PostHog is already initialized - setPostHogReady(true); - } + let cancelled = false; + + const initPostHog = async () => { + const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; + const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com'; + if (!posthogKey || cancelled) return; + + // Dynamic import defers the posthog-js bundle download until idle. + const { default: posthog } = await import('posthog-js'); + if (cancelled) return; + + const ph = posthog as typeof posthog & { __initialized?: boolean }; + if (typeof posthog.capture === 'function' && !ph.__initialized) { + posthog.init(posthogKey, { + api_host: posthogHost, + capture_pageview: false, + loaded: (ph) => { + ph.identify(); + setPostHogReady(true); + }, + }); + Object.defineProperty(ph, '__initialized', { value: true, configurable: false }); + } else { + setPostHogReady(true); + } + }; + + // Defer until the browser is idle (or after 1.5s at the latest) so + // PostHog never competes with first-paint resources. + const schedule = window.requestIdleCallback ?? ((cb: () => void) => setTimeout(cb, 1500)); + const handle = schedule(initPostHog); + + return () => { + cancelled = true; + if (window.cancelIdleCallback && typeof handle === 'number') { + window.cancelIdleCallback(handle); + } + }; }, []); // Route change is the single source of pageviews; trackPageview drops diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..fc4f1d0 --- /dev/null +++ b/src/components/ConfirmModal.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { Modal } from './Modal'; + +interface ConfirmModalProps { + open: boolean; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + destructive?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * Branded confirmation dialog replacing `window.confirm` for destructive + * actions. Uses the app's Modal component for consistent styling, focus + * trap, and ARIA attributes. + */ +export function ConfirmModal({ + open, + title, + message, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + destructive = false, + onConfirm, + onCancel, +}: ConfirmModalProps) { + return ( + +

{message}

+
+ + +
+
+ ); +} diff --git a/src/components/KeyboardShortcutHelpModal.tsx b/src/components/KeyboardShortcutHelpModal.tsx index 1b56e4d..1133672 100644 --- a/src/components/KeyboardShortcutHelpModal.tsx +++ b/src/components/KeyboardShortcutHelpModal.tsx @@ -6,6 +6,7 @@ interface Props { } const SHORTCUTS = [ + { key: 'Ctrl+K', description: 'Focus search' }, { key: '?', description: 'Show keyboard shortcuts' }, { key: 'Esc', description: 'Close modal / menu' }, ]; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 6075f0d..7f34f75 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -41,10 +41,24 @@ export const SearchBar = memo(function SearchBar({ // Clearing fires onSearch('') immediately, so an empty query is never pending. const isPending = query !== '' && query !== debouncedQuery; const pathname = usePathname(); + const inputRef = useRef(null); // Last value handed to `onSearch`, so clears and the debounce settling on the // same value don't trigger a redundant parent re-filter. const lastEmitted = useRef(null); + // Ctrl+K / Cmd+K focuses the search input from anywhere on the page (#740). + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + inputRef.current?.focus(); + inputRef.current?.select(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + const emit = useCallback((value: string) => { if (lastEmitted.current === value) return; lastEmitted.current = value; @@ -79,11 +93,12 @@ export const SearchBar = memo(function SearchBar({ /> )} setQuery(e.target.value)} - placeholder={placeholder} + placeholder={`${placeholder} (Ctrl+K)`} aria-label={label} aria-controls={resultsId} className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-gray-400 focus:border-teal-500 focus:outline-none" diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts index d5a328e..cb38bf9 100644 --- a/src/hooks/useDebounce.ts +++ b/src/hooks/useDebounce.ts @@ -1,14 +1,57 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; -export function useDebounce(value: T, delayMs: number): T { +interface UseDebounceOptions { + /** + * When true, the first call in a burst fires immediately (leading edge). + * Subsequent calls within the delay window are debounced as usual. + * Defaults to false (trailing-edge only). + */ + leading?: boolean; +} + +/** + * Debounce a value. By default only the trailing edge fires — `value` + * updates after `delayMs` of quiet. Pass `leading: true` to fire the first + * change immediately and debounce subsequent ones. + * + * @param value The value to debounce. + * @param delayMs Debounce delay in milliseconds. + * @param options Optional `{ leading: boolean }`. + */ +export function useDebounce( + value: T, + delayMs: number, + options?: UseDebounceOptions, +): T { + const leading = options?.leading ?? false; const [debounced, setDebounced] = useState(value); + const isFirstChange = useRef(true); + const previousValue = useRef(value); useEffect(() => { - const timer = setTimeout(() => setDebounced(value), delayMs); + // Skip the initial mount — the value is already correct. + if (isFirstChange.current) { + isFirstChange.current = false; + previousValue.current = value; + return; + } + + const valueChanged = value !== previousValue.current; + previousValue.current = value; + + // Leading edge: fire immediately on the first change after a quiet period. + if (leading && valueChanged) { + setDebounced(value); + } + + const timer = setTimeout(() => { + setDebounced(value); + }, delayMs); + return () => clearTimeout(timer); - }, [value, delayMs]); + }, [value, delayMs, leading]); return debounced; }