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:
parent
9ba20c8a9e
commit
4689f3676f
29 changed files with 641 additions and 340 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
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: rateLimitResult.resetTime.toISOString(),
|
||||
resetTime,
|
||||
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
|
||||
? `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),
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
{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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ 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 />
|
||||
<AuthLoader>
|
||||
<>
|
||||
<UserMenu />
|
||||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
|
|
@ -22,11 +24,13 @@ export default function Home() {
|
|||
</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>
|
||||
</div>
|
||||
</>
|
||||
</AuthLoader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
{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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,7 +17,8 @@ interface ProvidersProps {
|
|||
}
|
||||
|
||||
export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps) {
|
||||
const content = (
|
||||
return (
|
||||
<AutoRateLimitProvider authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
|
||||
<ThemeProvider>
|
||||
<ConfigProvider>
|
||||
<DocumentProvider>
|
||||
|
|
@ -37,16 +35,6 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps
|
|||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
25
src/components/player/RateLimitPauseButton.tsx
Normal file
25
src/components/player/RateLimitPauseButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(
|
||||
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
|
||||
|
|
@ -1203,7 +1254,24 @@ 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
|
||||
processSentence(nextSentence, true).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;
|
||||
})();
|
||||
// Ignore quota errors during preload
|
||||
if (!(status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED')) {
|
||||
console.error('Error preloading next sentence:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,20 @@ 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
|
||||
// Singleton flag to ensure we only initialize the table once per process
|
||||
let tableInitialized: Promise<void> | null = null;
|
||||
|
||||
// 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
|
||||
|
|
@ -29,6 +37,10 @@ export async function initializeRateLimitTable() {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue