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'; 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() { export async function GET() {
try { try {
// If auth is not enabled, return unlimited status // If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) { if (!isAuthEnabled() || !auth) {
const tomorrow = new Date(); const resetTime = getUtcResetTimeIso();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return NextResponse.json({ return NextResponse.json({
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
limit: Infinity, // Avoid Infinity in JSON (serializes to null). This value is never shown
remainingChars: Infinity, // because authEnabled=false, but we keep it finite to prevent surprises.
resetTime: tomorrow.toISOString(), limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
resetTime,
userType: 'unauthenticated', userType: 'unauthenticated',
authEnabled: false authEnabled: false
}); });
@ -32,22 +39,19 @@ export async function GET() {
// No session means unauthenticated // No session means unauthenticated
if (!session?.user) { if (!session?.user) {
const tomorrow = new Date(); const resetTime = getUtcResetTimeIso();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return NextResponse.json({ return NextResponse.json({
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
limit: RATE_LIMITS.ANONYMOUS, limit: RATE_LIMITS.ANONYMOUS,
remainingChars: RATE_LIMITS.ANONYMOUS, remainingChars: RATE_LIMITS.ANONYMOUS,
resetTime: tomorrow.toISOString(), resetTime,
userType: 'unauthenticated', userType: 'unauthenticated',
authEnabled: true authEnabled: true
}); });
} }
const isAnonymous = !session.user.email || session.user.email === ''; const isAnonymous = Boolean(session.user.isAnonymous);
const result = await rateLimiter.getCurrentUsage({ const result = await rateLimiter.getCurrentUsage({
id: session.user.id, id: session.user.id,

View file

@ -35,6 +35,21 @@ type InflightEntry = {
const inflightRequests = new Map<string, 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) { function sleep(ms: number) {
return new Promise((res) => setTimeout(res, ms)); return new Promise((res) => setTimeout(res, ms));
} }
@ -100,7 +115,18 @@ function makeCacheKey(input: {
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex'); 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) { export async function POST(req: NextRequest) {
let providerForError: string | null = null;
try { try {
// Parse body first to get text for rate limiting // Parse body first to get text for rate limiting
const body = (await req.json()) as TTSRequestPayload; 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; const charCount = text.length;
// Check rate limit // Check rate limit
@ -137,21 +163,36 @@ export async function POST(req: NextRequest) {
); );
if (!rateLimitResult.allowed) { if (!rateLimitResult.allowed) {
return NextResponse.json( const resetTime = rateLimitResult.resetTime.toISOString();
{ const retryAfterSeconds = Math.max(
code: 'RATE_LIMIT_EXCEEDED', 0,
message: 'Daily character limit exceeded', Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000)
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 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 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 openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai'; 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) }); 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 // 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; 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 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); console.warn('Error generating TTS:', error);
const errorBody: TTSError = { const errorBody: TTSError = {
code: 'TTS_GENERATION_FAILED', code: 'TTS_GENERATION_FAILED',

View file

@ -11,12 +11,15 @@ import { SettingsIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext"; import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { ZoomControl } from '@/components/ZoomControl'; import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons'; import { DownloadIcon } from '@/components/icons/Icons';
import type { TTSAudiobookChapter } from '@/types/tts'; import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/dexie'; 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; 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 router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB(); const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { stop } = useTTS(); const { stop } = useTTS();
const { isAtLimit } = useAutoRateLimit();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@ -178,12 +182,13 @@ export default function EPUBPage() {
} }
/> />
<div className="overflow-hidden" style={{ height: containerHeight }}> <div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? ( {isLoading ? (
<div className="p-4"> <div className="p-4">
<DocumentSkeleton /> <DocumentSkeleton />
</div> </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" /> <EPUBViewer className="h-full" />
</div> </div>
)} )}
@ -198,7 +203,16 @@ export default function EPUBPage() {
onRegenerateChapter={handleRegenerateChapter} 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} /> <DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</> </>
); );

View file

@ -7,18 +7,22 @@ import { useHTML } from '@/contexts/HTMLContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { HTMLViewer } from '@/components/HTMLViewer'; import { HTMLViewer } from '@/components/HTMLViewer';
import { DocumentSettings } from '@/components/DocumentSettings'; import { DocumentSettings } from '@/components/DocumentSettings';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { SettingsIcon } from '@/components/icons/Icons'; import { SettingsIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header'; import { Header } from '@/components/Header';
import { useTTS } from "@/contexts/TTSContext"; import { useTTS } from "@/contexts/TTSContext";
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl'; import { ZoomControl } from '@/components/ZoomControl';
import { resolveDocumentId } from '@/lib/dexie'; import { resolveDocumentId } from '@/lib/dexie';
import { RateLimitBanner } from '@/components/rate-limit-banner';
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
export default function HTMLPage() { export default function HTMLPage() {
const { id } = useParams(); const { id } = useParams();
const router = useRouter(); const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML(); const { setCurrentDocument, currDocName, clearCurrDoc } = useHTML();
const { stop } = useTTS(); const { stop } = useTTS();
const { isAtLimit } = useAutoRateLimit();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false);
@ -58,6 +62,7 @@ export default function HTMLPage() {
}, [loadDocument]); }, [loadDocument]);
// Compute available height = viewport - (header height + tts bar height) // Compute available height = viewport - (header height + tts bar height)
<RateLimitPauseButton />
useEffect(() => { useEffect(() => {
const compute = () => { const compute = () => {
const header = document.querySelector('[data-app-header]') as HTMLElement | null; 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> <p className="text-red-500 mb-4">{error}</p>
<Link <Link
href="/" 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" 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"> <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 /> <DocumentSkeleton />
</div> </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" /> <HTMLViewer className="h-full" />
</div> </div>
)} )}
</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} /> <DocumentSettings html isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</> </>
); );

View file

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

View file

@ -15,7 +15,10 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter } from '@/types/tts'; import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client'; import type { AudiobookGenerationSettings } from '@/types/client';
import TTSPlayer from '@/components/player/TTSPlayer'; import TTSPlayer from '@/components/player/TTSPlayer';
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/dexie'; 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; 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 router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF(); const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
const { stop } = useTTS(); const { stop } = useTTS();
const { isAtLimit } = useAutoRateLimit();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [zoomLevel, setZoomLevel] = useState<number>(100); const [zoomLevel, setZoomLevel] = useState<number>(100);
@ -114,7 +118,7 @@ export default function PDFViewerPage() {
<p className="text-red-500 mb-4">{error}</p> <p className="text-red-500 mb-4">{error}</p>
<Link <Link
href="/" 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" 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"> <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 }}> <div className="overflow-hidden" style={{ height: containerHeight }}>
{isLoading ? ( {isLoading ? (
<div className="p-4"> <div className="p-4">
<DocumentSkeleton /> <DocumentSkeleton />
@ -185,7 +190,16 @@ export default function PDFViewerPage() {
onRegenerateChapter={handleRegenerateChapter} 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} /> <DocumentSettings isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</> </>
); );

View file

@ -1,5 +1,3 @@
'use client';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { DocumentProvider } from '@/contexts/DocumentContext'; import { DocumentProvider } from '@/contexts/DocumentContext';
@ -9,9 +7,8 @@ import { TTSProvider } from '@/contexts/TTSContext';
import { ThemeProvider } from '@/contexts/ThemeContext'; import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext'; import { ConfigProvider } from '@/contexts/ConfigContext';
import { HTMLProvider } from '@/contexts/HTMLContext'; import { HTMLProvider } from '@/contexts/HTMLContext';
import { RateLimitProvider } from '@/components/rate-limit-provider'; import { AutoRateLimitProvider } from '@/contexts/AutoRateLimitContext';
import { PrivacyPopup } from '@/components/privacy-popup'; import { PrivacyPopup } from '@/components/privacy-popup';
import { AuthConfigProvider } from '@/contexts/AuthConfigContext';
interface ProvidersProps { interface ProvidersProps {
children: ReactNode; children: ReactNode;
@ -20,33 +17,24 @@ interface ProvidersProps {
} }
export function Providers({ children, authEnabled, authBaseUrl }: 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 ( return (
<AuthConfigProvider authEnabled={authEnabled} baseUrl={authBaseUrl}> <AutoRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
{wrappedContent} <ThemeProvider>
</AuthConfigProvider> <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 { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { useAuthConfig } from '@/contexts/AuthConfigContext'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { showPrivacyPopup } from '@/components/privacy-popup'; import { showPrivacyPopup } from '@/components/privacy-popup';
import { wasSignedOut, clearSignedOut } from '@/lib/session-utils'; import { wasSignedOut, clearSignedOut } from '@/lib/session-utils';
import { GithubIcon } from '@/components/icons/Icons'; import { GithubIcon } from '@/components/icons/Icons';
@ -32,6 +32,7 @@ function SignInContent() {
const [justSignedOut, setJustSignedOut] = useState(false); const [justSignedOut, setJustSignedOut] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit();
const isAnyLoading = loadingEmail || loadingGithub || loadingGuest; const isAnyLoading = loadingEmail || loadingGithub || loadingGuest;
@ -87,6 +88,9 @@ function SignInContent() {
setError(errorMessage); setError(errorMessage);
} }
} else { } 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('/'); router.push('/');
} }
} catch (err) { } catch (err) {
@ -116,6 +120,7 @@ function SignInContent() {
try { try {
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);
await client.signIn.anonymous(); await client.signIn.anonymous();
await refreshRateLimit();
router.push('/'); router.push('/');
} catch (e) { } catch (e) {
console.error('Anonymous sign-in failed:', 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 { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { useAuthConfig } from '@/contexts/AuthConfigContext'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { showPrivacyPopup } from '@/components/privacy-popup'; import { showPrivacyPopup } from '@/components/privacy-popup';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@ -19,6 +19,7 @@ export default function SignUpPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit();
// Check if auth is enabled, redirect home if not // Check if auth is enabled, redirect home if not
useEffect(() => { useEffect(() => {
@ -84,6 +85,7 @@ export default function SignUpPage() {
toast.success('Account created! Please sign in.'); toast.success('Account created! Please sign in.');
router.push('/signin'); router.push('/signin');
} else { } else {
await refreshRateLimit();
toast.success('Account created successfully!'); toast.success('Account created successfully!');
router.push('/'); router.push('/');
} }

View file

@ -23,7 +23,7 @@ interface PDFOnLinkClickArgs {
export function PDFViewer({ zoomLevel }: PDFViewerProps) { export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const scaleRef = useRef<number>(1); const scaleRef = useRef<number>(1);
const { containerWidth } = usePDFResize(containerRef); const { containerWidth, containerHeight } = usePDFResize(containerRef);
const sentenceHighlightSeqRef = useRef(0); const sentenceHighlightSeqRef = useRef(0);
const wordHighlightSeqRef = useRef(0); const wordHighlightSeqRef = useRef(0);
const sentenceHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]); const sentenceHighlightTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@ -55,7 +55,7 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
currDocPage, currDocPage,
} = usePDF(); } = usePDF();
const layoutKey = `${zoomLevel}:${containerWidth}:${viewType}:${currDocPage}`; const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
const clearSentenceHighlightTimeouts = useCallback(() => { const clearSentenceHighlightTimeouts = useCallback(() => {
for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t); for (const t of sentenceHighlightTimeoutsRef.current) clearTimeout(t);
@ -234,11 +234,11 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
// Modify scale calculation to be more efficient // Modify scale calculation to be more efficient
const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => { const calculateScale = useCallback((width = pageWidth, height = pageHeight): number => {
const margin = viewType === 'dual' ? 48 : 24; // adjust margin based on view type 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' const targetWidth = viewType === 'dual'
? (containerWidth - margin) / 2 // divide by 2 for dual pages ? (containerWidth - margin) / 2 // divide by 2 for dual pages
: containerWidth - margin; : containerWidth - margin;
const targetHeight = containerHeight - margin; const targetHeight = effectiveContainerHeight - margin;
if (viewType === 'scroll') { if (viewType === 'scroll') {
// For scroll mode, use a more comfortable width-based scale // 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); const baseScale = Math.min(scaleByWidth, scaleByHeight);
return baseScale * (zoomLevel / 100); return baseScale * (zoomLevel / 100);
}, [containerWidth, zoomLevel, pageWidth, pageHeight, viewType]); }, [containerWidth, containerHeight, zoomLevel, pageWidth, pageHeight, viewType]);
// Add memoized scale to prevent unnecessary recalculations // Add memoized scale to prevent unnecessary recalculations
const currentScale = useCallback(() => { const currentScale = useCallback(() => {

View file

@ -23,7 +23,7 @@ import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; 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 { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressPopup } from '@/components/ProgressPopup';
@ -35,7 +35,8 @@ import { BaseDocument } from '@/types/documents';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuth';
import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; 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; 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 [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit();
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const router = useRouter();
const ttsProviders = useMemo(() => [ const ttsProviders = useMemo(() => [
{ id: 'custom-openai', name: 'Custom OpenAI-Like' }, { id: 'custom-openai', name: 'Custom OpenAI-Like' },
@ -146,9 +149,12 @@ export function SettingsModal() {
[selectedModelId, supportsCustom, customModelInput] [selectedModelId, supportsCustom, customModelInput]
); );
// set firstVisit on initial load // Open settings on first visit (stored in Dexie app config)
const checkFirstVist = useCallback(async () => { const checkFirstVist = useCallback(async () => {
if (!isDev) return; const appConfig = await getAppConfig();
if (!appConfig?.privacyAccepted) {
return;
}
const firstVisit = await getFirstVisit(); const firstVisit = await getFirstVisit();
if (!firstVisit) { if (!firstVisit) {
await setFirstVisit(true); await setFirstVisit(true);
@ -165,6 +171,16 @@ export function SettingsModal() {
setLocalTTSInstructions(ttsInstructions); setLocalTTSInstructions(ttsInstructions);
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]); }, [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 // Detect if current model is custom (not in presets) and mirror it in the input field
useEffect(() => { useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
@ -317,10 +333,14 @@ export function SettingsModal() {
const handleSignOut = async () => { const handleSignOut = async () => {
await markSignedOut();
const client = getAuthClient(authBaseUrl); 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(); await client.signOut();
window.location.reload(); await clearSignedOut();
await client.signIn.anonymous();
await refreshRateLimit();
router.refresh();
}; };
const handleDeleteAccount = async () => { const handleDeleteAccount = async () => {
@ -787,16 +807,28 @@ export function SettingsModal() {
<h4 className="font-medium text-foreground">Current Session</h4> <h4 className="font-medium text-foreground">Current Session</h4>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<p className="text-muted">Logged in as:</p> <p className="text-muted">Logged in as:</p>
<p className="font-medium text-foreground">{session?.user?.name || 'Guest'}</p> {session?.user ? (
<p className="text-xs text-muted font-mono">{session?.user?.email}</p> <>
{session?.user?.isAnonymous && ( <p className="font-medium text-foreground">
<p className="text-xs text-accent mt-1">Anonymous / Guest Account</p> {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> </div>
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
{!session?.user?.isAnonymous ? ( {session?.user && !session.user.isAnonymous ? (
<> <>
<Button <Button
onClick={handleSignOut} onClick={handleSignOut}
@ -820,14 +852,14 @@ export function SettingsModal() {
Delete Account Delete Account
</Button> </Button>
<p className="text-xs text-muted mt-2 text-center"> <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> </p>
</div> </div>
</> </>
) : ( ) : (
<div className="pt-2 border-t border-offbase"> <div className="pt-2 border-t border-offbase">
<p className="text-sm text-muted mb-3"> <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> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Link href="/signin" className="w-full"> <Link href="/signin" className="w-full">

View file

@ -1,14 +1,14 @@
'use client'; 'use client';
import { useEffect, useState, ReactNode } from 'react'; import { useEffect, useState, ReactNode } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuth';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { wasSignedOut } from '@/lib/session-utils';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
export function AuthLoader({ children }: { children: ReactNode }) { export function AuthLoader({ children }: { children: ReactNode }) {
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false); const [isAutoLoggingIn, setIsAutoLoggingIn] = useState(false);
const [isCheckingSignOut, setIsCheckingSignOut] = useState(true); const [isCheckingSignOut, setIsCheckingSignOut] = useState(true);
@ -31,13 +31,6 @@ export function AuthLoader({ children }: { children: ReactNode }) {
return; 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 // Not signed out, start auto-login
setIsAutoLoggingIn(true); setIsAutoLoggingIn(true);
setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode setIsCheckingSignOut(false); // Stop checking sign-out, now in auto-login mode
@ -45,6 +38,7 @@ export function AuthLoader({ children }: { children: ReactNode }) {
try { try {
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);
await client.signIn.anonymous(); await client.signIn.anonymous();
await refreshRateLimit();
} catch (err) { } catch (err) {
console.error('Auto-login failed', err); console.error('Auto-login failed', err);
} finally { } finally {
@ -53,7 +47,7 @@ export function AuthLoader({ children }: { children: ReactNode }) {
}; };
checkStatus(); checkStatus();
}, [session, isPending, authEnabled, baseUrl]); }, [session, isPending, authEnabled, baseUrl, refreshRateLimit]);
// Show loader if: // Show loader if:
// 1. Auth client is initializing (isPending) AND auth is enabled // 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 { Button } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useAuthConfig } from '@/contexts/AuthConfigContext'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import { useAuthSession } from '@/hooks/useAuth'; import { useAuthSession } from '@/hooks/useAuth';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
import { markSignedOut } from '@/lib/session-utils'; import { clearSignedOut } from '@/lib/session-utils';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export function UserMenu() { export function UserMenu() {
const { authEnabled, baseUrl } = useAuthConfig(); const { authEnabled, baseUrl } = useAuthConfig();
const { refresh: refreshRateLimit } = useAutoRateLimit();
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const router = useRouter(); const router = useRouter();
if (!authEnabled || isPending) return null; if (!authEnabled || isPending) return null;
const handleSignOut = async () => { const handleSignOut = async () => {
await markSignedOut();
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);
await client.signOut(); await client.signOut();
await clearSignedOut();
await client.signIn.anonymous();
await refreshRateLimit();
router.refresh(); 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="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"> <div className="hidden sm:flex flex-col items-end">
<span className="text-xs font-medium text-foreground"> <span className="text-xs font-medium text-foreground">
{session.user.name || 'Guest'} {session.user.name || session.user.email || 'Account'}
</span> </span>
<span className="text-[10px] text-muted truncate max-w-[120px]"> <span className="text-[10px] text-muted truncate max-w-[120px]">
{session.user.email} {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 () => { const handleAccept = async () => {
await updateAppConfig({ privacyAccepted: true }); await updateAppConfig({ privacyAccepted: true });
setIsOpen(false); setIsOpen(false);
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('openreader:privacyAccepted'));
}
onAccept?.(); onAccept?.();
}; };
return ( return (
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => { }}> <Dialog as="div" className="relative z-[60]" onClose={() => { }}>
<TransitionChild <TransitionChild
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"

View file

@ -1,7 +1,6 @@
'use client'; 'use client';
import { useRateLimit, formatCharCount } from '@/components/rate-limit-provider'; import { useAutoRateLimit, formatCharCount } from '@/contexts/AutoRateLimitContext';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
import Link from 'next/link'; import Link from 'next/link';
interface RateLimitBannerProps { interface RateLimitBannerProps {
@ -9,8 +8,7 @@ interface RateLimitBannerProps {
} }
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit, timeUntilReset } = useRateLimit(); const { status, isAtLimit, timeUntilReset, authEnabled } = useAutoRateLimit();
const { authEnabled } = useAuthConfig();
// Don't show banner if auth is not enabled or if not at limit // Don't show banner if auth is not enabled or if not at limit
if (!authEnabled || !status?.authEnabled || !isAtLimit) { if (!authEnabled || !status?.authEnabled || !isAtLimit) {
@ -20,26 +18,26 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const isAnonymous = status.userType === 'anonymous'; const isAnonymous = status.userType === 'anonymous';
return ( return (
<div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg p-4 ${className}`}> <div className={`bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 ${className}`}>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div className="flex-1"> <div className="text-xs sm:text-sm">
<p className="text-sm font-medium text-amber-700 dark:text-amber-400"> <span className="font-medium text-amber-700 dark:text-amber-400">
Daily TTS limit reached Daily TTS limit reached.
</p> </span>
<p className="text-xs text-amber-600 dark:text-amber-500 mt-1"> <span className="text-amber-600 dark:text-amber-500 ml-1.5">
{`You've used ${formatCharCount(status.currentCount)} of ${formatCharCount(status.limit)} characters today.`} {`Used ${formatCharCount(status.currentCount)} / ${formatCharCount(status.limit)} characters.`}
{' Resets in '}{timeUntilReset}. {' Resets in '}{timeUntilReset}.
</p> </span>
</div> </div>
{isAnonymous && ( {isAnonymous && (
<Link <Link
href="/signup" 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 bg-accent text-background hover:bg-secondary-accent
transform transition-transform duration-200 hover:scale-[1.04]" transform transition-transform duration-200 hover:scale-[1.04]"
> >
Sign up for 4x more Sign up for a higher limit
</Link> </Link>
)} )}
</div> </div>
@ -51,17 +49,16 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
* Compact version for inline display * Compact version for inline display
*/ */
export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit } = useRateLimit(); const { status, isAtLimit, authEnabled } = useAutoRateLimit();
const { authEnabled } = useAuthConfig();
// Don't show if auth is not enabled // Don't show if auth is not enabled
if (!authEnabled || !status?.authEnabled) { if (!authEnabled || !status?.authEnabled) {
return null; return null;
} }
const percentage = status.limit === Infinity const percentage = status.limit > 0
? 0 ? Math.min(100, (status.currentCount / status.limit) * 100)
: Math.min(100, (status.currentCount / status.limit) * 100); : 0;
const isWarning = percentage >= 80; 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'; 'use client';
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext';
export interface RateLimitStatus { export interface RateLimitStatus {
allowed: boolean; allowed: boolean;
@ -13,7 +12,12 @@ export interface RateLimitStatus {
authEnabled: boolean; authEnabled: boolean;
} }
export interface RateLimitContextType { interface AutoRateLimitContextType {
// Auth Config
authEnabled: boolean;
authBaseUrl: string | null;
// Rate Limit
status: RateLimitStatus | null; status: RateLimitStatus | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
@ -23,20 +27,27 @@ export interface RateLimitContextType {
incrementCount: (charCount: number) => void; incrementCount: (charCount: number) => void;
onTTSStart: () => void; onTTSStart: () => void;
onTTSComplete: () => void; onTTSComplete: () => void;
triggerRateLimit: () => void;
} }
const RateLimitContext = createContext<RateLimitContextType | null>(null); const AutoRateLimitContext = createContext<AutoRateLimitContextType | null>(null);
export function useRateLimit(): RateLimitContextType { export function useAutoRateLimit(): AutoRateLimitContextType {
const context = useContext(RateLimitContext); const context = useContext(AutoRateLimitContext);
if (!context) { if (!context) {
throw new Error('useRateLimit must be used within a RateLimitProvider'); throw new Error('useAutoRateLimit must be used within a AutoRateLimitProvider');
} }
return context; return context;
} }
interface RateLimitProviderProps { // Re-export specific hooks for backward compatibility or convenience if needed
children: React.ReactNode; export function useAuthConfig() {
const { authEnabled, authBaseUrl } = useAutoRateLimit();
return { authEnabled, baseUrl: authBaseUrl };
}
export function useRateLimit() {
return useAutoRateLimit();
} }
function calculateTimeUntilReset(resetTime: Date): string { 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) { 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) { } 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(); 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 [status, setStatus] = useState<RateLimitStatus | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { authEnabled } = useAuthConfig();
// Track pending TTS operations to delay count updates // Track pending TTS operations to delay count updates
const pendingTTSRef = useRef<number>(0); const pendingTTSRef = useRef<number>(0);
@ -81,15 +100,17 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) {
const fetchStatus = useCallback(async () => { const fetchStatus = useCallback(async () => {
// Skip if auth is not enabled // Skip if auth is not enabled
if (!authEnabled) { if (!authEnabled) {
const tomorrow = new Date(); const now = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); const tomorrow = new Date(now);
tomorrow.setHours(0, 0, 0, 0); tomorrow.setUTCDate(now.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0);
setStatus({ setStatus({
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
limit: Infinity, // Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
remainingChars: Infinity, limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
resetTime: tomorrow, resetTime: tomorrow,
userType: 'unauthenticated', userType: 'unauthenticated',
authEnabled: false authEnabled: false
@ -128,7 +149,9 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) {
// Calculate time until reset // Calculate time until reset
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : ''; 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) // Increment count locally (for immediate UI feedback)
const incrementCount = useCallback((charCount: number) => { const incrementCount = useCallback((charCount: number) => {
@ -186,7 +209,9 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) {
}; };
}, []); }, []);
const contextValue: RateLimitContextType = { const contextValue: AutoRateLimitContextType = {
authEnabled,
authBaseUrl,
status, status,
loading, loading,
error, error,
@ -195,12 +220,13 @@ export function RateLimitProvider({ children }: RateLimitProviderProps) {
timeUntilReset, timeUntilReset,
incrementCount, incrementCount,
onTTSStart, onTTSStart,
onTTSComplete onTTSComplete,
triggerRateLimit: () => setStatus(prev => prev ? { ...prev, remainingChars: 0, allowed: false } : null)
}; };
return ( return (
<RateLimitContext.Provider value={contextValue}> <AutoRateLimitContext.Provider value={contextValue}>
{children} {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 { withRetry, generateTTS, alignAudio } from '@/lib/client';
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp'; import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp';
import { isKokoroModel } from '@/utils/voice'; import { isKokoroModel } from '@/utils/voice';
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
import type { import type {
TTSLocation, TTSLocation,
TTSSmartMergeResult, TTSSmartMergeResult,
@ -298,6 +299,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const audioContext = useAudioContext(); const audioContext = useAudioContext();
const audioCache = useAudioCache(25); const audioCache = useAudioCache(25);
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel); const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit } = useAutoRateLimit();
// Add ref for location change handler // Add ref for location change handler
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null); const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
@ -849,12 +851,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
backoffFactor: 2 backoffFactor: 2
}; };
const arrayBuffer = await withRetry( onTTSStart();
async () => { let arrayBuffer: TTSAudioBuffer;
return await generateTTS(reqBody, reqHeaders, controller.signal); try {
}, arrayBuffer = await withRetry(
retryOptions async () => {
); return await generateTTS(reqBody, reqHeaders, controller.signal);
},
retryOptions
);
} finally {
onTTSComplete();
}
// Remove the controller once the request is complete // Remove the controller once the request is complete
activeAbortControllers.current.delete(controller); activeAbortControllers.current.delete(controller);
@ -867,6 +875,22 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
return arrayBuffer; return arrayBuffer;
} catch (error) { } 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 // Check if this was an abort error
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
console.log('TTS request aborted:', sentence.substring(0, 20)); console.log('TTS request aborted:', sentence.substring(0, 20));
@ -874,6 +898,23 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
} }
setIsPlaying(false); 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.', { toast.error('Failed to generate audio. Server not responding.', {
id: 'tts-api-error', id: 'tts-api-error',
style: { style: {
@ -897,7 +938,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
pdfHighlightEnabled, pdfHighlightEnabled,
pdfWordHighlightEnabled, pdfWordHighlightEnabled,
epubHighlightEnabled, epubHighlightEnabled,
epubWordHighlightEnabled epubWordHighlightEnabled,
triggerRateLimit,
refreshRateLimit,
onTTSComplete,
onTTSStart
]); ]);
/** /**
@ -929,7 +974,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const processPromise = (async () => { const processPromise = (async () => {
try { try {
const audioBuffer = await getAudio(sentence); 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 // Convert to base64 data URI
const bytes = new Uint8Array(audioBuffer); 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 // Get the processed audio data URI directly from processSentence
const audioDataUri = await processSentence(sentence); const audioDataUri = await processSentence(sentence);
if (!audioDataUri) { 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 // 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)) { 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 => { 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 { interface UsePDFResizeResult {
containerWidth: number; containerWidth: number;
containerHeight: number;
setContainerWidth: (width: number) => void; setContainerWidth: (width: number) => void;
} }
@ -10,6 +11,7 @@ export function usePDFResize(
containerRef: RefObject<HTMLDivElement | null> containerRef: RefObject<HTMLDivElement | null>
): UsePDFResizeResult { ): UsePDFResizeResult {
const [containerWidth, setContainerWidth] = useState<number>(0); const [containerWidth, setContainerWidth] = useState<number>(0);
const [containerHeight, setContainerHeight] = useState<number>(0);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@ -18,11 +20,16 @@ export function usePDFResize(
setContainerWidth(Number(width)); setContainerWidth(Number(width));
}, 150); }, 150);
const debouncedResizeHeight = debounce((height: unknown) => {
setContainerHeight(Number(height));
}, 150);
const observer = new ResizeObserver(entries => { const observer = new ResizeObserver(entries => {
const width = entries[0]?.contentRect.width; const width = entries[0]?.contentRect.width;
if (width) { const height = entries[0]?.contentRect.height;
debouncedResize(width);
} if (width) debouncedResize(width);
if (height) debouncedResizeHeight(height);
}); });
observer.observe(containerRef.current); observer.observe(containerRef.current);
@ -31,5 +38,5 @@ export function usePDFResize(
}; };
}, [containerRef]); }, [containerRef]);
return { containerWidth, setContainerWidth }; return { containerWidth, containerHeight, setContainerWidth };
} }

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useAuthConfig } from '@/contexts/AuthConfigContext'; import { useAuthConfig } from '@/contexts/AutoRateLimitContext';
import { getAuthClient } from '@/lib/auth-client'; import { getAuthClient } from '@/lib/auth-client';
/** /**

View file

@ -12,24 +12,22 @@ function createAuthClientWithUrl(baseUrl: string) {
// Cache for auth client instances by baseUrl // Cache for auth client instances by baseUrl
const clientCache = new Map<string, ReturnType<typeof createAuthClientWithUrl>>(); 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) { export function getAuthClient(baseUrl: string | null) {
const effectiveUrl = baseUrl || "http://localhost:3003"; if (!baseUrl) {
throw new Error(
if (!clientCache.has(effectiveUrl)) { 'Cannot create auth client without baseUrl. ' +
clientCache.set(effectiveUrl, createAuthClientWithUrl(effectiveUrl)); '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, TTSRequestPayload,
TTSRequestHeaders, TTSRequestHeaders,
TTSRetryOptions, TTSRetryOptions,
TTSRequestError,
AudiobookStatusResponse, AudiobookStatusResponse,
CreateChapterPayload, CreateChapterPayload,
VoicesResponse, VoicesResponse,
@ -41,6 +42,26 @@ export const withRetry = async <T>(
break; 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) { if (attempt === maxRetries - 1) {
break; break;
} }
@ -183,7 +204,33 @@ export const generateTTS = async (
}); });
if (!response.ok) { 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(); const buffer = await response.arrayBuffer();

View file

@ -304,6 +304,8 @@ export async function extractTextFromPDF(
} }
// Highlighting functions // Highlighting functions
let highlightPatternSeq = 0;
export function clearHighlights() { export function clearHighlights() {
const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span'); const textNodes = document.querySelectorAll('.react-pdf__Page__textContent span');
textNodes.forEach((node) => { textNodes.forEach((node) => {
@ -343,6 +345,7 @@ export function highlightPattern(
pattern: string, pattern: string,
containerRef: React.RefObject<HTMLDivElement> containerRef: React.RefObject<HTMLDivElement>
) { ) {
const seq = ++highlightPatternSeq;
clearHighlights(); clearHighlights();
if (!pattern?.trim()) return; if (!pattern?.trim()) return;
@ -531,6 +534,7 @@ export function highlightPattern(
// Fire-and-forget async worker call; UI thread returns immediately // Fire-and-forget async worker call; UI thread returns immediately
runHighlightTokenMatch(cleanPattern, tokenTexts) runHighlightTokenMatch(cleanPattern, tokenTexts)
.then((result) => { .then((result) => {
if (seq !== highlightPatternSeq) return;
if (!result || result.bestStart === -1) { if (!result || result.bestStart === -1) {
// No worker result or no good match; nothing to highlight // No worker result or no good match; nothing to highlight
applyHighlightFromTokens(null); applyHighlightFromTokens(null);
@ -544,6 +548,7 @@ export function highlightPattern(
} }
}) })
.catch((error) => { .catch((error) => {
if (seq !== highlightPatternSeq) return;
console.error( console.error(
'Error in PDF highlight worker; no highlights applied:', 'Error in PDF highlight worker; no highlights applied:',
error error

View file

@ -84,13 +84,31 @@ export class SqliteAdapter implements DBAdapter {
} }
async query(text: string, params?: unknown[]) { async query(text: string, params?: unknown[]) {
// simple heuristic to convert Postgres $n params to SQLite ? // Convert Postgres $n params to SQLite placeholders.
// This assumes we aren't using $n inside string literals. // We separately reorder params to match placeholder order.
const convertedSql = text.replace(/\$\d+/g, "?"); 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 { try {
const stmt = this.db.prepare(convertedSql); const stmt = this.db.prepare(convertedSql);
const safeParams = params || []; const safeParams = orderedParams.length > 0 ? orderedParams : [];
const lowerSql = convertedSql.trim().toLowerCase(); 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 // Rate limits configuration - character counts per day
export const RATE_LIMITS = { export const RATE_LIMITS = {
ANONYMOUS: 250_000, // 250K characters per day for anonymous users ANONYMOUS: 50_000, // 50K characters per day for anonymous users
AUTHENTICATED: 1_000_000 // 1M characters per day for authenticated users AUTHENTICATED: 500_000 // 500K characters per day for authenticated users
} as const; } as const;
// Initialize rate limiting table // Singleton flag to ensure we only initialize the table once per process
export async function initializeRateLimitTable() { let tableInitialized: Promise<void> | null = null;
// 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 // Initialize rate limiting table (cached, runs only once per process)
await client.query(` export async function initializeRateLimitTable() {
CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date) 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 { export interface RateLimitResult {
@ -62,9 +74,9 @@ export class RateLimiter {
return { return {
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
limit: Infinity, limit: Number.MAX_SAFE_INTEGER,
resetTime: this.getResetTime(), resetTime: this.getResetTime(),
remainingChars: Infinity remainingChars: Number.MAX_SAFE_INTEGER
}; };
} }
@ -87,16 +99,24 @@ export class RateLimiter {
DO UPDATE SET updated_at = CURRENT_TIMESTAMP DO UPDATE SET updated_at = CURRENT_TIMESTAMP
`, [user.id, today]); `, [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(` 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 WHERE user_id = $1 AND date = $2
`, [user.id, today]); `, [user.id, today]);
const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10); 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 (!updated) {
if (currentCount + charCount > limit) {
return { return {
allowed: false, allowed: false,
currentCount, 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 { return {
allowed: true, allowed: true,
currentCount: newCount, currentCount,
limit, limit,
resetTime: this.getResetTime(), resetTime: this.getResetTime(),
remainingChars: Math.max(0, limit - newCount) remainingChars: Math.max(0, limit - currentCount)
}; };
}); });
} }
@ -134,9 +145,9 @@ export class RateLimiter {
return { return {
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
limit: Infinity, limit: Number.MAX_SAFE_INTEGER,
resetTime: this.getResetTime(), resetTime: this.getResetTime(),
remainingChars: Infinity remainingChars: Number.MAX_SAFE_INTEGER
}; };
} }
@ -219,9 +230,10 @@ export class RateLimiter {
} }
private getResetTime(): Date { private getResetTime(): Date {
const tomorrow = new Date(); const now = new Date();
tomorrow.setDate(tomorrow.getDate() + 1); const tomorrow = new Date(now);
tomorrow.setHours(0, 0, 0, 0); // Start of next day tomorrow.setUTCDate(now.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0); // Start of next day in UTC
return tomorrow; return tomorrow;
} }
} }

View file

@ -31,6 +31,14 @@ export interface TTSRetryOptions {
backoffFactor?: number; backoffFactor?: number;
} }
export interface TTSRequestError extends Error {
status?: number;
code?: string;
type?: string;
title?: string;
detail?: string;
}
// --- Audiobook API Types --- // --- Audiobook API Types ---
export interface AudiobookStatusResponse { export interface AudiobookStatusResponse {

View file

@ -34,8 +34,21 @@ function escapeRegExp(input: string) {
* Upload a sample epub or pdf * Upload a sample epub or pdf
*/ */
export async function uploadFile(page: Page, filePath: string) { export async function uploadFile(page: Page, filePath: string) {
await page.waitForSelector('input[type=file]', { timeout: 10000 }); const input = page.locator('input[type=file]').first();
await page.setInputFiles('input[type=file]', `${DIR}${filePath}`); 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.goto('/');
await page.waitForLoadState('networkidle'); 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 running in CI, select the "Custom OpenAI-Like" model and "Deepinfra" provider
if (process.env.CI) { if (process.env.CI) {
await page.getByRole('button', { name: 'Custom OpenAI-Like' }).click(); 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 // Click the "done" button to dismiss the welcome message
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();
await page.getByRole('button', { name: 'I Understand' }).click();
} }