From 50c3829f9849eb2928dcb1d6de06b39ef6718abb Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 25 Jan 2026 14:38:31 -0700 Subject: [PATCH] update(auth): enhance rate limiting with device/IP backstops and improve UI - Add device ID and IP-based rate limiting to prevent abuse - Refactor UI components for better header menus and settings - Update privacy popup with detailed data usage info - Improve PDF handling and caching to prevent react-pdf warnings - Update README with new Docker instructions and environment variables --- README.md | 118 +++++++------ public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - src/app/api/rate-limit/status/route.ts | 31 +++- src/app/api/tts/route.ts | 58 ++++++- src/app/epub/[id]/page.tsx | 36 ++-- src/app/html/[id]/page.tsx | 25 +-- src/app/page.tsx | 30 ++-- src/app/pdf/[id]/page.tsx | 34 ++-- src/app/providers.tsx | 2 +- src/app/signin/page.tsx | 2 +- src/app/signup/page.tsx | 2 +- src/components/DocumentHeaderMenu.tsx | 149 +++++++++++++++++ src/components/Footer.tsx | 88 ++-------- src/components/Header.tsx | 2 +- src/components/PDFViewer.tsx | 21 ++- src/components/SettingsModal.tsx | 68 ++++---- src/components/auth/UserMenu.tsx | 24 +-- src/components/icons/Icons.tsx | 20 ++- src/components/privacy-popup.tsx | 155 ++++++++++++++---- src/contexts/PDFContext.tsx | 88 +++++++--- src/contexts/TTSContext.tsx | 11 +- src/lib/pdf.ts | 16 +- src/lib/server/device-id.ts | 41 +++++ src/lib/server/rate-limiter.ts | 218 ++++++++++++++++++------- src/lib/server/request-ip.ts | 28 ++++ template.env | 25 ++- 30 files changed, 907 insertions(+), 390 deletions(-) delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg create mode 100644 src/components/DocumentHeaderMenu.tsx create mode 100644 src/lib/server/device-id.ts create mode 100644 src/lib/server/request-ip.ts diff --git a/README.md b/README.md index fa51d1f..ed6dc30 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ OpenReader WebUI is an open source text to speech document reader web app built > **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below). ### 1. 🐳 Start the Docker container: + Minimal (no persistence, auth disabled unless you set auth env vars): ```bash docker run --name openreader-webui \ --restart unless-stopped \ @@ -47,20 +48,60 @@ OpenReader WebUI is an open source text to speech document reader web app built ghcr.io/richardr1126/openreader-webui:latest ``` - (Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices + Fully featured (persistent storage + server library import + KokoroFastAPI in Docker + optional auth): ```bash docker run --name openreader-webui \ --restart unless-stopped \ - -e API_KEY=none \ - -e API_BASE=http://host.docker.internal:8880/v1 \ -p 3003:3003 \ + -v openreader_docstore:/app/docstore \ + -v /path/to/your/library:/app/docstore/library:ro \ + -e API_BASE=http://host.docker.internal:8880/v1 \ + -e API_KEY=none \ + -e BETTER_AUTH_URL=http://localhost:3003 \ + -e BETTER_AUTH_SECRET= \ ghcr.io/richardr1126/openreader-webui:latest ``` + You can remove the `/app/docstore/library` mount if you don't need server library import. + You can remove both `BETTER_AUTH_*` env vars to keep auth disabled. + + > **Notes:** + > - `API_BASE` should point to your TTS API server's base URL (if running Kokoro-FastAPI locally in Docker, use `http://host.docker.internal:8880/v1`). + > - `BETTER_AUTH_URL` should be your externally-facing URL for this app (for example `https://reader.example.com` or `http://localhost:3003`). + > - To enable auth, set **both** `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` generated with `openssl rand -base64 32`. + > - If you set `POSTGRES_URL`, the container will not auto-run migrations for you; run `npx @better-auth/cli migrate -y` against your Postgres database. + +
+ Docker environment variables (Click to expand) + + | Variable | Purpose | Example / Notes | + | --- | --- | --- | + | `API_BASE` | Default TTS API base URL (server-side) | `http://host.docker.internal:8880/v1` | + | `API_KEY` | Default TTS API key | `none` or your provider key | + | `BETTER_AUTH_URL` | Enables auth when set with `BETTER_AUTH_SECRET` | External URL for this app, e.g. `http://localhost:3003` or `https://reader.example.com` | + | `BETTER_AUTH_SECRET` | Enables auth when set with `BETTER_AUTH_URL` | Generate with `openssl rand -base64 32` | + | `POSTGRES_URL` | Use Postgres for auth storage instead of SQLite | If set, run migrations manually (see note above) | + | `GITHUB_CLIENT_ID` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_SECRET` | + | `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth sign-in | Requires `GITHUB_CLIENT_ID` | + +
+ +
+ Docker volume mounts (Click to expand) + + | Mount | Type | Recommended | Purpose | Example | + | --- | --- | --- | --- | --- | + | `/app/docstore` | Docker named volume | Yes | Persists server-side storage (documents, audiobook exports, settings, SQLite DB if used) | `-v openreader_docstore:/app/docstore` | + | `/app/docstore/library` | Bind mount (host folder) | Optional + `:ro` | Exposes an existing folder of documents for **Server Library Import** | `-v /path/to/your/library:/app/docstore/library:ro` | + + To import from the mounted library: **Settings → Documents → Server Library Import** + + > **Note:** Every file in the mounted library is imported into the client browser's storage. Keep the library reasonably sized to avoid performance issues. + +
+ Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. - > **Note:** Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. - ### 2. ⚙️ Configure the app settings in the UI: - Set the TTS Provider and Model in the Settings modal - Set the TTS API Base URL and API Key if needed (more secure to set in env vars) @@ -73,60 +114,6 @@ docker rm openreader-webui && \ docker pull ghcr.io/richardr1126/openreader-webui:latest ``` -### 📦 Volume mounts and Library import - -By default (no volume mounts), OpenReader will store its server-side files inside the container filesystem (which is lost if you remove the container). - -
- - -**Persist server-side storage (`/app/docstore`)** - - - -Run the container with the volume mounted: -```bash -docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - ghcr.io/richardr1126/openreader-webui:latest -``` -This will create a Docker named volume `openreader_docstore` to persist all server-side files, including: - -- **Documents:** Stored under `/app/docstore/documents_v1` -- **Audiobook exports:** Stored under `/app/docstore/audiobooks_v1` - - Per-audiobook settings: `/app/docstore/audiobooks_v1/-audiobook/audiobook.meta.json` - - Chapters: `0001__.m4b` or `0001__<title>.mp3` (no per-chapter `.meta.json` files) -- **Settings** - -This ensures that your documents, exported audiobooks, and server-side settings are retained even if the container is removed or recreated. - -</details> - -<details open> -<summary> - -**Mount an external library folder (read-only recommended)** - -</summary> - -```bash -docker run --name openreader-webui \ - --restart unless-stopped \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - -v /path/to/your/library:/app/docstore/library:ro \ - ghcr.io/richardr1126/openreader-webui:latest -``` -Separate from the main docstore volume, this mounts an external folder into the container at `/app/docstore/library` (read-only recommended) so OpenReader can use an existing library of documents. - -To import from the mounted library: **Settings → Documents → Server Library Import** - -> **Note:** Every file in the mounted volume is imported to the client browser's storage. Please ensure that the mounted library is not too large to avoid performance issues. - -</details> - ### 🗣️ Local Kokoro-FastAPI Quick-start (CPU or GPU) You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). @@ -239,12 +226,23 @@ Optionally required for different features: cp template.env .env # Edit .env with your configuration settings ``` + Auth is recommended for contributors and is enabled when **both** values are set: + + - Set `BETTER_AUTH_URL` to your local URL (default: `http://localhost:3003`) + - Generate a `BETTER_AUTH_SECRET` and paste it into `.env`: + ```bash + openssl rand -base64 32 + ``` + + > Note: To disable auth, remove either `BETTER_AUTH_URL` or `BETTER_AUTH_SECRET`. + > > Note: The base URL for the TTS API should be accessible and relative to the Next.js server -4. Run SQLite creation: +4. Run auth DB migrations (required when auth is enabled): ```bash npx @better-auth/cli migrate -y ``` + > Note: If you set `POSTGRES_URL` in `.env`, migrations will target Postgres instead of local SQLite. 5. Start the development server: diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg> \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg> \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg> \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ -<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg> \ No newline at end of file diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index ce6307d..8bd1c04 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -1,8 +1,10 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, type NextRequest } from 'next/server'; import { auth } from '@/lib/server/auth'; import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; import { headers } from 'next/headers'; import { isAuthEnabled } from '@/lib/server/auth-config'; +import { getClientIp } from '@/lib/server/request-ip'; +import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; export const dynamic = 'force-dynamic'; @@ -14,7 +16,7 @@ function getUtcResetTimeIso(): string { return tomorrow.toISOString(); } -export async function GET() { +export async function GET(req: NextRequest) { try { // If auth is not enabled, return unlimited status if (!isAuthEnabled() || !auth) { @@ -53,12 +55,21 @@ export async function GET() { const isAnonymous = Boolean(session.user.isAnonymous); - const result = await rateLimiter.getCurrentUsage({ - id: session.user.id, - isAnonymous - }); + const ip = getClientIp(req); + const device = isAnonymous ? getOrCreateDeviceId(req) : null; - return NextResponse.json({ + const result = await rateLimiter.getCurrentUsage( + { + id: session.user.id, + isAnonymous, + }, + { + deviceId: device?.deviceId ?? null, + ip, + } + ); + + const response = NextResponse.json({ allowed: result.allowed, currentCount: result.currentCount, limit: result.limit, @@ -67,6 +78,12 @@ export async function GET() { userType: isAnonymous ? 'anonymous' : 'authenticated', authEnabled: true }); + + if (device?.didCreate) { + setDeviceIdCookie(response, device.deviceId); + } + + return response; } catch (error) { console.error('Error getting rate limit status:', error); return NextResponse.json( diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index de750fd..d71ea94 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -10,6 +10,8 @@ import { headers } from 'next/headers'; import { auth } from '@/lib/server/auth'; import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter'; import { isAuthEnabled } from '@/lib/server/auth-config'; +import { getClientIp } from '@/lib/server/request-ip'; +import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; type CustomVoice = string; type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & { @@ -141,6 +143,9 @@ export async function POST(req: NextRequest) { } // Auth and rate limiting check (only when auth is enabled) + let didCreateDeviceIdCookie = false; + let deviceIdToSet: string | null = null; + if (isAuthEnabled() && auth) { const session = await auth.api.getSession({ headers: await headers() @@ -156,10 +161,21 @@ export async function POST(req: NextRequest) { const isAnonymous = Boolean(session.user.isAnonymous); const charCount = text.length; + const ip = getClientIp(req); + const device = isAnonymous ? getOrCreateDeviceId(req) : null; + if (device?.didCreate) { + didCreateDeviceIdCookie = true; + deviceIdToSet = device.deviceId; + } + // Check rate limit const rateLimitResult = await rateLimiter.checkAndIncrementLimit( { id: session.user.id, isAnonymous }, - charCount + charCount, + { + deviceId: device?.deviceId ?? null, + ip, + } ); if (!rateLimitResult.allowed) { @@ -186,13 +202,19 @@ export async function POST(req: NextRequest) { instance: req.nextUrl.pathname, }; - return new NextResponse(JSON.stringify(problem), { + const response = new NextResponse(JSON.stringify(problem), { status: 429, headers: { 'Content-Type': 'application/problem+json', 'Retry-After': String(retryAfterSeconds), }, }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } } @@ -254,7 +276,7 @@ export async function POST(req: NextRequest) { const cachedBuffer = ttsAudioCache.get(cacheKey); if (cachedBuffer) { if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) { - return new NextResponse(null, { + const response = new NextResponse(null, { status: 304, headers: { 'ETag': etag, @@ -262,9 +284,15 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } console.log('TTS cache HIT for key:', cacheKey.slice(0, 8)); - return new NextResponse(cachedBuffer, { + const response = new NextResponse(cachedBuffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'HIT', @@ -274,6 +302,12 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } // De-duplicate identical in-flight requests @@ -292,7 +326,7 @@ export async function POST(req: NextRequest) { try { const buffer = await existing.promise; - return new NextResponse(buffer, { + const response = new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'INFLIGHT', @@ -302,6 +336,12 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } finally { try { req.signal.removeEventListener('abort', onAbort); } catch { } } @@ -340,7 +380,7 @@ export async function POST(req: NextRequest) { try { req.signal.removeEventListener('abort', onAbort); } catch { } } - return new NextResponse(buffer, { + const response = new NextResponse(buffer, { headers: { 'Content-Type': contentType, 'X-Cache': 'MISS', @@ -350,6 +390,12 @@ export async function POST(req: NextRequest) { 'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url' } }); + + if (didCreateDeviceIdCookie && deviceIdToSet) { + setDeviceIdCookie(response, deviceIdToSet); + } + + return response; } catch (error) { // Check if this was an abort error if (error instanceof Error && error.name === 'AbortError') { diff --git a/src/app/epub/[id]/page.tsx b/src/app/epub/[id]/page.tsx index b36dd22..c3a260e 100644 --- a/src/app/epub/[id]/page.tsx +++ b/src/app/epub/[id]/page.tsx @@ -12,14 +12,14 @@ 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 { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; 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'; +import { UserMenu } from '@/components/auth/UserMenu'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -154,30 +154,16 @@ export default function EPUBPage() { title={isLoading ? 'Loading…' : (currDocName || '')} right={ <div className="flex items-center gap-3"> - <ZoomControl - value={padPct} - onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding - onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding - min={0} - max={100} + <DocumentHeaderMenu + zoomLevel={padPct} + onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))} + onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} + onOpenSettings={() => setIsSettingsOpen(true)} + onOpenAudiobook={() => setIsAudiobookModalOpen(true)} + isDev={isDev} + minZoom={0} + maxZoom={100} /> - {isDev && ( - <button - onClick={() => setIsAudiobookModalOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open audiobook export" - title="Export Audiobook" - > - <DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> - </button> - )} - <button - onClick={() => setIsSettingsOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open settings" - > - <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> - </button> </div> } /> diff --git a/src/app/html/[id]/page.tsx b/src/app/html/[id]/page.tsx index 6c98201..46db7b6 100644 --- a/src/app/html/[id]/page.tsx +++ b/src/app/html/[id]/page.tsx @@ -8,12 +8,11 @@ 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 { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; import { RateLimitBanner } from '@/components/rate-limit-banner'; import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; @@ -62,7 +61,6 @@ 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; @@ -122,20 +120,14 @@ export default function HTMLPage() { title={isLoading ? 'Loading…' : (currDocName || '')} right={ <div className="flex items-center gap-3"> - <ZoomControl - value={padPct} - onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding - onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding - min={0} - max={100} + <DocumentHeaderMenu + zoomLevel={padPct} + onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))} + onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))} + onOpenSettings={() => setIsSettingsOpen(true)} + minZoom={0} + maxZoom={100} /> - <button - onClick={() => setIsSettingsOpen(true)} - className="inline-flex items-center h-8 px-2.5 rounded-md border border-offbase bg-base text-foreground text-xs md:text-sm hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open settings" - > - <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> - </button> </div> } /> @@ -153,6 +145,7 @@ export default function HTMLPage() { {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> diff --git a/src/app/page.tsx b/src/app/page.tsx index 3d85b7f..3cd8a6c 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,3 +1,4 @@ +import { Header } from '@/components/Header'; import { HomeContent } from '@/components/HomeContent'; import { SettingsModal } from '@/components/SettingsModal'; import { UserMenu } from '@/components/auth/UserMenu'; @@ -9,23 +10,26 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N export default function Home() { return ( <div className="flex flex-col h-full w-full"> - <SettingsModal /> + <Header + title={ + <div className="flex items-center gap-2"> + {/* eslint-disable-next-line @next/next/no-img-element */} + <img src="/icon.svg" alt="" className="w-5 h-5" aria-hidden="true" /> + <h1 className="text-xs sm:text-sm font-bold truncate text-foreground tracking-tight">OpenReader</h1> + </div> + } + right={ + <div className="flex items-center gap-2"> + <SettingsModal /> + <UserMenu /> + </div> + } + /> <AuthLoader> <> - <UserMenu /> - <section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6"> - <div className="max-w-7xl mx-auto"> - <h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1> - <p className="text-sm leading-relaxed max-w-[77ch] text-foreground"> - Open source document reader {isDev ? 'self-hosted server' : 'demo app'}. - <span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span> - </p> - </div> - </section> - <section className="flex-1 px-4 pb-8 overflow-auto"> + <section className="flex-1 px-4 pb-8 pt-4 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> diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 588da24..e628c58 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -8,9 +8,8 @@ import { useCallback, useEffect, useState } from 'react'; import { DocumentSkeleton } from '@/components/DocumentSkeleton'; import { useTTS } from '@/contexts/TTSContext'; import { DocumentSettings } from '@/components/DocumentSettings'; -import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons'; +import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu'; import { Header } from '@/components/Header'; -import { ZoomControl } from '@/components/ZoomControl'; import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; @@ -19,6 +18,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton'; import { resolveDocumentId } from '@/lib/dexie'; import { RateLimitBanner } from '@/components/rate-limit-banner'; import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; +import { UserMenu } from '@/components/auth/UserMenu'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -60,7 +60,7 @@ export default function PDFViewerPage() { router.replace(`/pdf/${resolved}`); return; } - setCurrentDocument(resolved); + await setCurrentDocument(resolved); } catch (err) { console.error('Error loading document:', err); setError('Failed to load document'); @@ -149,24 +149,16 @@ export default function PDFViewerPage() { title={isLoading ? 'Loading…' : (currDocName || '')} right={ <div className="flex items-center gap-2"> - <ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} /> - {isDev && ( - <button - onClick={() => setIsAudiobookModalOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open audiobook export" - title="Export Audiobook" - > - <DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> - </button> - )} - <button - onClick={() => setIsSettingsOpen(true)} - className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" - aria-label="Open settings" - > - <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> - </button> + <DocumentHeaderMenu + zoomLevel={zoomLevel} + onZoomIncrease={handleZoomIn} + onZoomDecrease={handleZoomOut} + onOpenSettings={() => setIsSettingsOpen(true)} + onOpenAudiobook={() => setIsAudiobookModalOpen(true)} + isDev={isDev} + minZoom={50} + maxZoom={300} + /> </div> } /> diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 8e14dd4..c6a26ed 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -27,7 +27,7 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps <EPUBProvider> <HTMLProvider> {children} - <PrivacyPopup /> + <PrivacyPopup authEnabled={authEnabled} /> </HTMLProvider> </EPUBProvider> </PDFProvider> diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index d7cf651..187d02d 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -266,7 +266,7 @@ function SignInContent() { <p className="text-xs text-muted"> By signing in, you agree to our{' '} <button - onClick={() => showPrivacyPopup()} + onClick={() => showPrivacyPopup({ authEnabled })} className="underline hover:text-foreground" > Privacy Policy diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index c9cf414..3f1e5f3 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -229,7 +229,7 @@ export default function SignUpPage() { <p className="text-xs text-muted"> By creating an account, you agree to our{' '} <button - onClick={() => showPrivacyPopup()} + onClick={() => showPrivacyPopup({ authEnabled })} className="underline hover:text-foreground" > Privacy Policy diff --git a/src/components/DocumentHeaderMenu.tsx b/src/components/DocumentHeaderMenu.tsx new file mode 100644 index 0000000..bff9593 --- /dev/null +++ b/src/components/DocumentHeaderMenu.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Fragment } from 'react'; +import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon } from '@/components/icons/Icons'; +import { ZoomControl } from '@/components/ZoomControl'; +import { UserMenu } from '@/components/auth/UserMenu'; + +interface DocumentHeaderMenuProps { + zoomLevel: number; + onZoomIncrease: () => void; + onZoomDecrease: () => void; + onOpenSettings: () => void; + onOpenAudiobook?: () => void; + isDev?: boolean; + minZoom?: number; + maxZoom?: number; +} + +export function DocumentHeaderMenu({ + zoomLevel, + onZoomIncrease, + onZoomDecrease, + onOpenSettings, + onOpenAudiobook, + isDev, + minZoom = 0, + maxZoom = 100 +}: DocumentHeaderMenuProps) { + + // --- Desktop View --- + const DesktopView = ( + <div className="hidden sm:flex items-center gap-2"> + <ZoomControl + value={zoomLevel} + onIncrease={onZoomIncrease} + onDecrease={onZoomDecrease} + min={minZoom} + max={maxZoom} + /> + {isDev && onOpenAudiobook && ( + <button + onClick={onOpenAudiobook} + className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" + aria-label="Open audiobook export" + title="Export Audiobook" + > + <DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> + </button> + )} + <button + onClick={onOpenSettings} + className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" + aria-label="Open settings" + > + <FileSettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> + </button> + <UserMenu /> + </div> + ); + + // --- Mobile View --- + const MobileView = ( + <div className="sm:hidden flex items-center"> + <Menu as="div" className="relative inline-block text-left"> + <MenuButton + className="inline-flex items-center justify-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent" + title="Menu" + > + <DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" /> + </MenuButton> + <Transition + as={Fragment} + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leave="transition ease-in duration-75" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + > + <MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-offbase rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none z-50"> + {/* Zoom Controls Section */} + <div className="px-4 py-3"> + <p className="text-xs font-medium text-muted mb-2">Zoom / Padding</p> + <div className="flex justify-center"> + <ZoomControl + value={zoomLevel} + onIncrease={() => { + // We wrap in a handler to stop propagation if needed, + // but ZoomControl buttons handle their own clicks. + // However, Menu might close on click? + // Headless UI Menu closes on click inside MenuItem, but these are just buttons in a div. + // It should NOT close unless we click a MenuItem. + onZoomIncrease(); + }} + onDecrease={onZoomDecrease} + min={minZoom} + max={maxZoom} + /> + </div> + </div> + + {/* Actions Section */} + <div className="p-1"> + {isDev && onOpenAudiobook && ( + <MenuItem> + {({ active }) => ( + <button + onClick={onOpenAudiobook} + className={`${active ? 'bg-offbase text-accent' : 'text-foreground' + } group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} + > + <DownloadIcon className="h-4 w-4" /> + Export Audiobook + </button> + )} + </MenuItem> + )} + <MenuItem> + {({ active }) => ( + <button + onClick={onOpenSettings} + className={`${active ? 'bg-offbase text-accent' : 'text-foreground' + } group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} + > + <FileSettingsIcon className="h-4 w-4" /> + Settings + </button> + )} + </MenuItem> + </div> + + {/* Auth Section */} + <div className="p-2 border-t border-offbase flex justify-center"> + <UserMenu /> + </div> + </MenuItems> + </Transition> + </Menu> + </div> + ); + + return ( + <> + {DesktopView} + {MobileView} + </> + ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 904d843..b1ab6d1 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,34 +1,33 @@ -import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react' +'use client' + import { GithubIcon } from '@/components/icons/Icons' -import { CodeBlock } from '@/components/CodeBlock' +import { showPrivacyPopup } from '@/components/privacy-popup' +import { useAuthConfig } from '@/contexts/AutoRateLimitContext' export function Footer() { + const { authEnabled } = useAuthConfig(); + return ( <footer className="m-8 mb-2 text-sm text-muted"> <div className="flex flex-col items-center space-y-4"> <div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3"> <a - href="https://github.com/richardr1126/OpenReader-WebUI" + href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" - className="hover:text-foreground transition-colors" + className="inline-flex items-center gap-2 font-bold hover:text-foreground transition-colors" > <GithubIcon className="w-5 h-5" /> + <span>Self host</span> </a> <span className='w-full sm:w-fit'>•</span> - <Popover className="flex"> - <PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1"> - Privacy info - </PopoverButton> - <PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-xl border border-offbase z-50"> - <p className='max-w-xs'>Documents are uploaded to your local browser cache.</p> - <p className='mt-3 max-w-xs'>Each paragraph of the document you are viewing is sent to Deepinfra for audio generation through a Vercel backend proxy, containing a shared caching pool.</p> - <p className='mt-3 max-w-xs'>The audio is streamed back to your browser and played in real-time.</p> - <p className='mt-3 max-w-xs font-bold'><em>Self-hosting is the recommended way to use this app for a truly secure experience.</em></p> - {/* Vercel analytics disclaimer */} - <p className='mt-3 max-w-xs'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p> - </PopoverPanel> - </Popover> + <button + type="button" + onClick={() => showPrivacyPopup({ authEnabled })} + className="font-bold hover:text-foreground transition-colors outline-none" + > + Privacy + </button> <span className='w-full sm:w-fit'>•</span> <span> Powered by{' '} @@ -51,61 +50,6 @@ export function Footer() { </a> </span> </div> - <div className='font-medium text-center inline-flex truncate items-center justify-center gap-1'> - <span>This is a demo app (</span> - <Popover className="relative"> - <PopoverButton className="font-bold hover:text-foreground transition-colors outline-none inline"> - self-host - </PopoverButton> - <PopoverPanel anchor="top" className="bg-base p-6 rounded-xl shadow-2xl border border-offbase w-[90vw] max-w-3xl z-50 backdrop-blur-md flex flex-col gap-4"> - <div className="space-y-4 font-medium"> - <h3 className="text-lg font-bold text-foreground">Self-Hosting Instructions</h3> - - <div> - <p className="mb-2 font-medium"> - 1. Start the <a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground underline decoration-dotted underline-offset-4">Kokoro-FastAPI</a> container - </p> - <CodeBlock> - { - `docker run -d \\ - --name kokoro-tts \\ - --restart unless-stopped \\ - -p 8880:8880 \\ - -e ONNX_NUM_THREADS=8 \\ - -e ONNX_INTER_OP_THREADS=4 \\ - -e ONNX_EXECUTION_MODE=parallel \\ - -e ONNX_OPTIMIZATION_LEVEL=all \\ - -e ONNX_MEMORY_PATTERN=true \\ - -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \\ - -e API_LOG_LEVEL=DEBUG \\ - ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4` - } - </CodeBlock> - </div> - - <div> - <p className="mb-2 text-foreground font-medium">2. Start OpenReader WebUI container</p> - <CodeBlock> -{ -`docker run --name openreader-webui --rm \\ --e API_BASE=http://kokoro-tts:8880/v1 \\ --p 3003:3003 \\ --v openreader_docstore:/app/docstore \\ --v /path/to/your/library:/app/docstore/library:ro \\ -ghcr.io/richardr1126/openreader-webui:latest` -} - </CodeBlock> - </div> - - <p> - Visit <a href="http://localhost:3003" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">http://localhost:3003</a> to run the app and set your settings. - {' '}See the <a href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">README</a> for more details. - </p> - </div> - </PopoverPanel> - </Popover> - <span> for full functionality)</span> - </div> </div> </footer> ) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index cf55de5..dfd8367 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -15,7 +15,7 @@ export function Header({ <div className="flex items-center gap-2 min-w-0 flex-1"> {left} {typeof title === 'string' ? ( - <h1 className="text-xs sm:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1> + <h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1> ) : ( title )} diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index c591d7d..b21081f 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -49,12 +49,30 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { clearWordHighlights, highlightWordIndex, onDocumentLoadSuccess, + currDocId, currDocData, currDocPages, currDocText, currDocPage, } = usePDF(); + // IMPORTANT: + // - pdf.js may transfer/detach ArrayBuffers when sending them to its worker, so we must clone. + // - react-pdf warns if `file` changes by reference but is deep-equal to the previous value. + // Cache a stable `{ data: Uint8Array }` by `currDocId` so reloading the same PDF with + // identical bytes doesn't create a new `file` object and trigger the warning. + const fileCacheRef = useRef<Map<string, { data: Uint8Array }>>(new Map()); + + if (currDocId && currDocData && !fileCacheRef.current.has(currDocId)) { + try { + fileCacheRef.current.set(currDocId, { data: new Uint8Array(currDocData.slice(0)) }); + } catch (e) { + console.error('Failed to prepare PDF data for viewer:', e); + } + } + + const documentFile = currDocId ? fileCacheRef.current.get(currDocId) : undefined; + const layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`; const clearSentenceHighlightTimeouts = useCallback(() => { @@ -266,9 +284,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) { return ( <div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full"> <Document + key={currDocId || 'pdf'} loading={<DocumentSkeleton />} noData={<DocumentSkeleton />} - file={currDocData} + file={documentFile} onLoadSuccess={(pdf) => { onDocumentLoadSuccess(pdf); }} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index aa9b3a7..be29f97 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -37,6 +37,7 @@ import { useAuthSession } from '@/hooks/useAuth'; import { markSignedOut, clearSignedOut } from '@/lib/session-utils'; import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext'; import { useRouter } from 'next/navigation'; +import { showPrivacyPopup } from '@/components/privacy-popup'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; @@ -45,7 +46,7 @@ const themes = THEMES.map(id => ({ name: id.charAt(0).toUpperCase() + id.slice(1) })); -export function SettingsModal() { +export function SettingsModal({ className = '' }: { className?: string }) { const [isOpen, setIsOpen] = useState(false); const { theme, setTheme } = useTheme(); @@ -393,11 +394,11 @@ export function SettingsModal() { <> <Button onClick={() => setIsOpen(true)} - className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent absolute top-2 right-2 sm:top-4 sm:right-4" + className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent ${className}`} aria-label="Settings" tabIndex={0} > - <SettingsIcon className="w-4 h-4 sm:w-5 sm:h-5 transform transition-transform duration-200 ease-in-out hover:rotate-45" /> + <SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> </Button> <Transition appear show={isOpen} as={Fragment}> @@ -426,12 +427,21 @@ export function SettingsModal() { leaveTo="opacity-0 scale-95" > <DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> - <DialogTitle - as="h3" - className="text-lg font-semibold leading-6 text-foreground mb-4" - > - Settings - </DialogTitle> + <div className="flex items-center justify-between gap-3 mb-4"> + <DialogTitle + as="h3" + className="text-lg font-semibold leading-6 text-foreground" + > + Settings + </DialogTitle> + + <Button + onClick={() => showPrivacyPopup({ authEnabled })} + className="text-sm font-medium text-muted hover:text-accent" + > + Privacy + </Button> + </div> <TabGroup> <TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4"> @@ -520,6 +530,24 @@ export function SettingsModal() { </Listbox> </div> + {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( + <div className="space-y-1"> + <label className="block text-sm font-medium text-foreground"> + API Base URL + {localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} + </label> + <div className="flex gap-2"> + <Input + type="text" + value={localBaseUrl} + onChange={(e) => handleInputChange('baseUrl', e.target.value)} + placeholder="Using environment variable" + className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> + </div> + </div> + )} + <div className="space-y-1"> <label className="block text-sm font-medium text-foreground"> API Key @@ -620,24 +648,6 @@ export function SettingsModal() { </div> )} - {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( - <div className="space-y-1"> - <label className="block text-sm font-medium text-foreground"> - API Base URL - {localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} - </label> - <div className="flex gap-2"> - <Input - type="text" - value={localBaseUrl} - onChange={(e) => handleInputChange('baseUrl', e.target.value)} - placeholder="Using environment variable" - className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" - /> - </div> - </div> - )} - <div className="pt-4 flex justify-end gap-2"> <Button type="button" @@ -863,12 +873,12 @@ export function SettingsModal() { </p> <div className="grid grid-cols-2 gap-3"> <Link href="/signin" className="w-full"> - <Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase"> + <Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> Log In </Button> </Link> <Link href="/signup" className="w-full"> - <Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent"> + <Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> Sign Up </Button> </Link> diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index 6c0c6b0..77a7ff5 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -8,7 +8,7 @@ import { getAuthClient } from '@/lib/auth-client'; import { clearSignedOut } from '@/lib/session-utils'; import { useRouter } from 'next/navigation'; -export function UserMenu() { +export function UserMenu({ className = '' }: { className?: string }) { const { authEnabled, baseUrl } = useAuthConfig(); const { refresh: refreshRateLimit } = useAutoRateLimit(); const { data: session, isPending } = useAuthSession(); @@ -27,14 +27,14 @@ export function UserMenu() { if (!session || session.user.isAnonymous) { return ( - <div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex gap-2"> + <div className={`flex gap-2 ${className}`}> <Link href="/signin"> - <Button className="rounded-lg bg-accent/10 px-3 py-1.5 text-sm font-medium text-accent hover:bg-accent/20"> - {session?.user.isAnonymous ? 'Log In ' : 'Sign In'} + <Button className="inline-flex items-center rounded-md bg-base border border-offbase px-2 py-1 text-xs font-medium text-foreground hover:bg-offbase focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent"> + {session?.user.isAnonymous ? 'Log In' : 'Sign In'} </Button> </Link> - <Link href="/signup" className="hidden sm:block"> - <Button className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent"> + <Link href="/signup"> + <Button className="inline-flex items-center rounded-md bg-accent px-2 py-1 text-xs font-medium text-background hover:bg-secondary-accent focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 transform transition-all duration-200 ease-in-out hover:scale-[1.09]"> Sign Up </Button> </Link> @@ -43,22 +43,22 @@ export function UserMenu() { } return ( - <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"> + <div className={`flex items-center gap-2 ${className}`}> + <div className="hidden sm:flex flex-col items-end mr-1"> + <span className="text-xs font-medium text-foreground leading-none mb-0.5"> {session.user.name || session.user.email || 'Account'} </span> - <span className="text-[10px] text-muted truncate max-w-[120px]"> + <span className="text-[10px] text-muted truncate max-w-[120px] leading-none"> {session.user.email} </span> </div> <Button onClick={handleSignOut} - className="p-2 text-foreground/70 hover:text-red-500 transition-colors" + className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-red-500" title="Sign Out" > - <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path> <polyline points="16 17 21 12 16 7"></polyline> <line x1="21" y1="12" x2="9" y2="12"></line> diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index f731f81..9e0c5b1 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -233,7 +233,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) { strokeWidth='0.25' {...props} > - <path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z"/> + <path d="M25.6686 26.0962C25.1812 26.2401 24.4656 26.2563 23.6984 26.145C22.875 26.0256 22.0351 25.7739 21.2096 25.403C22.6817 25.1888 23.8237 25.2548 24.8005 25.6009C25.0319 25.6829 25.412 25.9021 25.6686 26.0962ZM17.4552 24.7459C17.3953 24.7622 17.3363 24.7776 17.2776 24.7939C16.8815 24.9017 16.4961 25.0069 16.1247 25.1005L15.6239 25.2275C14.6165 25.4824 13.5865 25.7428 12.5692 26.0529C12.9558 25.1206 13.315 24.178 13.6667 23.2564C13.9271 22.5742 14.193 21.8773 14.468 21.1894C14.6075 21.4198 14.7531 21.6503 14.9046 21.8814C15.5948 22.9326 16.4624 23.9045 17.4552 24.7459ZM14.8927 14.2326C14.958 15.383 14.7098 16.4897 14.3457 17.5514C13.8972 16.2386 13.6882 14.7889 14.2489 13.6185C14.3927 13.3185 14.5105 13.1581 14.5869 13.0744C14.7049 13.2566 14.8601 13.6642 14.8927 14.2326ZM9.63347 28.8054C9.38148 29.2562 9.12426 29.6782 8.86063 30.0767C8.22442 31.0355 7.18393 32.0621 6.64941 32.0621C6.59681 32.0621 6.53316 32.0536 6.44015 31.9554C6.38028 31.8926 6.37069 31.8476 6.37359 31.7862C6.39161 31.4337 6.85867 30.8059 7.53527 30.2238C8.14939 29.6957 8.84352 29.2262 9.63347 28.8054ZM27.3706 26.1461C27.2889 24.9719 25.3123 24.2186 25.2928 24.2116C24.5287 23.9407 23.6986 23.8091 22.7552 23.8091C21.7453 23.8091 20.6565 23.9552 19.2582 24.2819C18.014 23.3999 16.9392 22.2957 16.1362 21.0733C15.7816 20.5332 15.4628 19.9941 15.1849 19.4675C15.8633 17.8454 16.4742 16.1013 16.3632 14.1479C16.2737 12.5816 15.5674 11.5295 14.6069 11.5295C13.948 11.5295 13.3807 12.0175 12.9194 12.9813C12.0965 14.6987 12.3128 16.8962 13.562 19.5184C13.1121 20.5751 12.6941 21.6706 12.2895 22.7311C11.7861 24.0498 11.2674 25.4103 10.6828 26.7045C9.04334 27.3532 7.69648 28.1399 6.57402 29.1057C5.8387 29.7373 4.95223 30.7028 4.90163 31.7107C4.87693 32.1854 5.03969 32.6207 5.37044 32.9695C5.72183 33.3398 6.16329 33.5348 6.6487 33.5354C8.25189 33.5354 9.79489 31.3327 10.0876 30.8909C10.6767 30.0029 11.2281 29.0124 11.7684 27.8699C13.1292 27.3781 14.5794 27.011 15.985 26.6562L16.4884 26.5283C16.8668 26.4321 17.2601 26.3257 17.6635 26.2153C18.0904 26.0999 18.5296 25.9802 18.976 25.8665C20.4193 26.7844 21.9714 27.3831 23.4851 27.6028C24.7601 27.7883 25.8924 27.6807 26.6589 27.2811C27.3486 26.9219 27.3866 26.3676 27.3706 26.1461ZM30.4755 36.2428C30.4755 38.3932 28.5802 38.5258 28.1978 38.5301H3.74486C1.60224 38.5301 1.47322 36.6218 1.46913 36.2428L1.46884 3.75642C1.46884 1.6039 3.36763 1.4734 3.74457 1.46908H20.263L20.2718 1.4778V7.92396C20.2718 9.21763 21.0539 11.6669 24.0158 11.6669H30.4203L30.4753 11.7218L30.4755 36.2428ZM28.9572 10.1976H24.0169C21.8749 10.1976 21.7453 8.29969 21.7424 7.92417V2.95307L28.9572 10.1976ZM31.9447 36.2428V11.1157L21.7424 0.871022V0.823357H21.6936L20.8742 0H3.74491C2.44954 0 0 0.785336 0 3.75711V36.2435C0 37.5427 0.782956 40 3.74491 40H28.2001C29.4952 39.9997 31.9447 39.2143 31.9447 36.2428Z" /> </svg> ); } @@ -510,3 +510,21 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) { </svg> ); } +export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) { + return ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 36 36" + fill="currentColor" + className={props.className} + width={props.width || "1.5em"} + height={props.height || "1.5em"} + {...props} + > + <title>file-settings-solid + + + + + ); +} diff --git a/src/components/privacy-popup.tsx b/src/components/privacy-popup.tsx index 24e57cd..8e73f9d 100644 --- a/src/components/privacy-popup.tsx +++ b/src/components/privacy-popup.tsx @@ -11,12 +11,123 @@ import { } from '@headlessui/react'; import { updateAppConfig, getAppConfig } from '@/lib/dexie'; +const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; + interface PrivacyPopupProps { onAccept?: () => void; + authEnabled?: boolean; } -export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { +function PrivacyPopupBody({ + origin, + authEnabled, +}: { + origin: string; + authEnabled: boolean; +}) { + if (!isDev) { + return ( +
+
+
Service operator visibility
+
+ This OpenReader instance is hosted at {origin || 'this server'}. The operator + of this service can access data that reaches the service. +
+
+ +
+
Stored in your browser (IndexedDB)
+
    +
  • Uploaded documents (local library)
  • +
  • Reading progress (last location)
  • +
  • App settings (voice/speed/provider/base URL)
  • +
  • Privacy notice acceptance
  • +
+
+ +
+
Sent to this service
+
    +
  • Text for audio generation and associated metadata
  • +
  • Standard request metadata (e.g. IP address, user agent)
  • +
  • Text is forwarded to a TTS provider (Deepinfra) to generate audio
  • +
  • Some generated audio may be cached server-side to reduce cost/latency
  • +
+
+ +
+
Stored on this service
+
    + {authEnabled ? ( +
  • Auth users data and IP rate limiting data are stored in the service database
  • + ) : ( +
  • Authentication is disabled, so no user/session database is used
  • + )} +
+
+ +
+ This site uses Vercel Analytics to collect anonymous usage data. For maximum privacy, use self-hosted mode. +
+
+ ); + } + + return ( +
+
+
Server owner visibility
+
+ This OpenReader instance is hosted at {origin || 'this server'}. The operator + of this server can access data that reaches the server. +
+
+ +
+
Stored in your browser (IndexedDB)
+
    +
  • Uploaded documents (local library)
  • +
  • Reading progress (last location)
  • +
  • App settings (voice/speed/provider/base URL)
  • +
  • Privacy notice acceptance
  • +
+
+ +
+
Sent to this server
+
    +
  • Text for audio generation and associated metadata
  • +
  • DOCX document upload and conversion only
  • +
  • Your IP address and device ID cookie used for rate limiting
  • +
  • (Optionally) Generated audio for word-by-word timestamps
  • +
  • (Optionally) Your TTS API key so the server can call your TTS provider
  • +
+
+ +
+
Stored on this server
+
    +
  • Documents synced between your browser and this server
  • +
  • Generated audiobooks
  • + {authEnabled ? ( +
  • Auth users data and IP rate limiting data are stored in the server's database
  • + ) : ( +
  • Authentication is disabled on this server, so no server-side user/session database is used
  • + )} +
+
+ +
+ Tip: If you are behind a reverse proxy, the proxy operator may also have access to request logs. +
+
+ ); +} + +export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProps) { const [isOpen, setIsOpen] = useState(false); + const [origin, setOrigin] = useState(''); const checkPrivacyAccepted = useCallback(async () => { const config = await getAppConfig(); @@ -29,6 +140,11 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { checkPrivacyAccepted(); }, [checkPrivacyAccepted]); + useEffect(() => { + if (typeof window === 'undefined') return; + setOrigin(window.location.origin); + }, []); + const handleAccept = async () => { await updateAppConfig({ privacyAccepted: true }); setIsOpen(false); @@ -72,21 +188,7 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) { Privacy & Data Usage -
-

Documents are uploaded to your local browser cache.

-

- Each paragraph of the document you are viewing is sent to Deepinfra - for audio generation through a Vercel backend proxy, containing a - shared caching pool. -

-

The audio is streamed back to your browser and played in real-time.

-

- Self-hosting is the recommended way to use this app for a truly secure experience. -

-

- This site uses Vercel Analytics to collect anonymous usage data to help improve the service. -

-
+