refactor(auth): consolidate authentication contexts and enhance rate limit UI integration

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)
This commit is contained in:
Richard R 2026-01-25 11:12:19 -07:00
parent 9ba20c8a9e
commit 4689f3676f
29 changed files with 641 additions and 340 deletions

View file

@ -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,

View file

@ -35,6 +35,21 @@ type InflightEntry = {
const inflightRequests = new Map<string, InflightEntry>();
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<string, unknown>;
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',

View file

@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@ -178,12 +182,13 @@ export default function EPUBPage() {
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
<EPUBViewer className="h-full" />
</div>
)}
@ -198,7 +203,16 @@ export default function EPUBPage() {
onRegenerateChapter={handleRegenerateChapter}
/>
)}
<TTSPlayer />
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>
</div>
) : (
<TTSPlayer />
)}
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -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<string | null>(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)
<RateLimitPauseButton />
useEffect(() => {
const compute = () => {
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
@ -86,7 +91,7 @@ export default function HTMLPage() {
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => {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"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -140,12 +145,20 @@ export default function HTMLPage() {
<DocumentSkeleton />
</div>
) : (
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct)/100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct)/100))}px` }}>
<div className="h-full w-full" style={{ paddingLeft: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px`, paddingRight: `${Math.round(maxPadPx * ((100 - padPct) / 100))}px` }}>
<HTMLViewer className="h-full" />
</div>
)}
</div>
<TTSPlayer />
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitBanner />
</div>
</div>
) : (
<TTSPlayer />
)}
<DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -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 (
<AuthLoader>
<div className="flex flex-col h-full w-full">
<SettingsModal />
<UserMenu />
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
</p>
</div>
</section>
<section className="flex-1 px-4 pb-8 overflow-auto">
<div className="max-w-7xl mx-auto">
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent />
</div>
</section>
</div>
</AuthLoader>
<div className="flex flex-col h-full w-full">
<SettingsModal />
<AuthLoader>
<>
<UserMenu />
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
</p>
</div>
</section>
<section className="flex-1 px-4 pb-8 overflow-auto">
<div className="max-w-7xl mx-auto">
<RateLimitBanner className="mb-6" />
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
<HomeContent />
</div>
</section>
</>
</AuthLoader>
</div>
);
}

View file

@ -15,7 +15,10 @@ 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;
@ -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<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100);
@ -114,7 +118,7 @@ export default function PDFViewerPage() {
<p className="text-red-500 mb-4">{error}</p>
<Link
href="/"
onClick={() => {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"
>
<svg className="w-4 h-4 mr-2 text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -167,6 +171,7 @@ export default function PDFViewerPage() {
}
/>
<div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? (
<div className="p-4">
<DocumentSkeleton />
@ -185,7 +190,16 @@ export default function PDFViewerPage() {
onRegenerateChapter={handleRegenerateChapter}
/>
)}
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>
</div>
) : (
<TTSPlayer currentPage={currDocPage} numPages={currDocPages} />
)}
<DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);

View file

@ -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 = (
<ThemeProvider>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
{children}
<PrivacyPopup />
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</ThemeProvider>
);
// Wrap with RateLimitProvider only when auth is enabled
const wrappedContent = authEnabled ? (
<RateLimitProvider>{content}</RateLimitProvider>
) : content;
return (
<AuthConfigProvider authEnabled={authEnabled} baseUrl={authBaseUrl}>
{wrappedContent}
</AuthConfigProvider>
<AutoRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<ThemeProvider>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
{children}
<PrivacyPopup />
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</ThemeProvider>
</AutoRateLimitProvider>
);
}

View file

@ -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<string | null>(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);

View file

@ -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<string | null>(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('/');
}

View file

@ -23,7 +23,7 @@ interface PDFOnLinkClickArgs {
export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const scaleRef = useRef<number>(1);
const { containerWidth } = usePDFResize(containerRef);
const { containerWidth, containerHeight } = usePDFResize(containerRef);
const sentenceHighlightSeqRef = useRef(0);
const wordHighlightSeqRef = useRef(0);
const sentenceHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@ -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(() => {

View file

@ -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() {
<h4 className="font-medium text-foreground">Current Session</h4>
<div className="text-sm space-y-1">
<p className="text-muted">Logged in as:</p>
<p className="font-medium text-foreground">{session?.user?.name || 'Guest'}</p>
<p className="text-xs text-muted font-mono">{session?.user?.email}</p>
{session?.user?.isAnonymous && (
<p className="text-xs text-accent mt-1">Anonymous / Guest Account</p>
{session?.user ? (
<>
<p className="font-medium text-foreground">
{session.user.isAnonymous
? 'Anonymous'
: (session.user.name || session.user.email || 'Account')}
</p>
{!session.user.isAnonymous && (
<p className="text-xs text-muted font-mono">{session.user.email}</p>
)}
{session.user.isAnonymous && (
<p className="text-xs text-accent mt-1">Anonymous session</p>
)}
</>
) : (
<p className="font-medium text-foreground">Not signed in</p>
)}
</div>
</div>
<div className="space-y-3 pt-2">
{!session?.user?.isAnonymous ? (
{session?.user && !session.user.isAnonymous ? (
<>
<Button
onClick={handleSignOut}
@ -820,14 +852,14 @@ export function SettingsModal() {
Delete Account
</Button>
<p className="text-xs text-muted mt-2 text-center">
This will permanently delete your account and data. You will be returned to a fresh guest session.
This will permanently delete your account and data. You will be returned to a fresh anonymous session.
</p>
</div>
</>
) : (
<div className="pt-2 border-t border-offbase">
<p className="text-sm text-muted mb-3">
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.
</p>
<div className="grid grid-cols-2 gap-3">
<Link href="/signin" className="w-full">

View file

@ -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

View file

@ -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;
}

View file

@ -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() {
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex items-center gap-3">
<div className="hidden sm:flex flex-col items-end">
<span className="text-xs font-medium text-foreground">
{session.user.name || 'Guest'}
{session.user.name || session.user.email || 'Account'}
</span>
<span className="text-[10px] text-muted truncate max-w-[120px]">
{session.user.email}

View file

@ -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 (
<Button
onClick={() => {
if (isPlaying) togglePlay();
}}
className="relative p-1.5 rounded-md text-foreground hover:bg-offbase transition-all duration-200 focus:outline-none h-8 w-8 flex items-center justify-center transform ease-in-out hover:scale-[1.09] hover:text-accent"
aria-label="Pause"
>
<PauseIcon className="w-5 h-5" />
</Button>
);
}

View file

@ -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 (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => { }}>
<Dialog as="div" className="relative z-[60]" onClose={() => { }}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"

View file

@ -1,7 +1,6 @@
'use client';
import { useRateLimit, formatCharCount } from '@/components/rate-limit-provider';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import { useAutoRateLimit, formatCharCount } from '@/contexts/AutoRateLimitContext';
import Link from 'next/link';
interface RateLimitBannerProps {
@ -9,8 +8,7 @@ interface RateLimitBannerProps {
}
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit, timeUntilReset } = useRateLimit();
const { authEnabled } = useAuthConfig();
const { status, isAtLimit, timeUntilReset, authEnabled } = useAutoRateLimit();
// Don't show banner if auth is not enabled or if not at limit
if (!authEnabled || !status?.authEnabled || !isAtLimit) {
@ -20,26 +18,26 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const isAnonymous = status.userType === 'anonymous';
return (
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg p-4 ${className}`}>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<div className="flex-1">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400">
Daily TTS limit reached
</p>
<p className="text-xs text-amber-600 dark:text-amber-500 mt-1">
{`You've used ${formatCharCount(status.currentCount)} of ${formatCharCount(status.limit)} characters today.`}
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 ${className}`}>
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="text-xs sm:text-sm">
<span className="font-medium text-amber-700 dark:text-amber-400">
Daily TTS limit reached.
</span>
<span className="text-amber-600 dark:text-amber-500 ml-1.5">
{`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`}
{' Resets in '}{timeUntilReset}.
</p>
</span>
</div>
{isAnonymous && (
<Link
href="/signup"
className="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-lg
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md
bg-accent text-background hover:bg-secondary-accent
transform transition-transform duration-200 hover:scale-[1.04]"
>
Sign up for 4x more
Sign up for a higher limit
</Link>
)}
</div>
@ -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;

View file

@ -1,33 +0,0 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
interface AuthConfig {
authEnabled: boolean;
baseUrl: string | null;
}
const AuthConfigContext = createContext<AuthConfig>({
authEnabled: false,
baseUrl: null,
});
export function AuthConfigProvider({
children,
authEnabled,
baseUrl,
}: {
children: ReactNode;
authEnabled: boolean;
baseUrl: string | null;
}) {
return (
<AuthConfigContext.Provider value={{ authEnabled, baseUrl }}>
{children}
</AuthConfigContext.Provider>
);
}
export function useAuthConfig() {
return useContext(AuthConfigContext);
}

View file

@ -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<RateLimitContextType | null>(null);
const AutoRateLimitContext = createContext<AutoRateLimitContextType | null>(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<RateLimitStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { authEnabled } = useAuthConfig();
// Track pending TTS operations to delay count updates
const pendingTTSRef = useRef<number>(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 (
<RateLimitContext.Provider value={contextValue}>
<AutoRateLimitContext.Provider value={contextValue}>
{children}
</RateLimitContext.Provider>
</AutoRateLimitContext.Provider>
);
}

View file

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

View file

@ -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<HTMLDivElement | null>
): UsePDFResizeResult {
const [containerWidth, setContainerWidth] = useState<number>(0);
const [containerHeight, setContainerHeight] = useState<number>(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 };
}

View file

@ -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';
/**

View file

@ -12,24 +12,22 @@ function createAuthClientWithUrl(baseUrl: string) {
// Cache for auth client instances by baseUrl
const clientCache = new Map<string, ReturnType<typeof createAuthClientWithUrl>>();
/**
* 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));
}
return clientCache.get(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;

View file

@ -2,6 +2,7 @@ import type {
TTSRequestPayload,
TTSRequestHeaders,
TTSRetryOptions,
TTSRequestError,
AudiobookStatusResponse,
CreateChapterPayload,
VoicesResponse,
@ -41,6 +42,26 @@ export const withRetry = async <T>(
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;
}
@ -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<string, unknown>;
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();

View file

@ -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<HTMLDivElement>
) {
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

View file

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

View file

@ -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<void> | 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
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;
}
}

View file

@ -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 {

View file

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