('button, a, input, [tabindex]:not([tabindex="-1"])')
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
else if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, filteredMatches.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)) }
else if (e.key === 'Enter') {
@@ -412,6 +421,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
return (
()
+import { redis } from '@/lib/redis'
+import { NextResponse } from 'next/server'
-const WINDOW_MS = 60_000
-const MAX_ATTEMPTS = 5
+const WINDOW_SECONDS = 60
+const MAX_REQUESTS = 20
-export function rateLimit(key: string): { allowed: boolean; retryAfterMs: number } {
- const now = Date.now()
- const entry = attempts.get(key)
-
- if (!entry || now > entry.resetAt) {
- attempts.set(key, { count: 1, resetAt: now + WINDOW_MS })
- return { allowed: true, retryAfterMs: 0 }
+export async function rateLimit(
+ userId: string,
+ feature: string,
+ max: number = MAX_REQUESTS,
+ windowSec: number = WINDOW_SECONDS,
+): Promise<{ allowed: boolean; remaining: number }> {
+ const key = `ratelimit:${feature}:${userId}`
+ try {
+ const count = await redis.incr(key)
+ if (count === 1) {
+ await redis.expire(key, windowSec)
+ }
+ const remaining = Math.max(0, max - count)
+ return { allowed: count <= max, remaining }
+ } catch {
+ return { allowed: true, remaining: max }
}
-
- entry.count++
-
- if (entry.count > MAX_ATTEMPTS) {
- return { allowed: false, retryAfterMs: entry.resetAt - now }
- }
-
- return { allowed: true, retryAfterMs: 0 }
}
-setInterval(() => {
- const now = Date.now()
- for (const [k, v] of attempts) {
- if (now > v.resetAt) attempts.delete(k)
- }
-}, 60_000)
+export function rateLimitResponse(remaining: number): NextResponse | null {
+ return null
+}