Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 44 additions & 21 deletions src/components/Analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions src/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Modal open={open} onClose={onCancel} title={title}>
<p className="mb-6 text-sm text-gray-600 dark:text-gray-300">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-gray-300 hover:bg-white/10 transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
data-testid="confirm-action"
className={`rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
destructive
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-teal-500 text-white hover:bg-teal-600'
}`}
>
{confirmLabel}
</button>
</div>
</Modal>
);
}
1 change: 1 addition & 0 deletions src/components/KeyboardShortcutHelpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
];
Expand Down
15 changes: 15 additions & 0 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,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<HTMLInputElement>(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<string | null>(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;
Expand Down Expand Up @@ -80,6 +94,7 @@ export const SearchBar = memo(function SearchBar({
/>
)}
<input
ref={inputRef}
type="text"
aria-busy={isPending}
value={query}
Expand Down
51 changes: 47 additions & 4 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,57 @@
'use client';

import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';

export function useDebounce<T>(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<T>(
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;
}
Loading