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(); }