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
This commit is contained in:
parent
4689f3676f
commit
50c3829f98
30 changed files with 907 additions and 390 deletions
118
README.md
118
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).
|
> **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:
|
### 1. 🐳 Start the Docker container:
|
||||||
|
Minimal (no persistence, auth disabled unless you set auth env vars):
|
||||||
```bash
|
```bash
|
||||||
docker run --name openreader-webui \
|
docker run --name openreader-webui \
|
||||||
--restart unless-stopped \
|
--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
|
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
|
```bash
|
||||||
docker run --name openreader-webui \
|
docker run --name openreader-webui \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-e API_KEY=none \
|
|
||||||
-e API_BASE=http://host.docker.internal:8880/v1 \
|
|
||||||
-p 3003:3003 \
|
-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=<paste_the_output_of_openssl_here> \
|
||||||
ghcr.io/richardr1126/openreader-webui:latest
|
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.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Docker environment variables</strong> (Click to expand)</summary>
|
||||||
|
|
||||||
|
| 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` |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Docker volume mounts</strong> (Click to expand)</summary>
|
||||||
|
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
|
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:
|
### 2. ⚙️ Configure the app settings in the UI:
|
||||||
- Set the TTS Provider and Model in the Settings modal
|
- 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)
|
- 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
|
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).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>
|
|
||||||
|
|
||||||
**Persist server-side storage (`/app/docstore`)**
|
|
||||||
|
|
||||||
</summary>
|
|
||||||
|
|
||||||
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/<bookId>-audiobook/audiobook.meta.json`
|
|
||||||
- Chapters: `0001__<title>.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)
|
### 🗣️ 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).
|
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
|
cp template.env .env
|
||||||
# Edit .env with your configuration settings
|
# 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
|
> 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
|
```bash
|
||||||
npx @better-auth/cli migrate -y
|
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:
|
5. Start the development server:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 391 B |
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 1 KiB |
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 128 B |
|
|
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 385 B |
|
|
@ -1,8 +1,10 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
import { auth } from '@/lib/server/auth';
|
import { auth } from '@/lib/server/auth';
|
||||||
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
|
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
|
||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth-config';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -14,7 +16,7 @@ function getUtcResetTimeIso(): string {
|
||||||
return tomorrow.toISOString();
|
return tomorrow.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
// If auth is not enabled, return unlimited status
|
// If auth is not enabled, return unlimited status
|
||||||
if (!isAuthEnabled() || !auth) {
|
if (!isAuthEnabled() || !auth) {
|
||||||
|
|
@ -53,12 +55,21 @@ export async function GET() {
|
||||||
|
|
||||||
const isAnonymous = Boolean(session.user.isAnonymous);
|
const isAnonymous = Boolean(session.user.isAnonymous);
|
||||||
|
|
||||||
const result = await rateLimiter.getCurrentUsage({
|
const ip = getClientIp(req);
|
||||||
id: session.user.id,
|
const device = isAnonymous ? getOrCreateDeviceId(req) : null;
|
||||||
isAnonymous
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
allowed: result.allowed,
|
||||||
currentCount: result.currentCount,
|
currentCount: result.currentCount,
|
||||||
limit: result.limit,
|
limit: result.limit,
|
||||||
|
|
@ -67,6 +78,12 @@ export async function GET() {
|
||||||
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
||||||
authEnabled: true
|
authEnabled: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (device?.didCreate) {
|
||||||
|
setDeviceIdCookie(response, device.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting rate limit status:', error);
|
console.error('Error getting rate limit status:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { headers } from 'next/headers';
|
||||||
import { auth } from '@/lib/server/auth';
|
import { auth } from '@/lib/server/auth';
|
||||||
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
|
import { rateLimiter, RATE_LIMITS } from '@/lib/server/rate-limiter';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth-config';
|
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 CustomVoice = string;
|
||||||
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
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)
|
// Auth and rate limiting check (only when auth is enabled)
|
||||||
|
let didCreateDeviceIdCookie = false;
|
||||||
|
let deviceIdToSet: string | null = null;
|
||||||
|
|
||||||
if (isAuthEnabled() && auth) {
|
if (isAuthEnabled() && auth) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: await headers()
|
headers: await headers()
|
||||||
|
|
@ -156,10 +161,21 @@ export async function POST(req: NextRequest) {
|
||||||
const isAnonymous = Boolean(session.user.isAnonymous);
|
const isAnonymous = Boolean(session.user.isAnonymous);
|
||||||
const charCount = text.length;
|
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
|
// Check rate limit
|
||||||
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
||||||
{ id: session.user.id, isAnonymous },
|
{ id: session.user.id, isAnonymous },
|
||||||
charCount
|
charCount,
|
||||||
|
{
|
||||||
|
deviceId: device?.deviceId ?? null,
|
||||||
|
ip,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!rateLimitResult.allowed) {
|
if (!rateLimitResult.allowed) {
|
||||||
|
|
@ -186,13 +202,19 @@ export async function POST(req: NextRequest) {
|
||||||
instance: req.nextUrl.pathname,
|
instance: req.nextUrl.pathname,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new NextResponse(JSON.stringify(problem), {
|
const response = new NextResponse(JSON.stringify(problem), {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/problem+json',
|
'Content-Type': 'application/problem+json',
|
||||||
'Retry-After': String(retryAfterSeconds),
|
'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);
|
const cachedBuffer = ttsAudioCache.get(cacheKey);
|
||||||
if (cachedBuffer) {
|
if (cachedBuffer) {
|
||||||
if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) {
|
if (ifNoneMatch && (ifNoneMatch.includes(cacheKey) || ifNoneMatch.includes(etag))) {
|
||||||
return new NextResponse(null, {
|
const response = new NextResponse(null, {
|
||||||
status: 304,
|
status: 304,
|
||||||
headers: {
|
headers: {
|
||||||
'ETag': etag,
|
'ETag': etag,
|
||||||
|
|
@ -262,9 +284,15 @@ export async function POST(req: NextRequest) {
|
||||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
'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));
|
console.log('TTS cache HIT for key:', cacheKey.slice(0, 8));
|
||||||
return new NextResponse(cachedBuffer, {
|
const response = new NextResponse(cachedBuffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'X-Cache': 'HIT',
|
'X-Cache': 'HIT',
|
||||||
|
|
@ -274,6 +302,12 @@ export async function POST(req: NextRequest) {
|
||||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
'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
|
// De-duplicate identical in-flight requests
|
||||||
|
|
@ -292,7 +326,7 @@ export async function POST(req: NextRequest) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await existing.promise;
|
const buffer = await existing.promise;
|
||||||
return new NextResponse(buffer, {
|
const response = new NextResponse(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'X-Cache': 'INFLIGHT',
|
'X-Cache': 'INFLIGHT',
|
||||||
|
|
@ -302,6 +336,12 @@ export async function POST(req: NextRequest) {
|
||||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||||
|
setDeviceIdCookie(response, deviceIdToSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
} finally {
|
} finally {
|
||||||
try { req.signal.removeEventListener('abort', onAbort); } catch { }
|
try { req.signal.removeEventListener('abort', onAbort); } catch { }
|
||||||
}
|
}
|
||||||
|
|
@ -340,7 +380,7 @@ export async function POST(req: NextRequest) {
|
||||||
try { req.signal.removeEventListener('abort', onAbort); } catch { }
|
try { req.signal.removeEventListener('abort', onAbort); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
return new NextResponse(buffer, {
|
const response = new NextResponse(buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'X-Cache': 'MISS',
|
'X-Cache': 'MISS',
|
||||||
|
|
@ -350,6 +390,12 @@ export async function POST(req: NextRequest) {
|
||||||
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
'Vary': 'x-tts-provider, x-openai-key, x-openai-base-url'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (didCreateDeviceIdCookie && deviceIdToSet) {
|
||||||
|
setDeviceIdCookie(response, deviceIdToSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Check if this was an abort error
|
// Check if this was an abort error
|
||||||
if (error instanceof Error && error.name === 'AbortError') {
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,14 @@ import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { DownloadIcon } from '@/components/icons/Icons';
|
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import { resolveDocumentId } from '@/lib/dexie';
|
import { resolveDocumentId } from '@/lib/dexie';
|
||||||
import { RateLimitBanner } from '@/components/rate-limit-banner';
|
import { RateLimitBanner } from '@/components/rate-limit-banner';
|
||||||
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
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;
|
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 || '')}
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ZoomControl
|
<DocumentHeaderMenu
|
||||||
value={padPct}
|
zoomLevel={padPct}
|
||||||
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
|
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
|
||||||
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
|
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
||||||
min={0}
|
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||||
max={100}
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { HTMLViewer } from '@/components/HTMLViewer';
|
import { HTMLViewer } from '@/components/HTMLViewer';
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||||
import { SettingsIcon } from '@/components/icons/Icons';
|
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { useTTS } from "@/contexts/TTSContext";
|
import { useTTS } from "@/contexts/TTSContext";
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
|
||||||
import { resolveDocumentId } from '@/lib/dexie';
|
import { resolveDocumentId } from '@/lib/dexie';
|
||||||
|
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
|
||||||
import { RateLimitBanner } from '@/components/rate-limit-banner';
|
import { RateLimitBanner } from '@/components/rate-limit-banner';
|
||||||
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
||||||
|
|
||||||
|
|
@ -62,7 +61,6 @@ export default function HTMLPage() {
|
||||||
}, [loadDocument]);
|
}, [loadDocument]);
|
||||||
|
|
||||||
// Compute available height = viewport - (header height + tts bar height)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
<RateLimitPauseButton />
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
const header = document.querySelector('[data-app-header]') as HTMLElement | null;
|
||||||
|
|
@ -122,20 +120,14 @@ export default function HTMLPage() {
|
||||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ZoomControl
|
<DocumentHeaderMenu
|
||||||
value={padPct}
|
zoomLevel={padPct}
|
||||||
onIncrease={() => setPadPct(p => Math.min(p + 10, 100))} // Increase = less padding
|
onZoomIncrease={() => setPadPct(p => Math.min(p + 10, 100))}
|
||||||
onDecrease={() => setPadPct(p => Math.max(p - 10, 0))} // Decrease = add padding
|
onZoomDecrease={() => setPadPct(p => Math.max(p - 10, 0))}
|
||||||
min={0}
|
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||||
max={100}
|
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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -153,6 +145,7 @@ export default function HTMLPage() {
|
||||||
{isAtLimit ? (
|
{isAtLimit ? (
|
||||||
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
|
<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">
|
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
|
||||||
|
<RateLimitPauseButton />
|
||||||
<RateLimitBanner />
|
<RateLimitBanner />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
import { HomeContent } from '@/components/HomeContent';
|
import { HomeContent } from '@/components/HomeContent';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
import { SettingsModal } from '@/components/SettingsModal';
|
||||||
import { UserMenu } from '@/components/auth/UserMenu';
|
import { UserMenu } from '@/components/auth/UserMenu';
|
||||||
|
|
@ -9,23 +10,26 @@ const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.N
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full">
|
<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>
|
<AuthLoader>
|
||||||
<>
|
<>
|
||||||
<UserMenu />
|
<section className="flex-1 px-4 pb-8 pt-4 overflow-auto">
|
||||||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
|
||||||
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
|
|
||||||
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
|
|
||||||
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
|
|
||||||
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section className="flex-1 px-4 pb-8 overflow-auto">
|
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<RateLimitBanner className="mb-6" />
|
<RateLimitBanner className="mb-6" />
|
||||||
<div className="prism-divider mb-4 sm:mb-6" aria-hidden="true" />
|
|
||||||
<HomeContent />
|
<HomeContent />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,8 @@ import { useCallback, useEffect, useState } from 'react';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { DocumentSettings } from '@/components/DocumentSettings';
|
import { DocumentSettings } from '@/components/DocumentSettings';
|
||||||
import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
|
import { DocumentHeaderMenu } from '@/components/DocumentHeaderMenu';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
|
|
@ -19,6 +18,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
|
||||||
import { resolveDocumentId } from '@/lib/dexie';
|
import { resolveDocumentId } from '@/lib/dexie';
|
||||||
import { RateLimitBanner } from '@/components/rate-limit-banner';
|
import { RateLimitBanner } from '@/components/rate-limit-banner';
|
||||||
import { useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
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;
|
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}`);
|
router.replace(`/pdf/${resolved}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCurrentDocument(resolved);
|
await setCurrentDocument(resolved);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading document:', err);
|
console.error('Error loading document:', err);
|
||||||
setError('Failed to load document');
|
setError('Failed to load document');
|
||||||
|
|
@ -149,24 +149,16 @@ export default function PDFViewerPage() {
|
||||||
title={isLoading ? 'Loading…' : (currDocName || '')}
|
title={isLoading ? 'Loading…' : (currDocName || '')}
|
||||||
right={
|
right={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ZoomControl value={zoomLevel} onIncrease={handleZoomIn} onDecrease={handleZoomOut} />
|
<DocumentHeaderMenu
|
||||||
{isDev && (
|
zoomLevel={zoomLevel}
|
||||||
<button
|
onZoomIncrease={handleZoomIn}
|
||||||
onClick={() => setIsAudiobookModalOpen(true)}
|
onZoomDecrease={handleZoomOut}
|
||||||
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"
|
onOpenSettings={() => setIsSettingsOpen(true)}
|
||||||
aria-label="Open audiobook export"
|
onOpenAudiobook={() => setIsAudiobookModalOpen(true)}
|
||||||
title="Export Audiobook"
|
isDev={isDev}
|
||||||
>
|
minZoom={50}
|
||||||
<DownloadIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:text-accent" />
|
maxZoom={300}
|
||||||
</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>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export function Providers({ children, authEnabled, authBaseUrl }: ProvidersProps
|
||||||
<EPUBProvider>
|
<EPUBProvider>
|
||||||
<HTMLProvider>
|
<HTMLProvider>
|
||||||
{children}
|
{children}
|
||||||
<PrivacyPopup />
|
<PrivacyPopup authEnabled={authEnabled} />
|
||||||
</HTMLProvider>
|
</HTMLProvider>
|
||||||
</EPUBProvider>
|
</EPUBProvider>
|
||||||
</PDFProvider>
|
</PDFProvider>
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ function SignInContent() {
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
By signing in, you agree to our{' '}
|
By signing in, you agree to our{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => showPrivacyPopup()}
|
onClick={() => showPrivacyPopup({ authEnabled })}
|
||||||
className="underline hover:text-foreground"
|
className="underline hover:text-foreground"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ export default function SignUpPage() {
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
By creating an account, you agree to our{' '}
|
By creating an account, you agree to our{' '}
|
||||||
<button
|
<button
|
||||||
onClick={() => showPrivacyPopup()}
|
onClick={() => showPrivacyPopup({ authEnabled })}
|
||||||
className="underline hover:text-foreground"
|
className="underline hover:text-foreground"
|
||||||
>
|
>
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
|
|
|
||||||
149
src/components/DocumentHeaderMenu.tsx
Normal file
149
src/components/DocumentHeaderMenu.tsx
Normal file
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,33 @@
|
||||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
'use client'
|
||||||
|
|
||||||
import { GithubIcon } from '@/components/icons/Icons'
|
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() {
|
export function Footer() {
|
||||||
|
const { authEnabled } = useAuthConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="m-8 mb-2 text-sm text-muted">
|
<footer className="m-8 mb-2 text-sm text-muted">
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<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">
|
<div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/richardr1126/OpenReader-WebUI"
|
href="https://github.com/richardr1126/OpenReader-WebUI#readme"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<GithubIcon className="w-5 h-5" />
|
||||||
|
<span>Self host</span>
|
||||||
</a>
|
</a>
|
||||||
<span className='w-full sm:w-fit'>•</span>
|
<span className='w-full sm:w-fit'>•</span>
|
||||||
<Popover className="flex">
|
<button
|
||||||
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1">
|
type="button"
|
||||||
Privacy info
|
onClick={() => showPrivacyPopup({ authEnabled })}
|
||||||
</PopoverButton>
|
className="font-bold hover:text-foreground transition-colors outline-none"
|
||||||
<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>
|
Privacy
|
||||||
<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>
|
</button>
|
||||||
<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>
|
|
||||||
<span className='w-full sm:w-fit'>•</span>
|
<span className='w-full sm:w-fit'>•</span>
|
||||||
<span>
|
<span>
|
||||||
Powered by{' '}
|
Powered by{' '}
|
||||||
|
|
@ -51,61 +50,6 @@ export function Footer() {
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export function Header({
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
{left}
|
{left}
|
||||||
{typeof title === 'string' ? (
|
{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
|
title
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,30 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
clearWordHighlights,
|
clearWordHighlights,
|
||||||
highlightWordIndex,
|
highlightWordIndex,
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
|
currDocId,
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocText,
|
currDocText,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
} = usePDF();
|
} = 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 layoutKey = `${zoomLevel}:${containerWidth}:${containerHeight}:${viewType}:${currDocPage}`;
|
||||||
|
|
||||||
const clearSentenceHighlightTimeouts = useCallback(() => {
|
const clearSentenceHighlightTimeouts = useCallback(() => {
|
||||||
|
|
@ -266,9 +284,10 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
<div ref={containerRef} className="flex flex-col items-center overflow-auto w-full px-6 h-full">
|
||||||
<Document
|
<Document
|
||||||
|
key={currDocId || 'pdf'}
|
||||||
loading={<DocumentSkeleton />}
|
loading={<DocumentSkeleton />}
|
||||||
noData={<DocumentSkeleton />}
|
noData={<DocumentSkeleton />}
|
||||||
file={currDocData}
|
file={documentFile}
|
||||||
onLoadSuccess={(pdf) => {
|
onLoadSuccess={(pdf) => {
|
||||||
onDocumentLoadSuccess(pdf);
|
onDocumentLoadSuccess(pdf);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { useAuthSession } from '@/hooks/useAuth';
|
||||||
import { markSignedOut, clearSignedOut } from '@/lib/session-utils';
|
import { markSignedOut, clearSignedOut } from '@/lib/session-utils';
|
||||||
import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
import { useAuthConfig, useAutoRateLimit } from '@/contexts/AutoRateLimitContext';
|
||||||
import { useRouter } from 'next/navigation';
|
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;
|
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)
|
name: id.charAt(0).toUpperCase() + id.slice(1)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export function SettingsModal() {
|
export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
@ -393,11 +394,11 @@ export function SettingsModal() {
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
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"
|
aria-label="Settings"
|
||||||
tabIndex={0}
|
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>
|
</Button>
|
||||||
|
|
||||||
<Transition appear show={isOpen} as={Fragment}>
|
<Transition appear show={isOpen} as={Fragment}>
|
||||||
|
|
@ -426,12 +427,21 @@ export function SettingsModal() {
|
||||||
leaveTo="opacity-0 scale-95"
|
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">
|
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
|
||||||
<DialogTitle
|
<div className="flex items-center justify-between gap-3 mb-4">
|
||||||
as="h3"
|
<DialogTitle
|
||||||
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
as="h3"
|
||||||
>
|
className="text-lg font-semibold leading-6 text-foreground"
|
||||||
Settings
|
>
|
||||||
</DialogTitle>
|
Settings
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => showPrivacyPopup({ authEnabled })}
|
||||||
|
className="text-sm font-medium text-muted hover:text-accent"
|
||||||
|
>
|
||||||
|
Privacy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TabGroup>
|
<TabGroup>
|
||||||
<TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4">
|
<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>
|
</Listbox>
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
API Key
|
API Key
|
||||||
|
|
@ -620,24 +648,6 @@ export function SettingsModal() {
|
||||||
</div>
|
</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">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -863,12 +873,12 @@ export function SettingsModal() {
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Link href="/signin" className="w-full">
|
<Link href="/signin" className="w-full">
|
||||||
<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
|
Log In
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup" className="w-full">
|
<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
|
Sign Up
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { getAuthClient } from '@/lib/auth-client';
|
||||||
import { clearSignedOut } from '@/lib/session-utils';
|
import { clearSignedOut } from '@/lib/session-utils';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
export function UserMenu() {
|
export function UserMenu({ className = '' }: { className?: string }) {
|
||||||
const { authEnabled, baseUrl } = useAuthConfig();
|
const { authEnabled, baseUrl } = useAuthConfig();
|
||||||
const { refresh: refreshRateLimit } = useAutoRateLimit();
|
const { refresh: refreshRateLimit } = useAutoRateLimit();
|
||||||
const { data: session, isPending } = useAuthSession();
|
const { data: session, isPending } = useAuthSession();
|
||||||
|
|
@ -27,14 +27,14 @@ export function UserMenu() {
|
||||||
|
|
||||||
if (!session || session.user.isAnonymous) {
|
if (!session || session.user.isAnonymous) {
|
||||||
return (
|
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">
|
<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">
|
<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'}
|
{session?.user.isAnonymous ? 'Log In' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/signup" className="hidden sm:block">
|
<Link href="/signup">
|
||||||
<Button className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-background hover:bg-secondary-accent">
|
<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
|
Sign Up
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -43,22 +43,22 @@ export function UserMenu() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute top-2 right-14 sm:top-4 sm:right-16 flex items-center gap-3">
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
<div className="hidden sm:flex flex-col items-end">
|
<div className="hidden sm:flex flex-col items-end mr-1">
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground leading-none mb-0.5">
|
||||||
{session.user.name || session.user.email || 'Account'}
|
{session.user.name || session.user.email || 'Account'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted truncate max-w-[120px]">
|
<span className="text-[10px] text-muted truncate max-w-[120px] leading-none">
|
||||||
{session.user.email}
|
{session.user.email}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSignOut}
|
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"
|
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>
|
<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>
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
|
|
|
||||||
|
|
@ -233,7 +233,7 @@ export function PDFIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
strokeWidth='0.25'
|
strokeWidth='0.25'
|
||||||
{...props}
|
{...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>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -510,3 +510,21 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
</svg>
|
</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</title>
|
||||||
|
<path d="M15.55,31H6V5H26v8.78a2.37,2.37,0,0,1,2,1.57V5a2,2,0,0,0-2-2H6A2,2,0,0,0,4,5V31a2,2,0,0,0,2,2H17.16l-1-1A2.38,2.38,0,0,1,15.55,31Z" className="clr-i-solid clr-i-solid-path-1"></path>
|
||||||
|
<path d="M33.54,23.47l-2-.61a7.06,7.06,0,0,0-.58-1.41l1-1.86a.37.37,0,0,0-.07-.44L30.41,17.7a.37.37,0,0,0-.44-.07l-1.85,1A7,7,0,0,0,26.69,18l-.61-2a.37.37,0,0,0-.36-.25h-2a.37.37,0,0,0-.35.26l-.61,2a7,7,0,0,0-1.44.61l-1.82-1a.37.37,0,0,0-.44.07l-1.47,1.44a.37.37,0,0,0-.07.44l1,1.82a7,7,0,0,0-.61,1.44l-2,.61a.37.37,0,0,0-.26.35v2a.37.37,0,0,0,.26.35l2,.61a7,7,0,0,0,.61,1.41l-1,1.9a.37.37,0,0,0,.07.44L19,32a.37.37,0,0,0,.44.07l1.87-1a7.06,7.06,0,0,0,1.39.57l.61,2a.37.37,0,0,0,.35.26h2a.37.37,0,0,0,.35-.26l.61-2a7,7,0,0,0,1.38-.57l1.89,1a.37.37,0,0,0,.44-.07l1.45-1.45a.37.37,0,0,0,.07-.44l-1-1.88a7.06,7.06,0,0,0,.58-1.39l2-.61a.37.37,0,0,0,.26-.35V23.83A.37.37,0,0,0,33.54,23.47ZM24.7,28.19A3.33,3.33,0,1,1,28,24.86,3.33,3.33,0,0,1,24.7,28.19Z" className="clr-i-solid clr-i-solid-path-2"></path>
|
||||||
|
<rect x="0" y="0" width="36" height="36" fillOpacity="0" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,123 @@ import {
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { updateAppConfig, getAppConfig } from '@/lib/dexie';
|
import { updateAppConfig, getAppConfig } from '@/lib/dexie';
|
||||||
|
|
||||||
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
interface PrivacyPopupProps {
|
interface PrivacyPopupProps {
|
||||||
onAccept?: () => void;
|
onAccept?: () => void;
|
||||||
|
authEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
|
function PrivacyPopupBody({
|
||||||
|
origin,
|
||||||
|
authEnabled,
|
||||||
|
}: {
|
||||||
|
origin: string;
|
||||||
|
authEnabled: boolean;
|
||||||
|
}) {
|
||||||
|
if (!isDev) {
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4 text-sm text-foreground/90">
|
||||||
|
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Service operator visibility</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
This OpenReader instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. The operator
|
||||||
|
of this service can access data that reaches the service.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored in your browser (IndexedDB)</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>Uploaded documents (local library)</li>
|
||||||
|
<li>Reading progress (last location)</li>
|
||||||
|
<li>App settings (voice/speed/provider/base URL)</li>
|
||||||
|
<li>Privacy notice acceptance</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Sent to this service</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>Text for audio generation and associated metadata</li>
|
||||||
|
<li>Standard request metadata (e.g. IP address, user agent)</li>
|
||||||
|
<li>Text is forwarded to a TTS provider (Deepinfra) to generate audio</li>
|
||||||
|
<li>Some generated audio may be cached server-side to reduce cost/latency</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored on this service</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
{authEnabled ? (
|
||||||
|
<li>Auth users data and IP rate limiting data are stored in the service database</li>
|
||||||
|
) : (
|
||||||
|
<li>Authentication is disabled, so no user/session database is used</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
This site uses Vercel Analytics to collect anonymous usage data. For maximum privacy, use self-hosted mode.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4 text-sm text-foreground/90">
|
||||||
|
<div className="rounded-lg border border-offbase bg-offbase/40 p-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Server owner visibility</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
This OpenReader instance is hosted at <span className="font-bold">{origin || 'this server'}</span>. The operator
|
||||||
|
of this server can access data that reaches the server.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored in your browser (IndexedDB)</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>Uploaded documents (local library)</li>
|
||||||
|
<li>Reading progress (last location)</li>
|
||||||
|
<li>App settings (voice/speed/provider/base URL)</li>
|
||||||
|
<li>Privacy notice acceptance</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Sent to this server</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>Text for audio generation and associated metadata</li>
|
||||||
|
<li>DOCX document upload and conversion only</li>
|
||||||
|
<li>Your IP address and device ID cookie used for rate limiting</li>
|
||||||
|
<li>(Optionally) Generated audio for word-by-word timestamps</li>
|
||||||
|
<li>(Optionally) Your TTS API key so the server can call your TTS provider</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Stored on this server</div>
|
||||||
|
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||||
|
<li>Documents synced between your browser and this server</li>
|
||||||
|
<li>Generated audiobooks</li>
|
||||||
|
{authEnabled ? (
|
||||||
|
<li>Auth users data and IP rate limiting data are stored in the server's database</li>
|
||||||
|
) : (
|
||||||
|
<li>Authentication is disabled on this server, so no server-side user/session database is used</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
Tip: If you are behind a reverse proxy, the proxy operator may also have access to request logs.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivacyPopup({ onAccept, authEnabled = false }: PrivacyPopupProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [origin, setOrigin] = useState('');
|
||||||
|
|
||||||
const checkPrivacyAccepted = useCallback(async () => {
|
const checkPrivacyAccepted = useCallback(async () => {
|
||||||
const config = await getAppConfig();
|
const config = await getAppConfig();
|
||||||
|
|
@ -29,6 +140,11 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
|
||||||
checkPrivacyAccepted();
|
checkPrivacyAccepted();
|
||||||
}, [checkPrivacyAccepted]);
|
}, [checkPrivacyAccepted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
setOrigin(window.location.origin);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAccept = async () => {
|
const handleAccept = async () => {
|
||||||
await updateAppConfig({ privacyAccepted: true });
|
await updateAppConfig({ privacyAccepted: true });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
@ -72,21 +188,7 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
|
||||||
Privacy & Data Usage
|
Privacy & Data Usage
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3 text-sm text-foreground/90">
|
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
|
||||||
<p>Documents are uploaded to your local browser cache.</p>
|
|
||||||
<p>
|
|
||||||
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>The audio is streamed back to your browser and played in real-time.</p>
|
|
||||||
<p className="font-semibold italic">
|
|
||||||
Self-hosting is the recommended way to use this app for a truly secure experience.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
This site uses Vercel Analytics to collect anonymous usage data to help improve the service.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -113,12 +215,15 @@ export function PrivacyPopup({ onAccept }: PrivacyPopupProps) {
|
||||||
* Function to programmatically show the privacy popup
|
* Function to programmatically show the privacy popup
|
||||||
* This can be called from signin/signup components
|
* This can be called from signin/signup components
|
||||||
*/
|
*/
|
||||||
export function showPrivacyPopup(): void {
|
export function showPrivacyPopup(options?: { authEnabled?: boolean }): void {
|
||||||
// Create a temporary container for the popup
|
// Create a temporary container for the popup
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = 'privacy-popup-container';
|
container.id = 'privacy-popup-container';
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
const authEnabled = Boolean(options?.authEnabled);
|
||||||
|
|
||||||
// Import React and render the popup
|
// Import React and render the popup
|
||||||
import('react-dom/client').then(({ createRoot }) => {
|
import('react-dom/client').then(({ createRoot }) => {
|
||||||
import('react').then((React) => {
|
import('react').then((React) => {
|
||||||
|
|
@ -171,21 +276,7 @@ export function showPrivacyPopup(): void {
|
||||||
Privacy & Data Usage
|
Privacy & Data Usage
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3 text-sm text-foreground/90">
|
<PrivacyPopupBody origin={origin} authEnabled={authEnabled} />
|
||||||
<p>Documents are uploaded to your local browser cache.</p>
|
|
||||||
<p>
|
|
||||||
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>The audio is streamed back to your browser and played in real-time.</p>
|
|
||||||
<p className="font-semibold italic">
|
|
||||||
Self-hosting is the recommended way to use this app for a truly secure experience.
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted">
|
|
||||||
This site uses Vercel Analytics to collect anonymous usage data.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ import type {
|
||||||
*/
|
*/
|
||||||
interface PDFContextType {
|
interface PDFContextType {
|
||||||
// Current document state
|
// Current document state
|
||||||
|
currDocId: string | undefined;
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
currDocName: string | undefined;
|
currDocName: string | undefined;
|
||||||
currDocPages: number | undefined;
|
currDocPages: number | undefined;
|
||||||
|
|
@ -111,16 +112,16 @@ const CONTINUATION_PREVIEW_CHARS = 600;
|
||||||
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
* @param {ReactNode} props.children - Child components to be wrapped by the provider
|
||||||
*/
|
*/
|
||||||
export function PDFProvider({ children }: { children: ReactNode }) {
|
export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const {
|
const {
|
||||||
setText: setTTSText,
|
setText: setTTSText,
|
||||||
stop,
|
stop,
|
||||||
currDocPageNumber,
|
currDocPageNumber,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
setCurrDocPages,
|
setCurrDocPages,
|
||||||
setIsEPUB,
|
setIsEPUB,
|
||||||
registerVisualPageChangeHandler,
|
registerVisualPageChangeHandler,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
headerMargin,
|
headerMargin,
|
||||||
footerMargin,
|
footerMargin,
|
||||||
leftMargin,
|
leftMargin,
|
||||||
|
|
@ -136,6 +137,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Current document state
|
// Current document state
|
||||||
|
const [currDocId, setCurrDocId] = useState<string>();
|
||||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
|
|
@ -144,6 +146,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
const pageTextCacheRef = useRef<Map<number, string>>(new Map());
|
||||||
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
const [currDocPage, setCurrDocPage] = useState<number>(currDocPageNumber);
|
||||||
|
|
||||||
|
// Used to cancel/ignore in-flight text extraction when the document changes
|
||||||
|
// or when react-pdf tears down and recreates its internal worker.
|
||||||
|
const pdfDocGenerationRef = useRef(0);
|
||||||
|
const pdfDocumentRef = useRef<PDFDocumentProxy | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pdfDocumentRef.current = pdfDocument;
|
||||||
|
}, [pdfDocument]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrDocPage(currDocPageNumber);
|
setCurrDocPage(currDocPageNumber);
|
||||||
}, [currDocPageNumber]);
|
}, [currDocPageNumber]);
|
||||||
|
|
@ -155,9 +166,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
|
const onDocumentLoadSuccess = useCallback((pdf: PDFDocumentProxy) => {
|
||||||
console.log('Document loaded:', pdf.numPages);
|
console.log('Document loaded:', pdf.numPages);
|
||||||
|
pdfDocGenerationRef.current += 1;
|
||||||
|
pdfDocumentRef.current = pdf;
|
||||||
setCurrDocPages(pdf.numPages);
|
setCurrDocPages(pdf.numPages);
|
||||||
setPdfDocument(pdf);
|
setPdfDocument(pdf);
|
||||||
}, [setCurrDocPages]);
|
}, [setCurrDocPages, setPdfDocument]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and processes text from the current document page
|
* Loads and processes text from the current document page
|
||||||
|
|
@ -167,7 +180,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const loadCurrDocText = useCallback(async () => {
|
const loadCurrDocText = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) return;
|
const generation = pdfDocGenerationRef.current;
|
||||||
|
const currentPdf = pdfDocumentRef.current;
|
||||||
|
if (!currentPdf) return;
|
||||||
|
|
||||||
const margins = {
|
const margins = {
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
|
|
@ -177,6 +192,11 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
const getPageText = async (pageNumber: number, shouldCache = false): Promise<string> => {
|
||||||
|
// Ignore stale/in-flight work if the document or worker changed.
|
||||||
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
|
throw new DOMException('Stale PDF extraction', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
if (pageTextCacheRef.current.has(pageNumber)) {
|
if (pageTextCacheRef.current.has(pageNumber)) {
|
||||||
const cached = pageTextCacheRef.current.get(pageNumber)!;
|
const cached = pageTextCacheRef.current.get(pageNumber)!;
|
||||||
if (!shouldCache) {
|
if (!shouldCache) {
|
||||||
|
|
@ -185,14 +205,19 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
const extracted = await extractTextFromPDF(pdfDocument, pageNumber, margins);
|
const extracted = await extractTextFromPDF(currentPdf, pageNumber, margins);
|
||||||
|
|
||||||
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
|
throw new DOMException('Stale PDF extraction', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldCache) {
|
if (shouldCache) {
|
||||||
pageTextCacheRef.current.set(pageNumber, extracted);
|
pageTextCacheRef.current.set(pageNumber, extracted);
|
||||||
}
|
}
|
||||||
return extracted;
|
return extracted;
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPages = currDocPages ?? pdfDocument.numPages;
|
const totalPages = currDocPages ?? currentPdf.numPages;
|
||||||
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
|
const nextPageNumber = currDocPageNumber < totalPages ? currDocPageNumber + 1 : undefined;
|
||||||
|
|
||||||
const [text, nextText] = await Promise.all([
|
const [text, nextText] = await Promise.all([
|
||||||
|
|
@ -200,6 +225,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
|
nextPageNumber ? getPageText(nextPageNumber, true) : Promise.resolve<string | undefined>(undefined),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (generation !== pdfDocGenerationRef.current || pdfDocumentRef.current !== currentPdf) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (text !== currDocText || text === '') {
|
if (text !== currDocText || text === '') {
|
||||||
setCurrDocText(text);
|
setCurrDocText(text);
|
||||||
setTTSText(text, {
|
setTTSText(text, {
|
||||||
|
|
@ -209,10 +238,12 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('Error loading PDF text:', error);
|
console.error('Error loading PDF text:', error);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
pdfDocument,
|
|
||||||
currDocPageNumber,
|
currDocPageNumber,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
setTTSText,
|
setTTSText,
|
||||||
|
|
@ -228,10 +259,10 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
* Triggers text extraction and processing when either the document URL or page changes
|
* Triggers text extraction and processing when either the document URL or page changes
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currDocData) {
|
if (currDocData && pdfDocument) {
|
||||||
loadCurrDocText();
|
loadCurrDocText();
|
||||||
}
|
}
|
||||||
}, [currDocPageNumber, currDocData, loadCurrDocText]);
|
}, [currDocPageNumber, currDocData, pdfDocument, loadCurrDocText]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the current document based on its ID
|
* Sets the current document based on its ID
|
||||||
|
|
@ -242,21 +273,38 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
*/
|
*/
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
// Reset any state tied to the previously loaded PDF. This prevents calling
|
||||||
|
// `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects
|
||||||
|
// or fast refresh.
|
||||||
|
pdfDocGenerationRef.current += 1;
|
||||||
|
pageTextCacheRef.current.clear();
|
||||||
|
setPdfDocument(undefined);
|
||||||
|
setCurrDocPages(undefined);
|
||||||
|
setCurrDocText(undefined);
|
||||||
|
setCurrDocId(id);
|
||||||
|
setCurrDocName(undefined);
|
||||||
|
setCurrDocData(undefined);
|
||||||
|
|
||||||
const doc = await getPdfDocument(id);
|
const doc = await getPdfDocument(id);
|
||||||
if (doc) {
|
if (doc) {
|
||||||
setCurrDocName(doc.name);
|
setCurrDocName(doc.name);
|
||||||
setCurrDocData(doc.data);
|
// IMPORTANT: keep an immutable copy. pdf.js may transfer/detach the
|
||||||
|
// buffer passed into the worker; we always pass clones to react-pdf.
|
||||||
|
setCurrDocData(doc.data.slice(0));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get document:', error);
|
console.error('Failed to get document:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the current document state
|
* Clears the current document state
|
||||||
* Resets all document-related states and stops any ongoing TTS playback
|
* Resets all document-related states and stops any ongoing TTS playback
|
||||||
*/
|
*/
|
||||||
const clearCurrDoc = useCallback(() => {
|
const clearCurrDoc = useCallback(() => {
|
||||||
|
pdfDocGenerationRef.current += 1;
|
||||||
|
pdfDocumentRef.current = undefined;
|
||||||
|
setCurrDocId(undefined);
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
setCurrDocText(undefined);
|
setCurrDocText(undefined);
|
||||||
|
|
@ -264,7 +312,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
setPdfDocument(undefined);
|
setPdfDocument(undefined);
|
||||||
pageTextCacheRef.current.clear();
|
pageTextCacheRef.current.clear();
|
||||||
stop();
|
stop();
|
||||||
}, [setCurrDocPages, stop]);
|
}, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument, stop]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
|
* Creates a complete audiobook by processing all PDF pages through NLP and TTS
|
||||||
|
|
@ -302,7 +350,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
// First pass: extract and measure all text
|
// First pass: extract and measure all text
|
||||||
const textPerPage: string[] = [];
|
const textPerPage: string[] = [];
|
||||||
let totalLength = 0;
|
let totalLength = 0;
|
||||||
|
|
||||||
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
for (let pageNum = 1; pageNum <= pdfDocument.numPages; pageNum++) {
|
||||||
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
const rawText = await extractTextFromPDF(pdfDocument, pageNum, {
|
||||||
header: headerMargin,
|
header: headerMargin,
|
||||||
|
|
@ -356,7 +404,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = textPerPage[i];
|
const text = textPerPage[i];
|
||||||
|
|
||||||
// Skip pages that already exist on disk (supports non-contiguous indices)
|
// Skip pages that already exist on disk (supports non-contiguous indices)
|
||||||
if (existingIndices.has(i)) {
|
if (existingIndices.has(i)) {
|
||||||
processedLength += text.length;
|
processedLength += text.length;
|
||||||
|
|
@ -420,7 +468,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
chapterIndex: i,
|
chapterIndex: i,
|
||||||
settings
|
settings
|
||||||
}, signal);
|
}, signal);
|
||||||
|
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
bookId = chapter.bookId!;
|
bookId = chapter.bookId!;
|
||||||
}
|
}
|
||||||
|
|
@ -442,7 +490,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
throw new Error('Audiobook generation cancelled');
|
throw new Error('Audiobook generation cancelled');
|
||||||
}
|
}
|
||||||
console.error('Error processing page:', error);
|
console.error('Error processing page:', error);
|
||||||
|
|
||||||
// Notify about error
|
// Notify about error
|
||||||
if (onChapterComplete) {
|
if (onChapterComplete) {
|
||||||
onChapterComplete({
|
onChapterComplete({
|
||||||
|
|
@ -618,6 +666,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
() => ({
|
() => ({
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
|
currDocId,
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
|
@ -636,6 +685,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
[
|
[
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
|
currDocId,
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
|
|
||||||
|
|
@ -754,7 +754,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* @param {string} sentence - The sentence to generate audio for
|
* @param {string} sentence - The sentence to generate audio for
|
||||||
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
|
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
|
||||||
*/
|
*/
|
||||||
const getAudio = useCallback(async (sentence: string): Promise<TTSAudioBuffer | undefined> => {
|
const getAudio = useCallback(async (sentence: string, preload = false): Promise<TTSAudioBuffer | undefined> => {
|
||||||
const alignmentEnabledForCurrentDoc =
|
const alignmentEnabledForCurrentDoc =
|
||||||
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
|
||||||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
|
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
|
||||||
|
|
@ -897,7 +897,12 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsPlaying(false);
|
// If a preload request fails, we should not flip the global playback state.
|
||||||
|
// Otherwise the UI can lose the pause button while the current sentence
|
||||||
|
// continues playing.
|
||||||
|
if (!preload) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle daily quota exceeded (429 + Problem Details code)
|
// Handle daily quota exceeded (429 + Problem Details code)
|
||||||
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
|
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
|
||||||
|
|
@ -973,7 +978,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
// Create the audio processing promise
|
// Create the audio processing promise
|
||||||
const processPromise = (async () => {
|
const processPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const audioBuffer = await getAudio(sentence);
|
const audioBuffer = await getAudio(sentence, preload);
|
||||||
if (!audioBuffer) {
|
if (!audioBuffer) {
|
||||||
// If quota or other handled error returns undefined, ensure we don't throw "No audio data"
|
// 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
|
// Just return empty string to signal graceful failure/skip
|
||||||
|
|
|
||||||
|
|
@ -298,8 +298,22 @@ export async function extractTextFromPDF(
|
||||||
|
|
||||||
return pageText.replace(/\s+/g, ' ').trim();
|
return pageText.replace(/\s+/g, ' ').trim();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// During Next.js fast refresh / route transitions, react-pdf can tear down the
|
||||||
|
// underlying worker and pdf.js may throw a TypeError like:
|
||||||
|
// "null is not an object (evaluating 'this.messageHandler.sendWithPromise')".
|
||||||
|
// Treat this as a cancellation so the app can ignore it.
|
||||||
|
if (
|
||||||
|
error instanceof TypeError &&
|
||||||
|
typeof error.message === 'string' &&
|
||||||
|
error.message.includes('messageHandler') &&
|
||||||
|
error.message.includes('sendWithPromise')
|
||||||
|
) {
|
||||||
|
throw new DOMException('PDF worker torn down', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
console.error('Error extracting text from PDF:', error);
|
console.error('Error extracting text from PDF:', error);
|
||||||
throw new Error('Failed to extract text from PDF');
|
// Preserve the original error so callers can decide whether to retry/ignore.
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
41
src/lib/server/device-id.ts
Normal file
41
src/lib/server/device-id.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export const DEVICE_ID_COOKIE = 'or_device_id';
|
||||||
|
|
||||||
|
function isPlausibleDeviceId(value: string | undefined | null): value is string {
|
||||||
|
if (!value) return false;
|
||||||
|
// UUID v4 is typical here, but accept any reasonably sized token.
|
||||||
|
return value.length >= 16 && value.length <= 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeviceId(req: NextRequest): string | null {
|
||||||
|
const existing = req.cookies.get(DEVICE_ID_COOKIE)?.value;
|
||||||
|
return isPlausibleDeviceId(existing) ? existing : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stable anonymous device identifier.
|
||||||
|
*
|
||||||
|
* This survives localStorage/IndexedDB clears, but not full cookie clears.
|
||||||
|
*/
|
||||||
|
export function getOrCreateDeviceId(req: NextRequest): { deviceId: string; didCreate: boolean } {
|
||||||
|
const existing = getDeviceId(req);
|
||||||
|
if (existing) return { deviceId: existing, didCreate: false };
|
||||||
|
return { deviceId: randomUUID(), didCreate: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextResponse.cookies.set has an overloaded signature; keep this loosely typed.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function setDeviceIdCookie(res: { cookies: { set: (...args: any[]) => any } }, deviceId: string): void {
|
||||||
|
res.cookies.set({
|
||||||
|
name: DEVICE_ID_COOKIE,
|
||||||
|
value: deviceId,
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
// ~2 years
|
||||||
|
maxAge: 60 * 60 * 24 * 365 * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,12 @@ import { isAuthEnabled } from '@/lib/server/auth-config';
|
||||||
// Rate limits configuration - character counts per day
|
// Rate limits configuration - character counts per day
|
||||||
export const RATE_LIMITS = {
|
export const RATE_LIMITS = {
|
||||||
ANONYMOUS: 50_000, // 50K characters per day for anonymous users
|
ANONYMOUS: 50_000, // 50K characters per day for anonymous users
|
||||||
AUTHENTICATED: 500_000 // 500K characters per day for authenticated users
|
AUTHENTICATED: 500_000, // 500K characters per day for authenticated users
|
||||||
|
// IP-based backstop limits to make it harder to reset limits by creating new accounts
|
||||||
|
// or clearing storage/cookies. These are intentionally conservative defaults and can
|
||||||
|
// be tuned via env vars.
|
||||||
|
IP_ANONYMOUS: Number(process.env.TTS_IP_DAILY_LIMIT_ANONYMOUS || 100_000),
|
||||||
|
IP_AUTHENTICATED: Number(process.env.TTS_IP_DAILY_LIMIT_AUTHENTICATED || 1_000_000),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Singleton flag to ensure we only initialize the table once per process
|
// Singleton flag to ensure we only initialize the table once per process
|
||||||
|
|
@ -61,6 +66,63 @@ export interface UserInfo {
|
||||||
isPro?: boolean;
|
isPro?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RateLimitBackstops {
|
||||||
|
/** Stable device identifier cookie value (server-issued). */
|
||||||
|
deviceId?: string | null;
|
||||||
|
/** Best-effort client IP (from proxy headers). */
|
||||||
|
ip?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
key: string;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBackstopKey(prefix: string, value: string): string {
|
||||||
|
// Keep the key reasonably bounded; prevent extremely long identifiers from bloating DB.
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const safe = trimmed.length > 128 ? trimmed.slice(0, 128) : trimmed;
|
||||||
|
return `${prefix}:${safe}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickEffectiveResult(results: Array<{ currentCount: number; limit: number }>): {
|
||||||
|
currentCount: number;
|
||||||
|
limit: number;
|
||||||
|
remainingChars: number;
|
||||||
|
allowed: boolean;
|
||||||
|
} {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
currentCount: 0,
|
||||||
|
limit: Number.MAX_SAFE_INTEGER,
|
||||||
|
remainingChars: Number.MAX_SAFE_INTEGER,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let binding = results[0];
|
||||||
|
let bindingRemaining = Math.max(0, binding.limit - binding.currentCount);
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const remaining = Math.max(0, r.limit - r.currentCount);
|
||||||
|
if (remaining < bindingRemaining) {
|
||||||
|
binding = r;
|
||||||
|
bindingRemaining = remaining;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: results.every(r => r.currentCount < r.limit),
|
||||||
|
currentCount: binding.currentCount,
|
||||||
|
limit: binding.limit,
|
||||||
|
remainingChars: bindingRemaining,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class RateLimitExceeded extends Error {
|
||||||
|
name = 'RateLimitExceeded' as const;
|
||||||
|
}
|
||||||
|
|
||||||
export class RateLimiter {
|
export class RateLimiter {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
|
@ -68,7 +130,7 @@ export class RateLimiter {
|
||||||
* Check if a user can use TTS and increment their char count if allowed
|
* Check if a user can use TTS and increment their char count if allowed
|
||||||
* @param charCount - Number of characters to add
|
* @param charCount - Number of characters to add
|
||||||
*/
|
*/
|
||||||
async checkAndIncrementLimit(user: UserInfo, charCount: number): Promise<RateLimitResult> {
|
async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
|
||||||
// If auth is not enabled, always allow
|
// If auth is not enabled, always allow
|
||||||
if (!isAuthEnabled()) {
|
if (!isAuthEnabled()) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -82,64 +144,87 @@ export class RateLimiter {
|
||||||
|
|
||||||
await initializeRateLimitTable();
|
await initializeRateLimitTable();
|
||||||
|
|
||||||
return db.transaction(async (client) => {
|
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||||
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
||||||
const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
|
||||||
|
|
||||||
// Get or create today's record for this user
|
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
|
||||||
// Postgres supports RETURNING, but SQLite behavior can vary with ON CONFLICT
|
|
||||||
// We'll use a standard upsert pattern compatible with both via the adapter logic or simple queries
|
|
||||||
|
|
||||||
// First, ensure record exists
|
const deviceId = backstops?.deviceId?.toString() || null;
|
||||||
// SQLite/Postgres compatible UPSERT
|
const ip = backstops?.ip?.toString() || null;
|
||||||
await client.query(`
|
|
||||||
INSERT INTO user_tts_chars (user_id, date, char_count)
|
|
||||||
VALUES ($1, $2, 0)
|
|
||||||
ON CONFLICT (user_id, date)
|
|
||||||
DO UPDATE SET updated_at = CURRENT_TIMESTAMP
|
|
||||||
`, [user.id, today]);
|
|
||||||
|
|
||||||
// Allow the request that crosses the limit, but block any requests once the user is
|
if (user.isAnonymous && deviceId) {
|
||||||
// already at/over the limit. Do this atomically to avoid concurrent over-limit requests.
|
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
|
||||||
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)
|
if (ip) {
|
||||||
const result = await client.query(`
|
buckets.push({
|
||||||
SELECT char_count FROM user_tts_chars
|
key: normalizeBackstopKey('ip', ip),
|
||||||
WHERE user_id = $1 AND date = $2
|
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
|
||||||
`, [user.id, today]);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10);
|
try {
|
||||||
const updated = (updateResult.rowCount ?? 0) > 0;
|
return await db.transaction(async (client) => {
|
||||||
|
// Ensure records exist for each bucket
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO user_tts_chars (user_id, date, char_count)
|
||||||
|
VALUES ($1, $2, 0)
|
||||||
|
ON CONFLICT (user_id, date)
|
||||||
|
DO UPDATE SET updated_at = CURRENT_TIMESTAMP
|
||||||
|
`, [bucket.key, today]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to increment each bucket. If any bucket is already at/over the limit,
|
||||||
|
// throw to force a transaction rollback (no partial increments).
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
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
|
||||||
|
`, [bucket.key, today, charCount, bucket.limit]);
|
||||||
|
|
||||||
|
const updated = (updateResult.rowCount ?? 0) > 0;
|
||||||
|
if (!updated) {
|
||||||
|
throw new RateLimitExceeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current counts for all buckets after increment
|
||||||
|
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
const result = await client.query(`
|
||||||
|
SELECT char_count FROM user_tts_chars
|
||||||
|
WHERE user_id = $1 AND date = $2
|
||||||
|
`, [bucket.key, today]);
|
||||||
|
|
||||||
|
const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10);
|
||||||
|
bucketResults.push({ currentCount, limit: bucket.limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const effective = pickEffectiveResult(bucketResults);
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: true,
|
||||||
currentCount,
|
currentCount: effective.currentCount,
|
||||||
limit,
|
limit: effective.limit,
|
||||||
resetTime: this.getResetTime(),
|
resetTime: this.getResetTime(),
|
||||||
remainingChars: Math.max(0, limit - currentCount)
|
remainingChars: effective.remainingChars,
|
||||||
};
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RateLimitExceeded) {
|
||||||
|
const current = await this.getCurrentUsage(user, backstops);
|
||||||
|
return { ...current, allowed: false };
|
||||||
}
|
}
|
||||||
|
throw error;
|
||||||
return {
|
}
|
||||||
allowed: true,
|
|
||||||
currentCount,
|
|
||||||
limit,
|
|
||||||
resetTime: this.getResetTime(),
|
|
||||||
remainingChars: Math.max(0, limit - currentCount)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current usage for a user without incrementing
|
* Get current usage for a user without incrementing
|
||||||
*/
|
*/
|
||||||
async getCurrentUsage(user: UserInfo): Promise<RateLimitResult> {
|
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
|
||||||
// If auth is not enabled, return unlimited
|
// If auth is not enabled, return unlimited
|
||||||
if (!isAuthEnabled()) {
|
if (!isAuthEnabled()) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -154,21 +239,44 @@ export class RateLimiter {
|
||||||
await initializeRateLimitTable();
|
await initializeRateLimitTable();
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const limit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
|
||||||
|
|
||||||
const result = await db.query(
|
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
|
||||||
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
|
|
||||||
[user.id, today]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentCount = result.rows.length > 0 ? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10) : 0;
|
const deviceId = backstops?.deviceId?.toString() || null;
|
||||||
|
const ip = backstops?.ip?.toString() || null;
|
||||||
|
|
||||||
|
if (user.isAnonymous && deviceId) {
|
||||||
|
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ip) {
|
||||||
|
buckets.push({
|
||||||
|
key: normalizeBackstopKey('ip', ip),
|
||||||
|
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
|
||||||
|
[bucket.key, today]
|
||||||
|
);
|
||||||
|
const currentCount = result.rows.length > 0
|
||||||
|
? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10)
|
||||||
|
: 0;
|
||||||
|
bucketResults.push({ currentCount, limit: bucket.limit });
|
||||||
|
}
|
||||||
|
|
||||||
|
const effective = pickEffectiveResult(bucketResults);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allowed: currentCount < limit,
|
allowed: effective.allowed,
|
||||||
currentCount,
|
currentCount: effective.currentCount,
|
||||||
limit,
|
limit: effective.limit,
|
||||||
resetTime: this.getResetTime(),
|
resetTime: this.getResetTime(),
|
||||||
remainingChars: Math.max(0, limit - currentCount)
|
remainingChars: effective.remainingChars,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
28
src/lib/server/request-ip.ts
Normal file
28
src/lib/server/request-ip.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort client IP extraction that works on Vercel and typical reverse proxies.
|
||||||
|
*
|
||||||
|
* Note: IP-based limits are a backstop only; they are not perfectly reliable.
|
||||||
|
*/
|
||||||
|
export function getClientIp(req: NextRequest): string | null {
|
||||||
|
// Standard proxy header. Vercel also sets this.
|
||||||
|
const forwardedFor = req.headers.get('x-forwarded-for');
|
||||||
|
if (forwardedFor) {
|
||||||
|
const first = forwardedFor.split(',')[0]?.trim();
|
||||||
|
if (first) return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIp = req.headers.get('x-real-ip');
|
||||||
|
if (realIp) return realIp.trim();
|
||||||
|
|
||||||
|
// Some proxies use this.
|
||||||
|
const cfConnectingIp = req.headers.get('cf-connecting-ip');
|
||||||
|
if (cfConnectingIp) return cfConnectingIp.trim();
|
||||||
|
|
||||||
|
// NextRequest may expose ip depending on runtime.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const reqAny = req as any;
|
||||||
|
const ip = typeof reqAny.ip === 'string' ? (reqAny.ip as string) : null;
|
||||||
|
return ip?.trim() || null;
|
||||||
|
}
|
||||||
25
template.env
25
template.env
|
|
@ -1,11 +1,20 @@
|
||||||
NEXT_PUBLIC_NODE_ENV=development
|
# Determins the environment mode, `production` limits many features
|
||||||
|
NEXT_PUBLIC_NODE_ENV=development # Not availble in Docker containers (due to being build-time only)
|
||||||
|
|
||||||
# OpenAI API Key for Text-to-Speech functionality
|
# Local / OpenAI TTS API Configuration (default)
|
||||||
API_KEY=api_key_here_if_needed
|
# Suggest using https://github.com/remsky/Kokoro-FastAPI
|
||||||
|
API_BASE=http://localhost:8880/v1
|
||||||
|
API_KEY=api_key_optional
|
||||||
|
|
||||||
# OpenAI API Base URL (default)
|
# Path to your local whisper.cpp CLI binary for STT timestamp generation
|
||||||
# To use a local TTS model server, I suggest using https://github.com/remsky/Kokoro-FastAPI
|
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
|
||||||
API_BASE=https://api.openai.com/v1
|
|
||||||
|
|
||||||
# Path to your local whisper.cpp CLI binary
|
# Auth (recommended for contributors and public instances)
|
||||||
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
|
# (Optional) Auth is only enabled when **both** BETTER_AUTH_SECRET and BETTER_AUTH_URL are set
|
||||||
|
BETTER_AUTH_URL=http://localhost:3003 # Your externally facing URL for this app
|
||||||
|
BETTER_AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -base64 32`
|
||||||
|
# (Optional) Backend DB used only when authentication is enabled, defaults to SQLite when not set
|
||||||
|
POSTGRES_URL=
|
||||||
|
# (Optional) Sign in w/ GitHub Configuration
|
||||||
|
GITHUB_CLIENT_ID=
|
||||||
|
GITHUB_CLIENT_SECRET=
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue