From 4689f3676ffa67229932b853a7228888374558e6 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 25 Jan 2026 11:12:19 -0700 Subject: [PATCH] refactor(auth): consolidate authentication contexts and enhance rate limit UI integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streamline auth client factory and remove redundant session manager Introduce unified auto rate limit context for seamless status updates Implement problem details in TTS responses for improved error clarity Add rate limit pause controls and banners across document viewers Refine PDF scaling logic with height-aware resize handling Update privacy popup triggers and first-visit modal behavior BREAKING CHANGE: Daily character limits adjusted (anonymous: 250K → 50K, authenticated: 1M → 500K) --- src/app/api/rate-limit/status/route.ts | 30 +++--- src/app/api/tts/route.ts | 101 ++++++++++++++--- src/app/epub/[id]/page.tsx | 18 +++- src/app/html/[id]/page.tsx | 19 +++- src/app/page.tsx | 46 ++++---- src/app/pdf/[id]/page.tsx | 20 +++- src/app/providers.tsx | 50 ++++----- src/app/signin/page.tsx | 7 +- src/app/signup/page.tsx | 4 +- src/components/PDFViewer.tsx | 10 +- src/components/SettingsModal.tsx | 58 +++++++--- src/components/auth/AuthLoader.tsx | 14 +-- src/components/auth/SessionManager.tsx | 48 --------- src/components/auth/UserMenu.tsx | 11 +- .../player/RateLimitPauseButton.tsx | 25 +++++ src/components/privacy-popup.tsx | 5 +- src/components/rate-limit-banner.tsx | 37 +++---- src/contexts/AuthConfigContext.tsx | 33 ------ .../AutoRateLimitContext.tsx} | 78 +++++++++----- src/contexts/TTSContext.tsx | 90 ++++++++++++++-- src/hooks/pdf/usePDFResize.ts | 15 ++- src/hooks/useAuth.ts | 2 +- src/lib/auth-client.ts | 32 +++--- src/lib/client.ts | 55 +++++++++- src/lib/pdf.ts | 5 + src/lib/server/db-adapter.ts | 26 ++++- src/lib/server/rate-limiter.ts | 102 ++++++++++-------- src/types/client.ts | 8 ++ tests/helpers.ts | 32 +++++- 29 files changed, 641 insertions(+), 340 deletions(-) delete mode 100644 src/components/auth/SessionManager.tsx create mode 100644 src/components/player/RateLimitPauseButton.tsx delete mode 100644 src/contexts/AuthConfigContext.tsx rename src/{components/rate-limit-provider.tsx => contexts/AutoRateLimitContext.tsx} (65%) diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 12e1a07..ce6307d 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -6,20 +6,27 @@ import { isAuthEnabled } from '@/lib/server/auth-config'; export const dynamic = 'force-dynamic'; +function getUtcResetTimeIso(): string { + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); + return tomorrow.toISOString(); +} + export async function GET() { try { // If auth is not enabled, return unlimited status if (!isAuthEnabled() || !auth) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); - + const resetTime = getUtcResetTimeIso(); return NextResponse.json({ allowed: true, currentCount: 0, - limit: Infinity, - remainingChars: Infinity, - resetTime: tomorrow.toISOString(), + // Avoid Infinity in JSON (serializes to null). This value is never shown + // because authEnabled=false, but we keep it finite to prevent surprises. + limit: Number.MAX_SAFE_INTEGER, + remainingChars: Number.MAX_SAFE_INTEGER, + resetTime, userType: 'unauthenticated', authEnabled: false }); @@ -32,22 +39,19 @@ export async function GET() { // No session means unauthenticated if (!session?.user) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); - + const resetTime = getUtcResetTimeIso(); return NextResponse.json({ allowed: true, currentCount: 0, limit: RATE_LIMITS.ANONYMOUS, remainingChars: RATE_LIMITS.ANONYMOUS, - resetTime: tomorrow.toISOString(), + resetTime, userType: 'unauthenticated', authEnabled: true }); } - const isAnonymous = !session.user.email || session.user.email === ''; + const isAnonymous = Boolean(session.user.isAnonymous); const result = await rateLimiter.getCurrentUsage({ id: session.user.id, diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 33a0bdd..de750fd 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -35,6 +35,21 @@ type InflightEntry = { const inflightRequests = new Map(); +const PROBLEM_TYPES = { + dailyQuotaExceeded: 'https://openreader.app/problems/daily-quota-exceeded', + upstreamRateLimited: 'https://openreader.app/problems/upstream-rate-limited', +} as const; + +type ProblemDetails = { + type: string; + title: string; + status: number; + detail?: string; + instance?: string; + code?: string; + [key: string]: unknown; +}; + function sleep(ms: number) { return new Promise((res) => setTimeout(res, ms)); } @@ -100,7 +115,18 @@ function makeCacheKey(input: { return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); } +function formatLimitForHint(limit: number): string { + if (!Number.isFinite(limit) || limit <= 0) return String(limit); + if (limit >= 1_000_000) { + const m = limit / 1_000_000; + return `${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`; + } + if (limit >= 1_000) return `${Math.round(limit / 1_000)}K`; + return String(limit); +} + export async function POST(req: NextRequest) { + let providerForError: string | null = null; try { // Parse body first to get text for rate limiting const body = (await req.json()) as TTSRequestPayload; @@ -127,7 +153,7 @@ export async function POST(req: NextRequest) { ); } - const isAnonymous = !session.user.email || session.user.email === ''; + const isAnonymous = Boolean(session.user.isAnonymous); const charCount = text.length; // Check rate limit @@ -137,21 +163,36 @@ export async function POST(req: NextRequest) { ); if (!rateLimitResult.allowed) { - return NextResponse.json( - { - code: 'RATE_LIMIT_EXCEEDED', - message: 'Daily character limit exceeded', - currentCount: rateLimitResult.currentCount, - limit: rateLimitResult.limit, - remainingChars: rateLimitResult.remainingChars, - resetTime: rateLimitResult.resetTime.toISOString(), - userType: isAnonymous ? 'anonymous' : 'authenticated', - upgradeHint: isAnonymous - ? `Sign up to increase your limit from ${(RATE_LIMITS.ANONYMOUS / 1000).toFixed(0)}K to ${(RATE_LIMITS.AUTHENTICATED / 1_000_000).toFixed(0)}M characters per day` - : undefined - }, - { status: 429 } + const resetTime = rateLimitResult.resetTime.toISOString(); + const retryAfterSeconds = Math.max( + 0, + Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000) ); + + const problem: ProblemDetails = { + type: PROBLEM_TYPES.dailyQuotaExceeded, + title: 'Daily quota exceeded', + status: 429, + detail: 'Daily character limit exceeded', + code: 'USER_DAILY_QUOTA_EXCEEDED', + currentCount: rateLimitResult.currentCount, + limit: rateLimitResult.limit, + remainingChars: rateLimitResult.remainingChars, + resetTime, + userType: isAnonymous ? 'anonymous' : 'authenticated', + upgradeHint: isAnonymous + ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` + : undefined, + instance: req.nextUrl.pathname, + }; + + return new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + 'Retry-After': String(retryAfterSeconds), + }, + }); } } @@ -159,6 +200,7 @@ export async function POST(req: NextRequest) { const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; const provider = req.headers.get('x-tts-provider') || 'openai'; + providerForError = provider; console.log('Received TTS request:', { provider, req_model, voice, speed, format, hasInstructions: Boolean(instructions) }); // Use default Kokoro model for Deepinfra if none specified, then fall back to a safe default const rawModel = provider === 'deepinfra' && !req_model ? 'hexgrad/Kokoro-82M' : req_model; @@ -315,6 +357,35 @@ export async function POST(req: NextRequest) { return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request } + const upstreamStatus = (() => { + if (typeof error === 'object' && error !== null) { + const rec = error as Record; + if (typeof rec.status === 'number') return rec.status as number; + if (typeof rec.statusCode === 'number') return rec.statusCode as number; + } + return undefined; + })(); + + if (upstreamStatus === 429) { + const problem: ProblemDetails = { + type: PROBLEM_TYPES.upstreamRateLimited, + title: 'Upstream rate limited', + status: 429, + detail: 'The TTS provider is rate limiting requests. Please try again shortly.', + code: 'UPSTREAM_RATE_LIMIT', + provider: providerForError ?? undefined, + upstreamStatus, + instance: req.nextUrl.pathname, + }; + + return new NextResponse(JSON.stringify(problem), { + status: 429, + headers: { + 'Content-Type': 'application/problem+json', + }, + }); + } + console.warn('Error generating TTS:', error); const errorBody: TTSError = { code: 'TTS_GENERATION_FAILED', diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index 8cccb88..b36dd22 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -11,12 +11,15 @@ import { SettingsIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; +import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { DownloadIcon } from '@/components/icons/Icons'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import { resolveDocumentId } from '@/lib/dexie'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -25,6 +28,7 @@ export default function EPUBPage() { const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { stop } = useTTS(); + const { isAtLimit } = useAutoRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -178,12 +182,13 @@ export default function EPUBPage() { } />
+ {isLoading ? (
) : ( -
+
)} @@ -198,7 +203,16 @@ export default function EPUBPage() { onRegenerateChapter={handleRegenerateChapter} /> )} - + {isAtLimit ? ( +
+
+ + +
+
+ ) : ( + + )} ); diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index a350b0f..6c98201 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -7,18 +7,22 @@ import { useHTML } from '@/contexts/HTMLContext'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { HTMLViewer } from '@/components/HTMLViewer'; import { DocumentSettings } from '@/components/DocumentSettings'; +import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { SettingsIcon } from '@/components/icons/Icons'; import { Header } from '@/components/Header'; import { useTTS } from "@/contexts/TTSContext"; import TTSPlayer from '@/components/player/TTSPlayer'; import { ZoomControl } from '@/components/ZoomControl'; import { resolveDocumentId } from '@/lib/dexie'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; export default function HTMLPage() { const { id } = useParams(); const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML(); const { stop } = useTTS(); + const { isAtLimit } = useAutoRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSettingsOpen, setIsSettingsOpen] = useState(false); @@ -58,6 +62,7 @@ export default function HTMLPage() { }, [loadDocument]); // Compute available height = viewport - (header height + tts bar height) + useEffect(() => { const compute = () => { const header = document.querySelector('[data-app-header]') as HTMLElement | null; @@ -86,7 +91,7 @@ export default function HTMLPage() {

{error}

{clearCurrDoc();}} + onClick={() => { clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -140,12 +145,20 @@ export default function HTMLPage() {
) : ( -
+
)}
- + {isAtLimit ? ( +
+
+ +
+
+ ) : ( + + )} ); diff --git a/src/app/page.tsx b/src/app/page.tsx index 9d83fd5..3d85b7f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,31 +2,35 @@ import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; import { UserMenu } from '@/components/auth/UserMenu'; import { AuthLoader } from '@/components/auth/AuthLoader'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; export default function Home() { return ( - -
- - -
-
-

OpenReader WebUI

-

- Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. - Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices. -

-
-
-
-
- -
-
-
+
+ + + <> + +
+
+

OpenReader WebUI

+

+ Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. + Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices. +

+
+
+
+
+ + +
+ +
+
); } diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 8af59a4..588da24 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -15,14 +15,17 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; import TTSPlayer from '@/components/player/TTSPlayer'; +import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { resolveDocumentId } from '@/lib/dexie'; +import { RateLimitBanner } from '@/components/rate-limit-banner'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; // Dynamic import for client-side rendering only const PDFViewer = dynamic( () => import('@/components/PDFViewer').then((module) => module.PDFViewer), - { + { ssr: false, loading: () => } @@ -33,6 +36,7 @@ export default function PDFViewerPage() { const router = useRouter(); const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { stop } = useTTS(); + const { isAtLimit } = useAutoRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [zoomLevel, setZoomLevel] = useState(100); @@ -114,7 +118,7 @@ export default function PDFViewerPage() {

{error}

{clearCurrDoc();}} + onClick={() => { clearCurrDoc(); }} className="inline-flex items-center px-3 py-1 bg-base text-foreground rounded-lg hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent" > @@ -167,6 +171,7 @@ export default function PDFViewerPage() { } />
+ {isLoading ? (
@@ -185,7 +190,16 @@ export default function PDFViewerPage() { onRegenerateChapter={handleRegenerateChapter} /> )} - + {isAtLimit ? ( +
+
+ + +
+
+ ) : ( + + )} ); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 5d9016d..8e14dd4 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,5 +1,3 @@ -'use client'; - import { ReactNode } from 'react'; import { DocumentProvider } from '@/contexts/DocumentContext'; @@ -9,9 +7,8 @@ import { TTSProvider } from '@/contexts/TTSContext'; import { ThemeProvider } from '@/contexts/ThemeContext'; import { ConfigProvider } from '@/contexts/ConfigContext'; import { HTMLProvider } from '@/contexts/HTMLContext'; -import { RateLimitProvider } from '@/components/rate-limit-provider'; +import { AutoRateLimitProvider } from '@/contexts/AutoRateLimitContext'; import { PrivacyPopup } from '@/components/privacy-popup'; -import { AuthConfigProvider } from '@/contexts/AuthConfigContext'; interface ProvidersProps { children: ReactNode; @@ -20,33 +17,24 @@ interface ProvidersProps { } export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps) { - const content = ( - - - - - - - - {children} - - - - - - - - - ); - - // Wrap with RateLimitProvider only when auth is enabled - const wrappedContent = authEnabled ? ( - {content} - ) : content; - return ( - - {wrappedContent} - + + + + + + + + + {children} + + + + + + + + + ); } diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index 4b9637d..d7cf651 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -5,7 +5,7 @@ import { Button, Input } from '@headlessui/react'; import { useRouter, useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/auth-client'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { showPrivacyPopup } from '@/components/privacy-popup'; import { wasSignedOut, clearSignedOut } from '@/lib/session-utils'; import { GithubIcon } from '@/components/icons/Icons'; @@ -32,6 +32,7 @@ function SignInContent() { const [justSignedOut, setJustSignedOut] = useState(false); const [error, setError] = useState(null); const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const isAnyLoading = loadingEmail || loadingGithub || loadingGuest; @@ -87,6 +88,9 @@ function SignInContent() { setError(errorMessage); } } else { + // Immediately refresh rate-limit status so the banner clears without a full reload. + // This is especially important when an anonymous user upgrades to an account. + await refreshRateLimit(); router.push('/'); } } catch (err) { @@ -116,6 +120,7 @@ function SignInContent() { try { const client = getAuthClient(baseUrl); await client.signIn.anonymous(); + await refreshRateLimit(); router.push('/'); } catch (e) { console.error('Anonymous sign-in failed:', e); diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index ea3d974..c9cf414 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -5,7 +5,7 @@ import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { getAuthClient } from '@/lib/auth-client'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { showPrivacyPopup } from '@/components/privacy-popup'; import { LoadingSpinner } from '@/components/Spinner'; import toast from 'react-hot-toast'; @@ -19,6 +19,7 @@ export default function SignUpPage() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); // Check if auth is enabled, redirect home if not useEffect(() => { @@ -84,6 +85,7 @@ export default function SignUpPage() { toast.success('Account created! Please sign in.'); router.push('/signin'); } else { + await refreshRateLimit(); toast.success('Account created successfully!'); router.push('/'); } diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index 0a20958..c591d7d 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -23,7 +23,7 @@ interface PDFOnLinkClickArgs { export function PDFViewer({ zoomLevel }: PDFViewerProps) { const containerRef = useRef(null); const scaleRef = useRef(1); - const { containerWidth } = usePDFResize(containerRef); + const { containerWidth, containerHeight } = usePDFResize(containerRef); const sentenceHighlightSeqRef = useRef(0); const wordHighlightSeqRef = useRef(0); const sentenceHighlightTimeoutsRef = useRef[]>([]); @@ -55,7 +55,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { currDocPage, } = usePDF(); - const layoutKey = `${zoomLevel}:${containerWidth}:${viewType}:${currDocPage}`; + const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; const clearSentenceHighlightTimeouts = useCallback(() => { for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); @@ -234,11 +234,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { // Modify scale calculation to be more efficient const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => { const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type - const containerHeight = (containerRef.current?.clientHeight ?? window.innerHeight); + const effectiveContainerHeight = containerHeight || (containerRef.current?.clientHeight ?? window.innerHeight); const targetWidth = viewType === 'dual' ? (containerWidth - margin) / 2 // divide by 2 for dual pages : containerWidth - margin; - const targetHeight = containerHeight - margin; + const targetHeight = effectiveContainerHeight - margin; if (viewType === 'scroll') { // For scroll mode, use a more comfortable width-based scale @@ -252,7 +252,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { const baseScale = Math.min(scaleByWidth, scaleByHeight); return baseScale * (zoomLevel / 100); - }, [containerWidth, zoomLevel, pageWidth, pageHeight, viewType]); + }, [containerWidth, containerHeight, zoomLevel, pageWidth, pageHeight, viewType]); // Add memoized scale to prevent unnecessary recalculations const currentScale = useCallback(() => { diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index da8a8a9..aa9b3a7 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -23,7 +23,7 @@ import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; -import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; +import { syncSelectedDocumentsToServer, loadSelectedDocumentsFromServer, importSelectedDocuments, getAppConfig, getFirstVisit, setFirstVisit, getAllPdfDocuments, getAllEpubDocuments, getAllHtmlDocuments } from '@/lib/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -35,7 +35,8 @@ import { BaseDocument } from '@/types/documents'; import { getAuthClient } from '@/lib/auth-client'; import { useAuthSession } from '@/hooks/useAuth'; import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; +import { useRouter } from 'next/navigation'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -84,7 +85,9 @@ export function SettingsModal() { const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session } = useAuthSession(); + const router = useRouter(); const ttsProviders = useMemo(() => [ { id: 'custom-openai', name: 'Custom OpenAI-Like' }, @@ -146,9 +149,12 @@ export function SettingsModal() { [selectedModelId, supportsCustom, customModelInput] ); - // set firstVisit on initial load + // Open settings on first visit (stored in Dexie app config) const checkFirstVist = useCallback(async () => { - if (!isDev) return; + const appConfig = await getAppConfig(); + if (!appConfig?.privacyAccepted) { + return; + } const firstVisit = await getFirstVisit(); if (!firstVisit) { await setFirstVisit(true); @@ -165,6 +171,16 @@ export function SettingsModal() { setLocalTTSInstructions(ttsInstructions); }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]); + useEffect(() => { + const onPrivacyAccepted = () => { + checkFirstVist(); + }; + window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted); + return () => { + window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); + }; + }, [checkFirstVist]); + // Detect if current model is custom (not in presets) and mirror it in the input field useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { @@ -317,10 +333,14 @@ export function SettingsModal() { const handleSignOut = async () => { - await markSignedOut(); const client = getAuthClient(authBaseUrl); + // Sign out of the authenticated account, then immediately start a fresh anonymous session. + // This avoids landing in a "no session" state that can be misinterpreted as a "Guest" user. await client.signOut(); - window.location.reload(); + await clearSignedOut(); + await client.signIn.anonymous(); + await refreshRateLimit(); + router.refresh(); }; const handleDeleteAccount = async () => { @@ -787,16 +807,28 @@ export function SettingsModal() {

Current Session

Logged in as:

-

{session?.user?.name || 'Guest'}

-

{session?.user?.email}

- {session?.user?.isAnonymous && ( -

Anonymous / Guest Account

+ {session?.user ? ( + <> +

+ {session.user.isAnonymous + ? 'Anonymous' + : (session.user.name || session.user.email || 'Account')} +

+ {!session.user.isAnonymous && ( +

{session.user.email}

+ )} + {session.user.isAnonymous && ( +

Anonymous session

+ )} + + ) : ( +

Not signed in

)}
- {!session?.user?.isAnonymous ? ( + {session?.user && !session.user.isAnonymous ? ( <>
) : (

- You are using a temporary guest account. Sign up to save your progress permanently. + You are using an anonymous session. Sign up to save your progress permanently.

diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 80877db..7685f35 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -1,14 +1,14 @@ 'use client'; import { useEffect, useState, ReactNode } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useAuthSession } from '@/hooks/useAuth'; import { getAuthClient } from '@/lib/auth-client'; -import { wasSignedOut } from '@/lib/session-utils'; import { LoadingSpinner } from '@/components/Spinner'; export function AuthLoader({ children }: { children: ReactNode }) { const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session, isPending } = useAuthSession(); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); @@ -31,13 +31,6 @@ export function AuthLoader({ children }: { children: ReactNode }) { return; } - // No session, check if explicitly signed out - const userWasSignedOut = await wasSignedOut(); - if (userWasSignedOut) { - setIsCheckingSignOut(false); // Render children unauthenticated - return; - } - // Not signed out, start auto-login setIsAutoLoggingIn(true); setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode @@ -45,6 +38,7 @@ export function AuthLoader({ children }: { children: ReactNode }) { try { const client = getAuthClient(baseUrl); await client.signIn.anonymous(); + await refreshRateLimit(); } catch (err) { console.error('Auto-login failed', err); } finally { @@ -53,7 +47,7 @@ export function AuthLoader({ children }: { children: ReactNode }) { }; checkStatus(); - }, [session, isPending, authEnabled, baseUrl]); + }, [session, isPending, authEnabled, baseUrl, refreshRateLimit]); // Show loader if: // 1. Auth client is initializing (isPending) AND auth is enabled diff --git a/src/components/auth/SessionManager.tsx b/src/components/auth/SessionManager.tsx deleted file mode 100644 index 1d70d6d..0000000 --- a/src/components/auth/SessionManager.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { useEffect, useRef } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; -import { useAuthSession } from '@/hooks/useAuth'; -import { getAuthClient } from '@/lib/auth-client'; -import { wasSignedOut } from '@/lib/session-utils'; - -export function SessionManager() { - const { authEnabled, baseUrl } = useAuthConfig(); - const { data: session, isPending } = useAuthSession(); - const attemptRef = useRef(false); - - useEffect(() => { - // Only run if auth is enabled - if (!authEnabled) return; - - // Wait for session check to complete - if (isPending) return; - - // If we have a session, we're good - if (session) return; - - // Prevent multiple attempts - if (attemptRef.current) return; - - const checkAndSignIn = async () => { - attemptRef.current = true; - try { - // Check if user explicitly signed out - const signedOut = await wasSignedOut(); - - // If not explicitly signed out, sign in anonymously - if (!signedOut) { - console.log('No session found, signing in anonymously...'); - const client = getAuthClient(baseUrl); - await client.signIn.anonymous(); - } - } catch (error) { - console.error('Error in session manager:', error); - } - }; - - checkAndSignIn(); - }, [session, isPending, authEnabled, baseUrl]); - - return null; -} diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index cfeec5d..6c0c6b0 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -2,23 +2,26 @@ import { Button } from '@headlessui/react'; import Link from 'next/link'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useAuthSession } from '@/hooks/useAuth'; import { getAuthClient } from '@/lib/auth-client'; -import { markSignedOut } from '@/lib/session-utils'; +import { clearSignedOut } from '@/lib/session-utils'; import { useRouter } from 'next/navigation'; export function UserMenu() { const { authEnabled, baseUrl } = useAuthConfig(); + const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session, isPending } = useAuthSession(); const router = useRouter(); if (!authEnabled || isPending) return null; const handleSignOut = async () => { - await markSignedOut(); const client = getAuthClient(baseUrl); await client.signOut(); + await clearSignedOut(); + await client.signIn.anonymous(); + await refreshRateLimit(); router.refresh(); }; @@ -43,7 +46,7 @@ export function UserMenu() {
- {session.user.name || 'Guest'} + {session.user.name || session.user.email || 'Account'} {session.user.email} diff --git a/src/components/player/RateLimitPauseButton.tsx b/src/components/player/RateLimitPauseButton.tsx new file mode 100644 index 0000000..3194ace --- /dev/null +++ b/src/components/player/RateLimitPauseButton.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { Button } from '@headlessui/react'; +import { PauseIcon } from '@/components/icons/Icons'; +import { useTTS } from '@/contexts/TTSContext'; + +export function RateLimitPauseButton() { + const { isPlaying, togglePlay } = useTTS(); + + // Only show while audio is actively playing. This avoids presenting a "play" affordance + // when the user is rate-limited. + if (!isPlaying) return null; + + return ( + + ); +} diff --git a/src/components/privacy-popup.tsx b/src/components/privacy-popup.tsx index 9775544..24e57cd 100644 --- a/src/components/privacy-popup.tsx +++ b/src/components/privacy-popup.tsx @@ -32,12 +32,15 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { const handleAccept = async () => { await updateAppConfig({ privacyAccepted: true }); setIsOpen(false); + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('openreader:privacyAccepted')); + } onAccept?.(); }; return ( - { }}> + { }}> -
-
-

- Daily TTS limit reached -

-

- {`You've used ${formatCharCount(status.currentCount)} of ${formatCharCount(status.limit)} characters today.`} +

+
+
+ + Daily TTS limit reached. + + + {`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`} {' Resets in '}{timeUntilReset}. -

+
{isAnonymous && ( - Sign up for 4x more + Sign up for a higher limit )}
@@ -51,17 +49,16 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { * Compact version for inline display */ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { - const { status, isAtLimit } = useRateLimit(); - const { authEnabled } = useAuthConfig(); + const { status, isAtLimit, authEnabled } = useAutoRateLimit(); // Don't show if auth is not enabled if (!authEnabled || !status?.authEnabled) { return null; } - const percentage = status.limit === Infinity - ? 0 - : Math.min(100, (status.currentCount / status.limit) * 100); + const percentage = status.limit > 0 + ? Math.min(100, (status.currentCount / status.limit) * 100) + : 0; const isWarning = percentage >= 80; diff --git a/src/contexts/AuthConfigContext.tsx b/src/contexts/AuthConfigContext.tsx deleted file mode 100644 index f95dd22..0000000 --- a/src/contexts/AuthConfigContext.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { createContext, useContext, ReactNode } from 'react'; - -interface AuthConfig { - authEnabled: boolean; - baseUrl: string | null; -} - -const AuthConfigContext = createContext({ - authEnabled: false, - baseUrl: null, -}); - -export function AuthConfigProvider({ - children, - authEnabled, - baseUrl, -}: { - children: ReactNode; - authEnabled: boolean; - baseUrl: string | null; -}) { - return ( - - {children} - - ); -} - -export function useAuthConfig() { - return useContext(AuthConfigContext); -} diff --git a/src/components/rate-limit-provider.tsx b/src/contexts/AutoRateLimitContext.tsx similarity index 65% rename from src/components/rate-limit-provider.tsx rename to src/contexts/AutoRateLimitContext.tsx index 6eb4581..1c871d2 100644 --- a/src/components/rate-limit-provider.tsx +++ b/src/contexts/AutoRateLimitContext.tsx @@ -1,7 +1,6 @@ 'use client'; -import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; export interface RateLimitStatus { allowed: boolean; @@ -13,7 +12,12 @@ export interface RateLimitStatus { authEnabled: boolean; } -export interface RateLimitContextType { +interface AutoRateLimitContextType { + // Auth Config + authEnabled: boolean; + authBaseUrl: string | null; + + // Rate Limit status: RateLimitStatus | null; loading: boolean; error: string | null; @@ -23,20 +27,27 @@ export interface RateLimitContextType { incrementCount: (charCount: number) => void; onTTSStart: () => void; onTTSComplete: () => void; + triggerRateLimit: () => void; } -const RateLimitContext = createContext(null); +const AutoRateLimitContext = createContext(null); -export function useRateLimit(): RateLimitContextType { - const context = useContext(RateLimitContext); +export function useAutoRateLimit(): AutoRateLimitContextType { + const context = useContext(AutoRateLimitContext); if (!context) { - throw new Error('useRateLimit must be used within a RateLimitProvider'); + throw new Error('useAutoRateLimit must be used within a AutoRateLimitProvider'); } return context; } -interface RateLimitProviderProps { - children: React.ReactNode; +// Re-export specific hooks for backward compatibility or convenience if needed +export function useAuthConfig() { + const { authEnabled, authBaseUrl } = useAutoRateLimit(); + return { authEnabled, baseUrl: authBaseUrl }; +} + +export function useRateLimit() { + return useAutoRateLimit(); } function calculateTimeUntilReset(resetTime: Date): string { @@ -57,22 +68,30 @@ function calculateTimeUntilReset(resetTime: Date): string { } } -function formatCharCount(count: number): string { +export function formatCharCount(count: number): string { if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(1)}M`; + const m = count / 1_000_000; + // Show up to 1 decimal place, stripping trailing zeros (1.0 -> 1) + return `${parseFloat(m.toFixed(1))}M`; } else if (count >= 1_000) { - return `${(count / 1_000).toFixed(0)}K`; + const k = Math.round(count / 1_000); + // Handle edge case where rounding up reaches 1M (e.g., 999,999 -> 1000K -> 1M) + if (k >= 1_000) return '1M'; + return `${k}K`; } return count.toString(); } -export { formatCharCount }; +interface AutoRateLimitProviderProps { + children: ReactNode; + authEnabled: boolean; + authBaseUrl: string | null; +} -export function RateLimitProvider({ children }: RateLimitProviderProps) { +export function AutoRateLimitProvider({ children, authEnabled, authBaseUrl }: AutoRateLimitProviderProps) { const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { authEnabled } = useAuthConfig(); // Track pending TTS operations to delay count updates const pendingTTSRef = useRef(0); @@ -81,15 +100,17 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { const fetchStatus = useCallback(async () => { // Skip if auth is not enabled if (!authEnabled) { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); setStatus({ allowed: true, currentCount: 0, - limit: Infinity, - remainingChars: Infinity, + // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. + limit: Number.MAX_SAFE_INTEGER, + remainingChars: Number.MAX_SAFE_INTEGER, resetTime: tomorrow, userType: 'unauthenticated', authEnabled: false @@ -128,7 +149,9 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { // Calculate time until reset const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : ''; - const isAtLimit = status ? status.remainingChars <= 0 : false; + // Only treat the user as "at limit" when they are truly out of characters. + // The server allows the final request that may cross the limit, then blocks subsequent ones. + const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; // Increment count locally (for immediate UI feedback) const incrementCount = useCallback((charCount: number) => { @@ -186,7 +209,9 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { }; }, []); - const contextValue: RateLimitContextType = { + const contextValue: AutoRateLimitContextType = { + authEnabled, + authBaseUrl, status, loading, error, @@ -195,12 +220,13 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) { timeUntilReset, incrementCount, onTTSStart, - onTTSComplete + onTTSComplete, + triggerRateLimit: () => setStatus(prev => prev ? { ...prev, remainingChars: 0, allowed: false } : null) }; return ( - + {children} - + ); -} \ No newline at end of file +} diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index 1644c77..719ee57 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -39,6 +39,7 @@ import { useBackgroundState } from '@/hooks/audio/useBackgroundState'; import { withRetry, generateTTS, alignAudio } from '@/lib/client'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; import { isKokoroModel } from '@/utils/voice'; +import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import type { TTSLocation, TTSSmartMergeResult, @@ -298,6 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const audioContext = useAudioContext(); const audioCache = useAudioCache(25); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); + const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit } = useAutoRateLimit(); // Add ref for location change handler const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); @@ -849,12 +851,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement backoffFactor: 2 }; - const arrayBuffer = await withRetry( - async () => { - return await generateTTS(reqBody, reqHeaders, controller.signal); - }, - retryOptions - ); + onTTSStart(); + let arrayBuffer: TTSAudioBuffer; + try { + arrayBuffer = await withRetry( + async () => { + return await generateTTS(reqBody, reqHeaders, controller.signal); + }, + retryOptions + ); + } finally { + onTTSComplete(); + } // Remove the controller once the request is complete activeAbortControllers.current.delete(controller); @@ -867,6 +875,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return arrayBuffer; } catch (error) { + const status = (() => { + if (typeof error === 'object' && error !== null && 'status' in error) { + const maybe = (error as { status?: unknown }).status; + return typeof maybe === 'number' ? maybe : undefined; + } + return undefined; + })(); + + const code = (() => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const maybe = (error as { code?: unknown }).code; + return typeof maybe === 'string' ? maybe : undefined; + } + return undefined; + })(); + // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { console.log('TTS request aborted:', sentence.substring(0, 20)); @@ -874,6 +898,23 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } setIsPlaying(false); + + // Handle daily quota exceeded (429 + Problem Details code) + if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { + toast.error('Daily TTS limit reached.', { + id: 'tts-limit-error', + style: { + background: 'var(--background)', + color: 'var(--accent)', + }, + duration: 5000, + }); + triggerRateLimit(); + refreshRateLimit().catch(console.error); + // Do NOT re-throw, just return undefined to stop playback gracefully + return undefined; + } + toast.error('Failed to generate audio. Server not responding.', { id: 'tts-api-error', style: { @@ -897,7 +938,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement pdfHighlightEnabled, pdfWordHighlightEnabled, epubHighlightEnabled, - epubWordHighlightEnabled + epubWordHighlightEnabled, + triggerRateLimit, + refreshRateLimit, + onTTSComplete, + onTTSStart ]); /** @@ -929,7 +974,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement const processPromise = (async () => { try { const audioBuffer = await getAudio(sentence); - if (!audioBuffer) throw new Error('No audio data generated'); + if (!audioBuffer) { + // If quota or other handled error returns undefined, ensure we don't throw "No audio data" + // Just return empty string to signal graceful failure/skip + return ''; + } // Convert to base64 data URI const bytes = new Uint8Array(audioBuffer); @@ -974,7 +1023,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement // Get the processed audio data URI directly from processSentence const audioDataUri = await processSentence(sentence); if (!audioDataUri) { - throw new Error('No audio data generated'); + // Graceful exit for rate limit or skipped sentence + console.log('Skipping playback for sentence (no audio generated)'); + return null; } // Force unload any previous Howl instance to free up resources @@ -1201,9 +1252,26 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement ); if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) { - // Start preloading but don't wait for it to complete + // Start preloading but don't wait for it to complete processSentence(nextSentence, true).catch(error => { - console.error('Error preloading next sentence:', error); + const status = (() => { + if (typeof error === 'object' && error !== null && 'status' in error) { + const maybe = (error as { status?: unknown }).status; + return typeof maybe === 'number' ? maybe : undefined; + } + return undefined; + })(); + const code = (() => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const maybe = (error as { code?: unknown }).code; + return typeof maybe === 'string' ? maybe : undefined; + } + return undefined; + })(); + // Ignore quota errors during preload + if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) { + console.error('Error preloading next sentence:', error); + } }); } } diff --git a/src/hooks/pdf/usePDFResize.ts b/src/hooks/pdf/usePDFResize.ts index d495d4c..8bfa9ad 100644 --- a/src/hooks/pdf/usePDFResize.ts +++ b/src/hooks/pdf/usePDFResize.ts @@ -3,6 +3,7 @@ import { debounce } from '@/lib/pdf'; interface UsePDFResizeResult { containerWidth: number; + containerHeight: number; setContainerWidth: (width: number) => void; } @@ -10,6 +11,7 @@ export function usePDFResize( containerRef: RefObject ): UsePDFResizeResult { const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); useEffect(() => { if (!containerRef.current) return; @@ -18,11 +20,16 @@ export function usePDFResize( setContainerWidth(Number(width)); }, 150); + const debouncedResizeHeight = debounce((height: unknown) => { + setContainerHeight(Number(height)); + }, 150); + const observer = new ResizeObserver(entries => { const width = entries[0]?.contentRect.width; - if (width) { - debouncedResize(width); - } + const height = entries[0]?.contentRect.height; + + if (width) debouncedResize(width); + if (height) debouncedResizeHeight(height); }); observer.observe(containerRef.current); @@ -31,5 +38,5 @@ export function usePDFResize( }; }, [containerRef]); - return { containerWidth, setContainerWidth }; + return { containerWidth, containerHeight, setContainerWidth }; } \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 2f18287..3eae80e 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,7 +1,7 @@ 'use client'; import { useMemo } from 'react'; -import { useAuthConfig } from '@/contexts/AuthConfigContext'; +import { useAuthConfig } from '@/contexts/AutoRateLimitContext'; import { getAuthClient } from '@/lib/auth-client'; /** diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index cabc47a..5fc62ed 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -12,24 +12,22 @@ function createAuthClientWithUrl(baseUrl: string) { // Cache for auth client instances by baseUrl const clientCache = new Map>(); +/** + * Factory function to get auth client with specific baseUrl. + * In most cases, you should use the useAuth() hook instead of calling this directly. + * @param baseUrl - The auth server base URL. If null, will throw an error. + */ export function getAuthClient(baseUrl: string | null) { - const effectiveUrl = baseUrl || "http://localhost:3003"; - - if (!clientCache.has(effectiveUrl)) { - clientCache.set(effectiveUrl, createAuthClientWithUrl(effectiveUrl)); + if (!baseUrl) { + throw new Error( + 'Cannot create auth client without baseUrl. ' + + 'Use the useAuth() hook in components to get the properly configured client.' + ); } - return clientCache.get(effectiveUrl)!; -} + if (!clientCache.has(baseUrl)) { + clientCache.set(baseUrl, createAuthClientWithUrl(baseUrl)); + } -// Default client for backwards compatibility (will use localhost in dev) -// Components should prefer useAuth() hook which gets baseUrl from context -export const authClient = getAuthClient(null); - -export const { - signIn, - signUp, - signOut, - useSession, - getSession -} = authClient; \ No newline at end of file + return clientCache.get(baseUrl)!; +} \ No newline at end of file diff --git a/src/lib/client.ts b/src/lib/client.ts index 4481f36..032cdc1 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -2,6 +2,7 @@ import type { TTSRequestPayload, TTSRequestHeaders, TTSRetryOptions, + TTSRequestError, AudiobookStatusResponse, CreateChapterPayload, VoicesResponse, @@ -28,7 +29,7 @@ export const withRetry = async ( } = options; let lastError: Error | null = null; - + for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await operation(); @@ -41,6 +42,26 @@ export const withRetry = async ( break; } + // Do not retry on payment required / rate limit exceeded (user quota) + const status = (() => { + if (typeof error === 'object' && error !== null && 'status' in error) { + const maybe = (error as { status?: unknown }).status; + return typeof maybe === 'number' ? maybe : undefined; + } + return undefined; + })(); + const code = (() => { + if (typeof error === 'object' && error !== null && 'code' in error) { + const maybe = (error as { code?: unknown }).code; + return typeof maybe === 'string' ? maybe : undefined; + } + return undefined; + })(); + // Do not retry on user quota exceeded (server tells us when to retry via resetTime/Retry-After) + if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { + break; + } + if (attempt === maxRetries - 1) { break; } @@ -49,7 +70,7 @@ export const withRetry = async ( initialDelay * Math.pow(backoffFactor, attempt), maxDelay ); - + console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`); await new Promise(resolve => setTimeout(resolve, delay)); } @@ -165,7 +186,7 @@ export const getVoices = async (headers: HeadersInit): Promise = const response = await fetch('/api/tts/voices', { headers, }); - + if (!response.ok) throw new Error('Failed to fetch voices'); return await response.json(); }; @@ -183,7 +204,33 @@ export const generateTTS = async ( }); if (!response.ok) { - throw new Error(`TTS processing failed with status ${response.status}`); + let problem: unknown = undefined; + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/problem+json') || contentType.includes('application/json')) { + try { + problem = await response.json(); + } catch { + // ignore JSON parse errors + } + } + + const err = new Error(`TTS processing failed with status ${response.status}`) as TTSRequestError; + err.status = response.status; + + if (typeof problem === 'object' && problem !== null) { + const rec = problem as Record; + if (typeof rec.code === 'string') err.code = rec.code; + if (typeof rec.type === 'string') err.type = rec.type; + if (typeof rec.title === 'string') err.title = rec.title; + if (typeof rec.detail === 'string') err.detail = rec.detail; + } + + // Avoid noisy logs for expected user quota failures + if (!(err.status === 429 && err.code === 'USER_DAILY_QUOTA_EXCEEDED')) { + console.error(`TTS request failed: ${response.status}`, err.code ? { code: err.code } : undefined); + } + + throw err; } const buffer = await response.arrayBuffer(); diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts index 6f157cc..571e469 100644 --- a/src/lib/pdf.ts +++ b/src/lib/pdf.ts @@ -304,6 +304,8 @@ export async function extractTextFromPDF( } // Highlighting functions +let highlightPatternSeq = 0; + export function clearHighlights() { const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); textNodes.forEach((node) => { @@ -343,6 +345,7 @@ export function highlightPattern( pattern: string, containerRef: React.RefObject ) { + const seq = ++highlightPatternSeq; clearHighlights(); if (!pattern?.trim()) return; @@ -531,6 +534,7 @@ export function highlightPattern( // Fire-and-forget async worker call; UI thread returns immediately runHighlightTokenMatch(cleanPattern, tokenTexts) .then((result) => { + if (seq !== highlightPatternSeq) return; if (!result || result.bestStart === -1) { // No worker result or no good match; nothing to highlight applyHighlightFromTokens(null); @@ -544,6 +548,7 @@ export function highlightPattern( } }) .catch((error) => { + if (seq !== highlightPatternSeq) return; console.error( 'Error in PDF highlight worker; no highlights applied:', error diff --git a/src/lib/server/db-adapter.ts b/src/lib/server/db-adapter.ts index 8b6d11f..579b66d 100644 --- a/src/lib/server/db-adapter.ts +++ b/src/lib/server/db-adapter.ts @@ -84,13 +84,31 @@ export class SqliteAdapter implements DBAdapter { } async query(text: string, params?: unknown[]) { - // simple heuristic to convert Postgres $n params to SQLite ? - // This assumes we aren't using $n inside string literals. - const convertedSql = text.replace(/\$\d+/g, "?"); + // Convert Postgres $n params to SQLite placeholders. + // We separately reorder params to match placeholder order. + const convertedSql = text.replace(/\$(\d+)/g, "?"); + + // Reorder params array to match the order they appear in the SQL + // SQLite expects params in the order they appear, not by their $n number + const orderedParams: unknown[] = []; + if (params && params.length > 0) { + // Extract parameter numbers in order of appearance + const paramNumbers: number[] = []; + const regex = /\$(\d+)/g; + let match; + while ((match = regex.exec(text)) !== null) { + paramNumbers.push(parseInt(match[1], 10)); + } + + // Map to actual values + for (const num of paramNumbers) { + orderedParams.push(params[num - 1]); // $1 maps to params[0] + } + } try { const stmt = this.db.prepare(convertedSql); - const safeParams = params || []; + const safeParams = orderedParams.length > 0 ? orderedParams : []; const lowerSql = convertedSql.trim().toLowerCase(); diff --git a/src/lib/server/rate-limiter.ts b/src/lib/server/rate-limiter.ts index 4876cb8..00acd4c 100644 --- a/src/lib/server/rate-limiter.ts +++ b/src/lib/server/rate-limiter.ts @@ -3,32 +3,44 @@ import { isAuthEnabled } from '@/lib/server/auth-config'; // Rate limits configuration - character counts per day export const RATE_LIMITS = { - ANONYMOUS: 250_000, // 250K characters per day for anonymous users - AUTHENTICATED: 1_000_000 // 1M characters per day for authenticated users + ANONYMOUS: 50_000, // 50K characters per day for anonymous users + AUTHENTICATED: 500_000 // 500K characters per day for authenticated users } as const; -// Initialize rate limiting table -export async function initializeRateLimitTable() { - // Use transaction to ensure safe initialization - await db.transaction(async (client) => { - // Check if table exists first to avoid errors on some DBs - // Simple create table if not exists - await client.query(` - CREATE TABLE IF NOT EXISTS user_tts_chars ( - user_id VARCHAR(255) NOT NULL, - date DATE NOT NULL, - char_count BIGINT DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, date) - ) - `); +// Singleton flag to ensure we only initialize the table once per process +let tableInitialized: Promise | null = null; - // Create index for faster queries - await client.query(` - CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date) - `); - }); +// Initialize rate limiting table (cached, runs only once per process) +export async function initializeRateLimitTable() { + if (tableInitialized) { + return tableInitialized; + } + + tableInitialized = (async () => { + // Use transaction to ensure safe initialization + await db.transaction(async (client) => { + // Check if table exists first to avoid errors on some DBs + // Simple create table if not exists + await client.query(` + CREATE TABLE IF NOT EXISTS user_tts_chars ( + user_id VARCHAR(255) NOT NULL, + date DATE NOT NULL, + char_count BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, date) + ) + `); + + // Create index for faster queries + await client.query(` + CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date) + `); + }); + console.log('Rate limit table initialized'); + })(); + + return tableInitialized; } export interface RateLimitResult { @@ -62,9 +74,9 @@ export class RateLimiter { return { allowed: true, currentCount: 0, - limit: Infinity, + limit: Number.MAX_SAFE_INTEGER, resetTime: this.getResetTime(), - remainingChars: Infinity + remainingChars: Number.MAX_SAFE_INTEGER }; } @@ -87,16 +99,24 @@ export class RateLimiter { DO UPDATE SET updated_at = CURRENT_TIMESTAMP `, [user.id, today]); - // Get current count + // Allow the request that crosses the limit, but block any requests once the user is + // already at/over the limit. Do this atomically to avoid concurrent over-limit requests. + const updateResult = await client.query(` + UPDATE user_tts_chars + SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 AND date = $2 AND char_count < $4 + `, [user.id, today, charCount, limit]); + + // Get current count after the attempted update (works for both success and failure) const result = await client.query(` - SELECT char_count FROM user_tts_chars + SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2 `, [user.id, today]); const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10); + const updated = (updateResult.rowCount ?? 0) > 0; - // Check if adding these chars would exceed the limit - if (currentCount + charCount > limit) { + if (!updated) { return { allowed: false, currentCount, @@ -106,21 +126,12 @@ export class RateLimiter { }; } - // Increment the count - await client.query(` - UPDATE user_tts_chars - SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP - WHERE user_id = $1 AND date = $2 - `, [user.id, today, charCount]); - - const newCount = currentCount + charCount; - return { allowed: true, - currentCount: newCount, + currentCount, limit, resetTime: this.getResetTime(), - remainingChars: Math.max(0, limit - newCount) + remainingChars: Math.max(0, limit - currentCount) }; }); } @@ -134,9 +145,9 @@ export class RateLimiter { return { allowed: true, currentCount: 0, - limit: Infinity, + limit: Number.MAX_SAFE_INTEGER, resetTime: this.getResetTime(), - remainingChars: Infinity + remainingChars: Number.MAX_SAFE_INTEGER }; } @@ -219,9 +230,10 @@ export class RateLimiter { } private getResetTime(): Date { - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); // Start of next day + const now = new Date(); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); // Start of next day in UTC return tomorrow; } } diff --git a/src/types/client.ts b/src/types/client.ts index 32a3b36..7c741ec 100644 --- a/src/types/client.ts +++ b/src/types/client.ts @@ -31,6 +31,14 @@ export interface TTSRetryOptions { backoffFactor?: number; } +export interface TTSRequestError extends Error { + status?: number; + code?: string; + type?: string; + title?: string; + detail?: string; +} + // --- Audiobook API Types --- export interface AudiobookStatusResponse { diff --git a/tests/helpers.ts b/tests/helpers.ts index ab1e8bf..a7c0039 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -34,8 +34,21 @@ function escapeRegExp(input: string) { * Upload a sample epub or pdf */ export async function uploadFile(page: Page, filePath: string) { - await page.waitForSelector('input[type=file]', { timeout: 10000 }); - await page.setInputFiles('input[type=file]', `${DIR}${filePath}`); + const input = page.locator('input[type=file]').first(); + await expect(input).toBeVisible({ timeout: 10000 }); + await expect(input).toBeEnabled({ timeout: 10000 }); + + await input.setInputFiles(`${DIR}${filePath}`); + + // Wait for the uploader to finish processing. The input is disabled while + // uploading/converting via react-dropzone's `disabled` prop. + // Tolerate extremely fast operations where the disabled state may be missed. + try { + await expect(input).toBeDisabled({ timeout: 2000 }); + } catch { + // ignore + } + await expect(input).toBeEnabled({ timeout: 15000 }); } /** @@ -110,6 +123,19 @@ export async function setupTest(page: Page) { await page.goto('/'); await page.waitForLoadState('networkidle'); + // Privacy modal should come first in onboarding. + // Be tolerant if it's already accepted (e.g., reused context). + const privacyBtn = page.getByRole('button', { name: 'I Understand' }); + try { + await expect(privacyBtn).toBeVisible({ timeout: 5000 }); + await privacyBtn.click(); + } catch { + // ignore + } + + // Settings modal should appear after privacy acceptance on first visit. + await expect(page.getByRole('button', { name: 'Save' })).toBeVisible({ timeout: 10000 }); + // If running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider if (process.env.CI) { await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click(); @@ -118,8 +144,6 @@ export async function setupTest(page: Page) { // Click the "done" button to dismiss the welcome message await page.getByRole('button', { name: 'Save' }).click(); - - await page.getByRole('button', { name: 'I Understand' }).click(); }