feat: add user preferences and document progress syncing
- Implemented user preferences management with a new API for GET and PUT requests. - Added user document progress tracking with a new API for retrieving and updating progress. - Introduced database schema changes for user preferences and document progress. - Enhanced EPUB and TTS contexts to support syncing user preferences and document progress. - Added functions to handle transferring user preferences and progress during account linking. - Updated client-side logic to schedule syncing of user preferences and document progress.
This commit is contained in:
parent
852092c558
commit
82f82a990e
23 changed files with 1209 additions and 50 deletions
|
|
@ -18,3 +18,20 @@ This page covers application-level configuration for provider access and authent
|
|||
- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage)
|
||||
- For database mode: [Database](./database)
|
||||
- For migration behavior and commands: [Migrations](./migrations)
|
||||
|
||||
## Sync notes
|
||||
|
||||
### Auth enabled
|
||||
|
||||
- Settings and reading progress are saved to the server.
|
||||
- Updates are not instant push-based sync; they use normal client polling/refresh behavior.
|
||||
- If two devices change the same item around the same time, the newest update wins.
|
||||
|
||||
### Auth disabled
|
||||
|
||||
- Settings and reading progress stay local in the browser (Dexie/IndexedDB).
|
||||
- This avoids no-auth cross-browser conflicts, but there is no cross-device sync.
|
||||
|
||||
## Claim modal note
|
||||
|
||||
- You may still see old anonymous settings/progress available to claim from older deployments.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ This page covers database mode selection for OpenReader WebUI.
|
|||
|
||||
- Document and audiobook metadata/state used by server routes.
|
||||
- Auth/session tables when auth is enabled.
|
||||
- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
|
||||
- User settings preferences (`user_preferences`) when auth is enabled.
|
||||
- User reading progress (`user_document_progress`) when auth is enabled.
|
||||
|
||||
## Related variables
|
||||
|
||||
|
|
@ -24,3 +27,10 @@ For database variable behavior, see [Environment Variables](../reference/environ
|
|||
|
||||
- [Migrations](./migrations)
|
||||
- [Object / Blob Storage](./object-blob-storage)
|
||||
- [Auth](./auth)
|
||||
|
||||
## State sync summary
|
||||
|
||||
- With auth enabled, settings and reading progress are stored in SQL and synced from the app.
|
||||
- With auth disabled, settings and reading progress remain local in the browser.
|
||||
- Sync is currently request-based (not realtime push invalidation).
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ title: Stack
|
|||
## Next.js server
|
||||
|
||||
- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying
|
||||
- State sync: request-based today (not realtime push updates)
|
||||
- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters
|
||||
- Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`)
|
||||
- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3
|
||||
|
|
|
|||
21
drizzle/postgres/0001_user_state_minimal.sql
Normal file
21
drizzle/postgres/0001_user_state_minimal.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
CREATE TABLE "user_document_progress" (
|
||||
"user_id" text NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"reader_type" text NOT NULL,
|
||||
"location" text NOT NULL,
|
||||
"progress" real,
|
||||
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now(),
|
||||
CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_preferences" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"data_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "idx_user_document_progress_user_id_updated_at" ON "user_document_progress" USING btree ("user_id","updated_at");
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1770191341206,
|
||||
"tag": "0000_perfect_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1770843600000,
|
||||
"tag": "0001_user_state_minimal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
drizzle/sqlite/0001_user_state_minimal.sql
Normal file
21
drizzle/sqlite/0001_user_state_minimal.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
CREATE TABLE `user_document_progress` (
|
||||
`user_id` text NOT NULL,
|
||||
`document_id` text NOT NULL,
|
||||
`reader_type` text NOT NULL,
|
||||
`location` text NOT NULL,
|
||||
`progress` real,
|
||||
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
|
||||
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
|
||||
PRIMARY KEY(`user_id`, `document_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `user_preferences` (
|
||||
`user_id` text PRIMARY KEY NOT NULL,
|
||||
`data_json` text DEFAULT '{}' NOT NULL,
|
||||
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
|
||||
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_user_document_progress_user_id_updated_at` ON `user_document_progress` (`user_id`,`updated_at`);
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1770191340954,
|
||||
"tag": "0000_lively_korvac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1770843600000,
|
||||
"tag": "0001_user_state_minimal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
import { claimAnonymousData } from '@/lib/server/claim-data';
|
||||
import { auth } from '@/lib/server/auth';
|
||||
import { db } from '@/db';
|
||||
import { audiobooks, documents } from '@/db/schema';
|
||||
import { audiobooks, documents, userDocumentProgress, userPreferences } from '@/db/schema';
|
||||
import { count, eq, ne } from 'drizzle-orm';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
|
||||
|
||||
|
|
@ -22,7 +22,9 @@ async function checkClaimMigrationReadiness(): Promise<NextResponse | null> {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents: number; audiobooks: number }> {
|
||||
async function getClaimableCounts(
|
||||
unclaimedUserId: string,
|
||||
): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> {
|
||||
const [docCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(documents)
|
||||
|
|
@ -31,10 +33,20 @@ async function getClaimableCounts(unclaimedUserId: string): Promise<{ documents:
|
|||
.select({ count: count() })
|
||||
.from(audiobooks)
|
||||
.where(eq(audiobooks.userId, unclaimedUserId));
|
||||
const [preferencesCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, unclaimedUserId));
|
||||
const [progressCount] = await db
|
||||
.select({ count: count() })
|
||||
.from(userDocumentProgress)
|
||||
.where(eq(userDocumentProgress.userId, unclaimedUserId));
|
||||
|
||||
return {
|
||||
documents: Number(docCount?.count ?? 0),
|
||||
audiobooks: Number(bookCount?.count ?? 0),
|
||||
preferences: Number(preferencesCount?.count ?? 0),
|
||||
progress: Number(progressCount?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
195
src/app/api/user/state/preferences/route.ts
Normal file
195
src/app/api/user/state/preferences/route.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { userPreferences } from '@/db/schema';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
import { resolveUserStateScope } from '@/lib/server/user-state-scope';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function nowForDb(): Date | number {
|
||||
return process.env.POSTGRES_URL ? new Date() : Date.now();
|
||||
}
|
||||
|
||||
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string {
|
||||
if (process.env.POSTGRES_URL) return patch;
|
||||
return JSON.stringify(patch);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function parseStoredPreferences(value: unknown): SyncedPreferencesPatch {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return isRecord(parsed) ? sanitizePreferencesPatch(parsed) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return isRecord(value) ? sanitizePreferencesPatch(value) : {};
|
||||
}
|
||||
|
||||
function sanitizeSavedVoices(value: unknown): Record<string, string> {
|
||||
if (!isRecord(value)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (typeof key !== 'string' || key.length === 0) continue;
|
||||
if (typeof val !== 'string') continue;
|
||||
out[key] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
||||
if (!isRecord(input)) return {};
|
||||
|
||||
const out: SyncedPreferencesPatch = {};
|
||||
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in input)) continue;
|
||||
const value = input[key];
|
||||
|
||||
switch (key) {
|
||||
case 'viewType':
|
||||
if (value === 'single' || value === 'dual' || value === 'scroll') out[key] = value;
|
||||
break;
|
||||
case 'voice':
|
||||
case 'ttsProvider':
|
||||
case 'ttsModel':
|
||||
case 'ttsInstructions':
|
||||
if (typeof value === 'string') out[key] = value;
|
||||
break;
|
||||
case 'voiceSpeed':
|
||||
case 'audioPlayerSpeed':
|
||||
case 'headerMargin':
|
||||
case 'footerMargin':
|
||||
case 'leftMargin':
|
||||
case 'rightMargin':
|
||||
if (Number.isFinite(value)) out[key] = Number(value);
|
||||
break;
|
||||
case 'skipBlank':
|
||||
case 'epubTheme':
|
||||
case 'smartSentenceSplitting':
|
||||
case 'pdfHighlightEnabled':
|
||||
case 'pdfWordHighlightEnabled':
|
||||
case 'epubHighlightEnabled':
|
||||
case 'epubWordHighlightEnabled':
|
||||
if (typeof value === 'boolean') out[key] = value;
|
||||
break;
|
||||
case 'savedVoices':
|
||||
out[key] = sanitizeSavedVoices(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||
if (!Number.isFinite(value)) return Date.now();
|
||||
const normalized = Number(value);
|
||||
if (normalized <= 0) return Date.now();
|
||||
return Math.floor(normalized);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const scope = await resolveUserStateScope(req);
|
||||
if (scope instanceof Response) return scope;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
dataJson: userPreferences.dataJson,
|
||||
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
|
||||
})
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, scope.ownerUserId))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
const storedPatch = parseStoredPreferences(row?.dataJson);
|
||||
const clientUpdatedAtMs = Number(row?.clientUpdatedAtMs ?? 0);
|
||||
|
||||
return NextResponse.json({
|
||||
preferences: storedPatch,
|
||||
clientUpdatedAtMs,
|
||||
hasStoredPreferences: Boolean(row),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user preferences:', error);
|
||||
return NextResponse.json({ error: 'Failed to load user preferences' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const scope = await resolveUserStateScope(req);
|
||||
if (scope instanceof Response) return scope;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as
|
||||
| { patch?: unknown; clientUpdatedAtMs?: unknown }
|
||||
| null;
|
||||
const patch = sanitizePreferencesPatch(body?.patch);
|
||||
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return NextResponse.json({ error: 'No valid preferences provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingRows = await db
|
||||
.select({
|
||||
dataJson: userPreferences.dataJson,
|
||||
clientUpdatedAtMs: userPreferences.clientUpdatedAtMs,
|
||||
})
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, scope.ownerUserId))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
||||
const existingPatch = parseStoredPreferences(existing?.dataJson);
|
||||
|
||||
if (existing && clientUpdatedAtMs < existingUpdated) {
|
||||
return NextResponse.json({
|
||||
preferences: existingPatch,
|
||||
clientUpdatedAtMs: existingUpdated,
|
||||
applied: false,
|
||||
});
|
||||
}
|
||||
|
||||
const mergedPatch = { ...existingPatch, ...patch };
|
||||
const dataJson = serializePreferencesForDb(mergedPatch);
|
||||
const updatedAt = nowForDb();
|
||||
|
||||
await db
|
||||
.insert(userPreferences)
|
||||
.values({
|
||||
userId: scope.ownerUserId,
|
||||
dataJson,
|
||||
clientUpdatedAtMs,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId],
|
||||
set: {
|
||||
dataJson,
|
||||
clientUpdatedAtMs,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
preferences: mergedPatch,
|
||||
clientUpdatedAtMs,
|
||||
applied: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user preferences:', error);
|
||||
return NextResponse.json({ error: 'Failed to update user preferences' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
188
src/app/api/user/state/progress/route.ts
Normal file
188
src/app/api/user/state/progress/route.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { userDocumentProgress } from '@/db/schema';
|
||||
import type { ReaderType } from '@/types/user-state';
|
||||
import { isValidDocumentId } from '@/lib/server/documents-blobstore';
|
||||
import { resolveUserStateScope } from '@/lib/server/user-state-scope';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function nowForDb(): Date | number {
|
||||
return process.env.POSTGRES_URL ? new Date() : Date.now();
|
||||
}
|
||||
|
||||
function normalizeReaderType(value: unknown): ReaderType | null {
|
||||
if (value === 'pdf' || value === 'epub' || value === 'html') return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||
if (!Number.isFinite(value)) return Date.now();
|
||||
const normalized = Number(value);
|
||||
if (normalized <= 0) return Date.now();
|
||||
return Math.floor(normalized);
|
||||
}
|
||||
|
||||
function toUpdatedAtMs(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (value instanceof Date) return value.getTime();
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const scope = await resolveUserStateScope(req);
|
||||
if (scope instanceof Response) return scope;
|
||||
|
||||
const documentId = (new URL(req.url).searchParams.get('documentId') || '').trim().toLowerCase();
|
||||
if (!isValidDocumentId(documentId)) {
|
||||
return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
documentId: userDocumentProgress.documentId,
|
||||
readerType: userDocumentProgress.readerType,
|
||||
location: userDocumentProgress.location,
|
||||
progress: userDocumentProgress.progress,
|
||||
clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs,
|
||||
updatedAt: userDocumentProgress.updatedAt,
|
||||
})
|
||||
.from(userDocumentProgress)
|
||||
.where(and(
|
||||
eq(userDocumentProgress.userId, scope.ownerUserId),
|
||||
eq(userDocumentProgress.documentId, documentId),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return NextResponse.json({ progress: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
documentId: row.documentId,
|
||||
readerType: row.readerType,
|
||||
location: row.location,
|
||||
progress: row.progress == null ? null : Number(row.progress),
|
||||
clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0),
|
||||
updatedAtMs: toUpdatedAtMs(row.updatedAt),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading user progress:', error);
|
||||
return NextResponse.json({ error: 'Failed to load user progress' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const scope = await resolveUserStateScope(req);
|
||||
if (scope instanceof Response) return scope;
|
||||
|
||||
const body = (await req.json().catch(() => null)) as
|
||||
| {
|
||||
documentId?: unknown;
|
||||
readerType?: unknown;
|
||||
location?: unknown;
|
||||
progress?: unknown;
|
||||
clientUpdatedAtMs?: unknown;
|
||||
}
|
||||
| null;
|
||||
|
||||
const documentId = typeof body?.documentId === 'string' ? body.documentId.trim().toLowerCase() : '';
|
||||
if (!isValidDocumentId(documentId)) {
|
||||
return NextResponse.json({ error: 'Invalid documentId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const readerType = normalizeReaderType(body?.readerType);
|
||||
if (!readerType) {
|
||||
return NextResponse.json({ error: "Invalid readerType. Expected 'pdf', 'epub', or 'html'." }, { status: 400 });
|
||||
}
|
||||
|
||||
const location = typeof body?.location === 'string' ? body.location.trim() : '';
|
||||
if (!location) {
|
||||
return NextResponse.json({ error: 'Invalid location' }, { status: 400 });
|
||||
}
|
||||
|
||||
const progress =
|
||||
body?.progress == null
|
||||
? null
|
||||
: Number.isFinite(body.progress)
|
||||
? Math.max(0, Math.min(1, Number(body.progress)))
|
||||
: null;
|
||||
const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs);
|
||||
|
||||
const existingRows = await db
|
||||
.select({
|
||||
clientUpdatedAtMs: userDocumentProgress.clientUpdatedAtMs,
|
||||
location: userDocumentProgress.location,
|
||||
readerType: userDocumentProgress.readerType,
|
||||
progress: userDocumentProgress.progress,
|
||||
updatedAt: userDocumentProgress.updatedAt,
|
||||
})
|
||||
.from(userDocumentProgress)
|
||||
.where(and(
|
||||
eq(userDocumentProgress.userId, scope.ownerUserId),
|
||||
eq(userDocumentProgress.documentId, documentId),
|
||||
))
|
||||
.limit(1);
|
||||
const existing = existingRows[0];
|
||||
const existingUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
||||
|
||||
if (existing && clientUpdatedAtMs < existingUpdated) {
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
documentId,
|
||||
readerType: existing.readerType,
|
||||
location: existing.location,
|
||||
progress: existing.progress == null ? null : Number(existing.progress),
|
||||
clientUpdatedAtMs: existingUpdated,
|
||||
updatedAtMs: toUpdatedAtMs(existing.updatedAt),
|
||||
},
|
||||
applied: false,
|
||||
});
|
||||
}
|
||||
|
||||
const updatedAt = nowForDb();
|
||||
await db
|
||||
.insert(userDocumentProgress)
|
||||
.values({
|
||||
userId: scope.ownerUserId,
|
||||
documentId,
|
||||
readerType,
|
||||
location,
|
||||
progress,
|
||||
clientUpdatedAtMs,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userDocumentProgress.userId, userDocumentProgress.documentId],
|
||||
set: {
|
||||
readerType,
|
||||
location,
|
||||
progress,
|
||||
clientUpdatedAtMs,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
progress: {
|
||||
documentId,
|
||||
readerType,
|
||||
location,
|
||||
progress,
|
||||
clientUpdatedAtMs,
|
||||
updatedAtMs: toUpdatedAtMs(updatedAt),
|
||||
},
|
||||
applied: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating user progress:', error);
|
||||
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -11,6 +11,24 @@ import {
|
|||
} from '@headlessui/react';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
type ClaimableCounts = {
|
||||
documents: number;
|
||||
audiobooks: number;
|
||||
preferences: number;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
function toClaimableCounts(value: unknown): ClaimableCounts {
|
||||
const rec = (value && typeof value === 'object') ? (value as Record<string, unknown>) : {};
|
||||
return {
|
||||
documents: Number(rec.documents ?? 0),
|
||||
audiobooks: Number(rec.audiobooks ?? 0),
|
||||
preferences: Number(rec.preferences ?? 0),
|
||||
progress: Number(rec.progress ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ClaimDataModal() {
|
||||
const { data: sessionData } = useAuthSession();
|
||||
|
|
@ -18,6 +36,12 @@ export default function ClaimDataModal() {
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [hasChecked, setHasChecked] = useState(false);
|
||||
const [isClaiming, setIsClaiming] = useState(false);
|
||||
const [claimableCounts, setClaimableCounts] = useState<ClaimableCounts>({
|
||||
documents: 0,
|
||||
audiobooks: 0,
|
||||
preferences: 0,
|
||||
progress: 0,
|
||||
});
|
||||
const user = sessionData?.user;
|
||||
|
||||
const checkClaimableData = useCallback(async () => {
|
||||
|
|
@ -29,7 +53,10 @@ export default function ClaimDataModal() {
|
|||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.documents > 0 || data.audiobooks > 0) {
|
||||
const counts = toClaimableCounts(data);
|
||||
setClaimableCounts(counts);
|
||||
|
||||
if (counts.documents + counts.audiobooks + counts.preferences + counts.progress > 0) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -53,13 +80,22 @@ export default function ClaimDataModal() {
|
|||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
alert(`Successfully claimed ${data.claimed.documents} documents and ${data.claimed.audiobooks} audiobooks!`);
|
||||
const claimed = toClaimableCounts(data?.claimed);
|
||||
toast.success(
|
||||
`Successfully claimed ${claimed.documents} documents, `
|
||||
+ `${claimed.audiobooks} audiobooks, `
|
||||
+ `${claimed.preferences} preference set(s), and `
|
||||
+ `${claimed.progress} reading progress record(s)!`,
|
||||
);
|
||||
|
||||
setIsOpen(false);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
const data = await res.json().catch(() => null) as { error?: string } | null;
|
||||
toast.error(data?.error || 'Failed to claim data.');
|
||||
} catch {
|
||||
alert('Failed to claim data.');
|
||||
toast.error('Failed to claim data.');
|
||||
} finally {
|
||||
setIsClaiming(false);
|
||||
}
|
||||
|
|
@ -106,12 +142,22 @@ export default function ClaimDataModal() {
|
|||
</DialogTitle>
|
||||
|
||||
<p className="text-sm text-muted mb-2">
|
||||
We found documents and audiobooks that were created before auth was enabled.
|
||||
Would you like to claim them and add them to your account?
|
||||
We found existing anonymous data from before auth was enabled.
|
||||
Claim it now to attach it to your account.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 rounded-lg border border-offbase bg-offbase/40 p-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Claimable data</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-foreground/90">
|
||||
<li>{claimableCounts.documents} document(s)</li>
|
||||
<li>{claimableCounts.audiobooks} audiobook(s)</li>
|
||||
<li>{claimableCounts.preferences} preference set(s)</li>
|
||||
<li>{claimableCounts.progress} reading progress record(s)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted/70 mb-6 italic">
|
||||
⚠️ First user to claim these files will own them and revoke access for anyone else.
|
||||
⚠️ First user to claim this data will own it and revoke access for anyone else.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
|
|
@ -137,7 +183,7 @@ export default function ClaimDataModal() {
|
|||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
|
||||
disabled:opacity-50"
|
||||
>
|
||||
{isClaiming ? 'Claiming...' : 'Claim All'}
|
||||
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, ReactNode } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie';
|
||||
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config';
|
||||
import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state';
|
||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import toast from 'react-hot-toast';
|
||||
export type { ViewType } from '@/types/config';
|
||||
|
||||
|
|
@ -50,10 +54,51 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDBReady, setIsDBReady] = useState(false);
|
||||
const didRunStartupMigrations = useRef(false);
|
||||
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
|
||||
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||
|
||||
// Helper function to generate provider-model key
|
||||
const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`;
|
||||
|
||||
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
|
||||
if (!authEnabled) return;
|
||||
|
||||
const syncedPatch: SyncedPreferencesPatch = {};
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in patch)) continue;
|
||||
const value = patch[key];
|
||||
if (value === undefined) continue;
|
||||
(syncedPatch as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
if (Object.keys(syncedPatch).length === 0) return;
|
||||
scheduleUserPreferencesSync(syncedPatch);
|
||||
}, [authEnabled]);
|
||||
|
||||
const buildSyncedPreferencePatch = useCallback((
|
||||
source: Partial<AppConfigValues>,
|
||||
options?: { nonDefaultOnly?: boolean },
|
||||
): SyncedPreferencesPatch => {
|
||||
const out: SyncedPreferencesPatch = {};
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in source)) continue;
|
||||
const value = source[key];
|
||||
if (value === undefined) continue;
|
||||
if (options?.nonDefaultOnly) {
|
||||
const defaultValue = APP_CONFIG_DEFAULTS[key];
|
||||
const same =
|
||||
typeof value === 'object'
|
||||
? JSON.stringify(value) === JSON.stringify(defaultValue)
|
||||
: value === defaultValue;
|
||||
if (same) continue;
|
||||
}
|
||||
(out as Record<SyncedPreferenceKey, unknown>)[key] = value;
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ status?: string; ms?: number }>).detail;
|
||||
|
|
@ -107,6 +152,38 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
void run();
|
||||
}, [isDBReady]);
|
||||
|
||||
const refreshSyncedPreferencesFromServer = useCallback(async () => {
|
||||
if (!isDBReady || !authEnabled) return;
|
||||
try {
|
||||
const remote = await getUserPreferences();
|
||||
if (!remote?.hasStoredPreferences) return;
|
||||
if (!remote.preferences || Object.keys(remote.preferences).length === 0) return;
|
||||
await updateAppConfig(remote.preferences as Partial<AppConfigRow>);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load synced preferences:', error);
|
||||
}
|
||||
}, [isDBReady, authEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !authEnabled || isSessionPending) return;
|
||||
refreshSyncedPreferencesFromServer().catch((error) => {
|
||||
console.warn('Synced preferences refresh failed:', error);
|
||||
});
|
||||
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !authEnabled) return;
|
||||
const onFocus = () => {
|
||||
refreshSyncedPreferencesFromServer().catch((error) => {
|
||||
console.warn('Focus synced preferences refresh failed:', error);
|
||||
});
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]);
|
||||
|
||||
const appConfig = useLiveQuery(
|
||||
async () => {
|
||||
if (!isDBReady) return null;
|
||||
|
|
@ -124,6 +201,32 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
return { ...APP_CONFIG_DEFAULTS, ...rest };
|
||||
}, [appConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
|
||||
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
|
||||
didAttemptInitialPreferenceSeedForSession.current = sessionKey;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const remote = await getUserPreferences();
|
||||
if (remote?.hasStoredPreferences) return;
|
||||
|
||||
// Seed only user-customized (non-default) values. This prevents fresh/default
|
||||
// profiles from overwriting existing server values during first-run races.
|
||||
const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true });
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() });
|
||||
} catch (error) {
|
||||
console.warn('Failed to seed initial synced preferences from local Dexie:', error);
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.warn('Initial synced preferences seed failed:', error);
|
||||
});
|
||||
}, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]);
|
||||
|
||||
// Destructure for convenience and to match context shape
|
||||
const {
|
||||
apiKey,
|
||||
|
|
@ -163,7 +266,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (newConfig.baseUrl !== undefined) {
|
||||
updates.baseUrl = newConfig.baseUrl;
|
||||
}
|
||||
if (newConfig.viewType !== undefined) {
|
||||
updates.viewType = newConfig.viewType;
|
||||
}
|
||||
await updateAppConfig(updates);
|
||||
queueSyncedPreferencePatch(updates);
|
||||
} catch (error) {
|
||||
console.error('Error updating config:', error);
|
||||
throw error;
|
||||
|
|
@ -189,6 +296,10 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
});
|
||||
queueSyncedPreferencePatch({
|
||||
savedVoices: updatedSavedVoices,
|
||||
voice: value as string,
|
||||
});
|
||||
}
|
||||
// Special handling for provider/model changes - restore saved voice if available
|
||||
else if (key === 'ttsProvider' || key === 'ttsModel') {
|
||||
|
|
@ -200,17 +311,29 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigRow>);
|
||||
queueSyncedPreferencePatch({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
voice: restoredVoice,
|
||||
} as Partial<AppConfigValues>);
|
||||
}
|
||||
else if (key === 'savedVoices') {
|
||||
const newSavedVoices = value as SavedVoices;
|
||||
await updateAppConfig({
|
||||
savedVoices: newSavedVoices,
|
||||
});
|
||||
queueSyncedPreferencePatch({
|
||||
savedVoices: newSavedVoices,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await updateAppConfig({
|
||||
[key]: value as AppConfigValues[keyof AppConfigValues],
|
||||
} as Partial<AppConfigRow>);
|
||||
if (syncedPreferenceKeys.has(String(key))) {
|
||||
queueSyncedPreferencePatch({
|
||||
[key]: value,
|
||||
} as Partial<AppConfigValues>);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error updating config key ${String(key)}:`, error);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState, R
|
|||
import type { BaseDocument, DocumentType } from '@/types/documents';
|
||||
import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents';
|
||||
import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
|
||||
interface DocumentContextType {
|
||||
// PDF Documents
|
||||
|
|
@ -36,6 +37,8 @@ const DocumentContext = createContext<DocumentContextType | undefined>(undefined
|
|||
export function DocumentProvider({ children }: { children: ReactNode }) {
|
||||
const [docs, setDocs] = useState<BaseDocument[] | null>(null);
|
||||
const isLoading = docs === null;
|
||||
const { data: sessionData, isPending: isSessionPending } = useAuthSession();
|
||||
const sessionKey = sessionData?.user?.id ?? 'no-session';
|
||||
|
||||
const refreshDocuments = useCallback(async () => {
|
||||
const serverDocs = await listDocuments();
|
||||
|
|
@ -44,11 +47,12 @@ export function DocumentProvider({ children }: { children: ReactNode }) {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSessionPending) return;
|
||||
refreshDocuments().catch((err) => {
|
||||
console.error('Failed to load documents from server:', err);
|
||||
setDocs([]);
|
||||
});
|
||||
}, [refreshDocuments]);
|
||||
}, [refreshDocuments, sessionKey, isSessionPending]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ import type { SpineItem } from 'epubjs/types/section';
|
|||
import type { Book, Rendition } from 'epubjs';
|
||||
|
||||
import { setLastDocumentLocation } from '@/lib/dexie';
|
||||
import { scheduleDocumentProgressSync } from '@/lib/client-user-state';
|
||||
import { getDocumentMetadata } from '@/lib/client-documents';
|
||||
import { ensureCachedDocument } from '@/lib/document-cache';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { createRangeCfi } from '@/lib/epub';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useConfig } from './ConfigContext';
|
||||
|
|
@ -171,6 +173,7 @@ const collectContinuationFromRange = (range: Range | null | undefined, limit = E
|
|||
*/
|
||||
export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { id } = useParams();
|
||||
// Configuration context to get TTS settings
|
||||
const {
|
||||
|
|
@ -694,6 +697,13 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
if (id && locationRef.current !== 1) {
|
||||
console.log('Saving location:', location);
|
||||
setLastDocumentLocation(id as string, location.toString());
|
||||
if (authEnabled) {
|
||||
scheduleDocumentProgressSync({
|
||||
documentId: id as string,
|
||||
readerType: 'epub',
|
||||
location: location.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
skipToLocation(location);
|
||||
|
|
@ -703,7 +713,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
|||
extractPageText(bookRef.current, renditionRef.current, shouldPauseRef.current);
|
||||
shouldPauseRef.current = true;
|
||||
}
|
||||
}, [id, skipToLocation, extractPageText, setIsEPUB]);
|
||||
}, [id, skipToLocation, extractPageText, setIsEPUB, authEnabled]);
|
||||
|
||||
const clearWordHighlights = useCallback(() => {
|
||||
if (!renditionRef.current) return;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import {
|
|||
} from 'react';
|
||||
import { Howl } from 'howler';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useParams, usePathname } from 'next/navigation';
|
||||
|
||||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useAudioCache } from '@/hooks/audio/useAudioCache';
|
||||
|
|
@ -35,6 +35,7 @@ import { useVoiceManagement } from '@/hooks/audio/useVoiceManagement';
|
|||
import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
||||
import { getDocumentProgress, scheduleDocumentProgressSync } from '@/lib/client-user-state';
|
||||
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
||||
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
|
||||
import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksEPUB } from '@/lib/nlp';
|
||||
|
|
@ -53,6 +54,7 @@ import type {
|
|||
TTSRequestHeaders,
|
||||
TTSRetryOptions,
|
||||
} from '@/types/client';
|
||||
import type { ReaderType } from '@/types/user-state';
|
||||
|
||||
// Media globals
|
||||
declare global {
|
||||
|
|
@ -304,7 +306,14 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const audioContext = useAudioContext();
|
||||
const audioCache = useAudioCache(25);
|
||||
const { availableVoices, fetchVoices } = useVoiceManagement(openApiKey, openApiBaseUrl, configTTSProvider, configTTSModel);
|
||||
const { onTTSStart, onTTSComplete, refresh: refreshRateLimit, triggerRateLimit, isAtLimit } = useAuthRateLimit();
|
||||
const {
|
||||
authEnabled,
|
||||
onTTSStart,
|
||||
onTTSComplete,
|
||||
refresh: refreshRateLimit,
|
||||
triggerRateLimit,
|
||||
isAtLimit,
|
||||
} = useAuthRateLimit();
|
||||
|
||||
// Add ref for location change handler
|
||||
const locationChangeHandlerRef = useRef<((location: TTSLocation) => void) | null>(null);
|
||||
|
|
@ -332,6 +341,13 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
|
||||
// Get document ID from URL params
|
||||
const { id } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const currentReaderType: ReaderType = useMemo(() => {
|
||||
if (pathname.startsWith('/epub/')) return 'epub';
|
||||
if (pathname.startsWith('/html/')) return 'html';
|
||||
return 'pdf';
|
||||
}, [pathname]);
|
||||
|
||||
/**
|
||||
* State Management
|
||||
|
|
@ -1716,38 +1732,75 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
skipBackward,
|
||||
});
|
||||
|
||||
// Load last location on mount for both EPUB and PDF
|
||||
// Load last location on mount for both EPUB and PDF.
|
||||
// Prefer server-backed progress when available, then fall back to local Dexie.
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
getLastDocumentLocation(id as string).then(lastLocation => {
|
||||
if (lastLocation) {
|
||||
console.log('Setting last location:', lastLocation);
|
||||
if (!id) return;
|
||||
|
||||
if (isEPUB && locationChangeHandlerRef.current) {
|
||||
// For EPUB documents, use the location change handler
|
||||
locationChangeHandlerRef.current(lastLocation);
|
||||
} else if (!isEPUB) {
|
||||
// For PDF documents, parse the location as "page:sentence"
|
||||
try {
|
||||
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
||||
const page = parseInt(pageStr, 10);
|
||||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||||
let cancelled = false;
|
||||
const docId = id as string;
|
||||
|
||||
if (!isNaN(page) && !isNaN(sentenceIndex)) {
|
||||
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
|
||||
// Skip to the page first, then the sentence index will be restored when setText is called
|
||||
setCurrDocPage(page);
|
||||
// Store the sentence index to be used when text is loaded
|
||||
setPendingRestoreIndex(sentenceIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error parsing PDF location:', error);
|
||||
}
|
||||
const applyLocation = (lastLocation: string) => {
|
||||
console.log('Setting last location:', lastLocation);
|
||||
|
||||
if (isEPUB && locationChangeHandlerRef.current) {
|
||||
// For EPUB documents, use the location change handler
|
||||
locationChangeHandlerRef.current(lastLocation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEPUB) {
|
||||
// For PDF documents, parse the location as "page:sentence"
|
||||
try {
|
||||
const [pageStr, sentenceIndexStr] = lastLocation.split(':');
|
||||
const page = parseInt(pageStr, 10);
|
||||
const sentenceIndex = parseInt(sentenceIndexStr, 10);
|
||||
|
||||
if (!isNaN(page) && !isNaN(sentenceIndex)) {
|
||||
console.log(`Restoring PDF position: page ${page}, sentence ${sentenceIndex}`);
|
||||
// Skip to the page first, then the sentence index will be restored when setText is called
|
||||
setCurrDocPage(page);
|
||||
// Store the sentence index to be used when text is loaded
|
||||
setPendingRestoreIndex(sentenceIndex);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error parsing PDF location:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [id, isEPUB]);
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (authEnabled) {
|
||||
try {
|
||||
const remote = await getDocumentProgress(docId);
|
||||
if (!cancelled && remote?.location) {
|
||||
await setLastDocumentLocation(docId, remote.location).catch((error) => {
|
||||
console.warn('Error caching remote location locally:', error);
|
||||
});
|
||||
applyLocation(remote.location);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading remote progress:', error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const local = await getLastDocumentLocation(docId);
|
||||
if (!cancelled && local) {
|
||||
applyLocation(local);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error loading local last location:', error);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, isEPUB, currentReaderType, authEnabled]);
|
||||
|
||||
// Save current position periodically for PDFs
|
||||
useEffect(() => {
|
||||
|
|
@ -1758,11 +1811,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setLastDocumentLocation(id as string, location).catch(error => {
|
||||
console.warn('Error saving PDF location:', error);
|
||||
});
|
||||
if (authEnabled) {
|
||||
scheduleDocumentProgressSync({
|
||||
documentId: id as string,
|
||||
readerType: currentReaderType,
|
||||
location,
|
||||
});
|
||||
}
|
||||
}, 1000); // Debounce saves by 1 second
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length]);
|
||||
}, [id, isEPUB, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]);
|
||||
|
||||
/**
|
||||
* Renders the TTS context provider with its children
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ export const session = usePostgres ? postgresSchema.session : sqliteSchema.sessi
|
|||
export const account = usePostgres ? postgresSchema.account : sqliteSchema.account;
|
||||
export const verification = usePostgres ? postgresSchema.verification : sqliteSchema.verification;
|
||||
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
|
||||
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
|
||||
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const documents = pgTable('documents', {
|
||||
id: text('id').notNull(),
|
||||
|
|
@ -96,3 +96,25 @@ export const userTtsChars = pgTable("user_tts_chars", {
|
|||
pk: primaryKey({ columns: [table.userId, table.date] }),
|
||||
dateIdx: index('idx_user_tts_chars_date').on(table.date),
|
||||
}));
|
||||
|
||||
export const userPreferences = pgTable('user_preferences', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
dataJson: jsonb('data_json').notNull().default({}),
|
||||
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
});
|
||||
|
||||
export const userDocumentProgress = pgTable('user_document_progress', {
|
||||
userId: text('user_id').notNull(),
|
||||
documentId: text('document_id').notNull(),
|
||||
readerType: text('reader_type').notNull(), // pdf, epub, html
|
||||
location: text('location').notNull(),
|
||||
progress: real('progress'),
|
||||
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: timestamp('created_at').defaultNow(),
|
||||
updatedAt: timestamp('updated_at').defaultNow(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.documentId] }),
|
||||
userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -97,3 +97,25 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
|
|||
pk: primaryKey({ columns: [table.userId, table.date] }),
|
||||
dateIdx: index('idx_user_tts_chars_date').on(table.date),
|
||||
}));
|
||||
|
||||
export const userPreferences = sqliteTable('user_preferences', {
|
||||
userId: text('user_id').primaryKey(),
|
||||
dataJson: text('data_json').notNull().default('{}'),
|
||||
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
|
||||
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
||||
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
||||
});
|
||||
|
||||
export const userDocumentProgress = sqliteTable('user_document_progress', {
|
||||
userId: text('user_id').notNull(),
|
||||
documentId: text('document_id').notNull(),
|
||||
readerType: text('reader_type').notNull(), // pdf, epub, html
|
||||
location: text('location').notNull(),
|
||||
progress: real('progress'),
|
||||
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
|
||||
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
||||
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.documentId] }),
|
||||
userUpdatedIdx: index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
|
||||
}));
|
||||
|
|
|
|||
181
src/lib/client-user-state.ts
Normal file
181
src/lib/client-user-state.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { SYNCED_PREFERENCE_KEYS, type DocumentProgressRecord, type ReaderType, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||
|
||||
type PreferencesResponse = {
|
||||
preferences: SyncedPreferencesPatch;
|
||||
clientUpdatedAtMs: number;
|
||||
hasStoredPreferences?: boolean;
|
||||
};
|
||||
|
||||
type ProgressResponse = {
|
||||
progress: DocumentProgressRecord | null;
|
||||
};
|
||||
|
||||
function sanitizePreferencesPatch(input: SyncedPreferencesPatch): SyncedPreferencesPatch {
|
||||
const patch: SyncedPreferencesPatch = {};
|
||||
for (const key of SYNCED_PREFERENCE_KEYS) {
|
||||
if (!(key in input)) continue;
|
||||
const value = input[key];
|
||||
if (value === undefined) continue;
|
||||
(patch as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export async function getUserPreferences(options?: { signal?: AbortSignal }): Promise<PreferencesResponse> {
|
||||
const res = await fetch('/api/user/state/preferences', { signal: options?.signal });
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error || 'Failed to load user preferences');
|
||||
}
|
||||
return (await res.json()) as PreferencesResponse;
|
||||
}
|
||||
|
||||
export async function putUserPreferences(
|
||||
patch: SyncedPreferencesPatch,
|
||||
options?: { signal?: AbortSignal; clientUpdatedAtMs?: number },
|
||||
): Promise<PreferencesResponse & { applied: boolean }> {
|
||||
const cleanPatch = sanitizePreferencesPatch(patch);
|
||||
const res = await fetch('/api/user/state/preferences', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
patch: cleanPatch,
|
||||
clientUpdatedAtMs: options?.clientUpdatedAtMs ?? Date.now(),
|
||||
}),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error || 'Failed to update user preferences');
|
||||
}
|
||||
|
||||
return (await res.json()) as PreferencesResponse & { applied: boolean };
|
||||
}
|
||||
|
||||
type PendingPreferenceSync = {
|
||||
patch: SyncedPreferencesPatch;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
const pendingPreferenceSync: PendingPreferenceSync = {
|
||||
patch: {},
|
||||
timer: null,
|
||||
};
|
||||
|
||||
export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void {
|
||||
Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch));
|
||||
|
||||
if (pendingPreferenceSync.timer) {
|
||||
clearTimeout(pendingPreferenceSync.timer);
|
||||
}
|
||||
|
||||
pendingPreferenceSync.timer = setTimeout(async () => {
|
||||
const payload = { ...pendingPreferenceSync.patch };
|
||||
pendingPreferenceSync.patch = {};
|
||||
pendingPreferenceSync.timer = null;
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
|
||||
try {
|
||||
await putUserPreferences(payload);
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync user preferences:', error);
|
||||
}
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
export async function getDocumentProgress(
|
||||
documentId: string,
|
||||
options?: { signal?: AbortSignal },
|
||||
): Promise<DocumentProgressRecord | null> {
|
||||
const res = await fetch(`/api/user/state/progress?documentId=${encodeURIComponent(documentId)}`, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error || 'Failed to load document progress');
|
||||
}
|
||||
const data = (await res.json()) as ProgressResponse;
|
||||
return data.progress ?? null;
|
||||
}
|
||||
|
||||
export async function putDocumentProgress(payload: {
|
||||
documentId: string;
|
||||
readerType: ReaderType;
|
||||
location: string;
|
||||
progress?: number | null;
|
||||
clientUpdatedAtMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<DocumentProgressRecord | null> {
|
||||
const res = await fetch('/api/user/state/progress', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
documentId: payload.documentId,
|
||||
readerType: payload.readerType,
|
||||
location: payload.location,
|
||||
progress: payload.progress ?? null,
|
||||
clientUpdatedAtMs: payload.clientUpdatedAtMs ?? Date.now(),
|
||||
}),
|
||||
signal: payload.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
throw new Error(data?.error || 'Failed to update document progress');
|
||||
}
|
||||
const data = (await res.json()) as ProgressResponse;
|
||||
return data.progress ?? null;
|
||||
}
|
||||
|
||||
type PendingProgressSync = {
|
||||
payload: {
|
||||
documentId: string;
|
||||
readerType: ReaderType;
|
||||
location: string;
|
||||
progress: number | null;
|
||||
};
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
const pendingProgressByDoc = new Map<string, PendingProgressSync>();
|
||||
|
||||
export function scheduleDocumentProgressSync(
|
||||
payload: {
|
||||
documentId: string;
|
||||
readerType: ReaderType;
|
||||
location: string;
|
||||
progress?: number | null;
|
||||
},
|
||||
debounceMs: number = 1000,
|
||||
): void {
|
||||
const existing = pendingProgressByDoc.get(payload.documentId);
|
||||
if (existing?.timer) {
|
||||
clearTimeout(existing.timer);
|
||||
}
|
||||
|
||||
const next: PendingProgressSync = {
|
||||
payload: {
|
||||
documentId: payload.documentId,
|
||||
readerType: payload.readerType,
|
||||
location: payload.location,
|
||||
progress: payload.progress ?? null,
|
||||
},
|
||||
timer: null,
|
||||
};
|
||||
|
||||
next.timer = setTimeout(async () => {
|
||||
pendingProgressByDoc.delete(payload.documentId);
|
||||
try {
|
||||
await putDocumentProgress({
|
||||
documentId: next.payload.documentId,
|
||||
readerType: next.payload.readerType,
|
||||
location: next.payload.location,
|
||||
progress: next.payload.progress,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Failed to sync document progress:', error);
|
||||
}
|
||||
}, debounceMs);
|
||||
|
||||
pendingProgressByDoc.set(payload.documentId, next);
|
||||
}
|
||||
|
|
@ -7,7 +7,12 @@ import type { NextRequest } from 'next/server';
|
|||
import { db } from "@/db";
|
||||
import { rateLimiter } from "@/lib/server/rate-limiter";
|
||||
import { isAuthEnabled } from "@/lib/server/auth-config";
|
||||
import { transferUserAudiobooks, transferUserDocuments } from "@/lib/server/claim-data";
|
||||
import {
|
||||
transferUserAudiobooks,
|
||||
transferUserDocuments,
|
||||
transferUserPreferences,
|
||||
transferUserProgress,
|
||||
} from "@/lib/server/claim-data";
|
||||
|
||||
import * as schema from "@/db/schema"; // Import the dynamic schema
|
||||
|
||||
|
|
@ -135,6 +140,28 @@ const createAuth = () => betterAuth({
|
|||
console.error("Error transferring documents during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer preferences from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring preferences during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
|
||||
// Transfer reading progress from anonymous user to new authenticated user
|
||||
try {
|
||||
const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id);
|
||||
if (transferred > 0) {
|
||||
console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error transferring reading progress during account linking:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in onLinkAccount callback:", error);
|
||||
// Don't throw here to prevent blocking the account linking process
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from '@/db';
|
||||
import { documents, audiobooks, audiobookChapters } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress } from '@/db/schema';
|
||||
import { eq, and, inArray } from 'drizzle-orm';
|
||||
import { UNCLAIMED_USER_ID } from './docstore';
|
||||
import {
|
||||
deleteAudiobookObject,
|
||||
|
|
@ -34,6 +34,25 @@ type AudiobookChapterRow = {
|
|||
format: string;
|
||||
};
|
||||
|
||||
type UserPreferenceRow = {
|
||||
userId: string;
|
||||
dataJson: unknown;
|
||||
clientUpdatedAtMs: number;
|
||||
createdAt: unknown;
|
||||
updatedAt: unknown;
|
||||
};
|
||||
|
||||
type UserDocumentProgressRow = {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
readerType: string;
|
||||
location: string;
|
||||
progress: number | null;
|
||||
clientUpdatedAtMs: number;
|
||||
createdAt: unknown;
|
||||
updatedAt: unknown;
|
||||
};
|
||||
|
||||
function contentTypeForAudiobookObject(fileName: string): string {
|
||||
if (fileName.endsWith('.mp3')) return 'audio/mpeg';
|
||||
if (fileName.endsWith('.m4b')) return 'audio/mp4';
|
||||
|
|
@ -70,16 +89,22 @@ async function moveAudiobookBlobScope(
|
|||
}
|
||||
|
||||
export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) {
|
||||
if (!isAuthEnabled() || !userId) return { documents: 0, audiobooks: 0 };
|
||||
if (!isAuthEnabled() || !userId) {
|
||||
return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 };
|
||||
}
|
||||
|
||||
const [documentsClaimed, audiobooksClaimed] = await Promise.all([
|
||||
const [documentsClaimed, audiobooksClaimed, preferencesClaimed, progressClaimed] = await Promise.all([
|
||||
transferUserDocuments(unclaimedUserId, userId),
|
||||
transferUserAudiobooks(unclaimedUserId, userId, namespace),
|
||||
transferUserPreferences(unclaimedUserId, userId),
|
||||
transferUserProgress(unclaimedUserId, userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
documents: documentsClaimed,
|
||||
audiobooks: audiobooksClaimed,
|
||||
preferences: preferencesClaimed,
|
||||
progress: progressClaimed,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -161,3 +186,92 @@ export async function transferUserAudiobooks(
|
|||
|
||||
return books.length;
|
||||
}
|
||||
|
||||
export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> {
|
||||
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
|
||||
if (fromUserId === toUserId) return 0;
|
||||
|
||||
const fromRows = (await db
|
||||
.select()
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, fromUserId))) as UserPreferenceRow[];
|
||||
const fromRow = fromRows[0];
|
||||
if (!fromRow) return 0;
|
||||
|
||||
const toRows = (await db
|
||||
.select()
|
||||
.from(userPreferences)
|
||||
.where(eq(userPreferences.userId, toUserId))) as UserPreferenceRow[];
|
||||
const toRow = toRows[0];
|
||||
|
||||
if (!toRow || Number(fromRow.clientUpdatedAtMs ?? 0) > Number(toRow.clientUpdatedAtMs ?? 0)) {
|
||||
await db
|
||||
.insert(userPreferences)
|
||||
.values({
|
||||
...fromRow,
|
||||
userId: toUserId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userPreferences.userId],
|
||||
set: {
|
||||
dataJson: fromRow.dataJson,
|
||||
clientUpdatedAtMs: fromRow.clientUpdatedAtMs,
|
||||
updatedAt: fromRow.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await db.delete(userPreferences).where(eq(userPreferences.userId, fromUserId));
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function transferUserProgress(fromUserId: string, toUserId: string): Promise<number> {
|
||||
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0;
|
||||
if (fromUserId === toUserId) return 0;
|
||||
|
||||
const fromRows = (await db
|
||||
.select()
|
||||
.from(userDocumentProgress)
|
||||
.where(eq(userDocumentProgress.userId, fromUserId))) as UserDocumentProgressRow[];
|
||||
if (fromRows.length === 0) return 0;
|
||||
|
||||
const documentIds = fromRows.map((row) => row.documentId);
|
||||
const toRows = (await db
|
||||
.select()
|
||||
.from(userDocumentProgress)
|
||||
.where(and(
|
||||
eq(userDocumentProgress.userId, toUserId),
|
||||
inArray(userDocumentProgress.documentId, documentIds),
|
||||
))) as UserDocumentProgressRow[];
|
||||
const toByDocId = new Map<string, UserDocumentProgressRow>();
|
||||
for (const row of toRows) {
|
||||
toByDocId.set(row.documentId, row);
|
||||
}
|
||||
|
||||
for (const row of fromRows) {
|
||||
const existing = toByDocId.get(row.documentId);
|
||||
const fromUpdated = Number(row.clientUpdatedAtMs ?? 0);
|
||||
const toUpdated = Number(existing?.clientUpdatedAtMs ?? 0);
|
||||
if (existing && fromUpdated <= toUpdated) continue;
|
||||
|
||||
await db
|
||||
.insert(userDocumentProgress)
|
||||
.values({
|
||||
...row,
|
||||
userId: toUserId,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userDocumentProgress.userId, userDocumentProgress.documentId],
|
||||
set: {
|
||||
readerType: row.readerType,
|
||||
location: row.location,
|
||||
progress: row.progress,
|
||||
clientUpdatedAtMs: row.clientUpdatedAtMs,
|
||||
updatedAt: row.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await db.delete(userDocumentProgress).where(eq(userDocumentProgress.userId, fromUserId));
|
||||
return fromRows.length;
|
||||
}
|
||||
|
|
|
|||
30
src/lib/server/user-state-scope.ts
Normal file
30
src/lib/server/user-state-scope.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { NextRequest } from 'next/server';
|
||||
import type { AuthContext } from '@/lib/server/auth';
|
||||
import { requireAuthContext } from '@/lib/server/auth';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace';
|
||||
|
||||
export type ResolvedUserStateScope = {
|
||||
auth: AuthContext;
|
||||
namespace: string | null;
|
||||
ownerUserId: string;
|
||||
unclaimedUserId: string;
|
||||
};
|
||||
|
||||
export async function resolveUserStateScope(
|
||||
req: NextRequest,
|
||||
): Promise<ResolvedUserStateScope | Response> {
|
||||
const auth = await requireAuthContext(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
|
||||
const namespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace);
|
||||
const ownerUserId = auth.userId ?? unclaimedUserId;
|
||||
|
||||
return {
|
||||
auth,
|
||||
namespace,
|
||||
ownerUserId,
|
||||
unclaimedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
39
src/types/user-state.ts
Normal file
39
src/types/user-state.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { AppConfigValues } from '@/types/config';
|
||||
|
||||
export const SYNCED_PREFERENCE_KEYS = [
|
||||
'viewType',
|
||||
'voiceSpeed',
|
||||
'audioPlayerSpeed',
|
||||
'voice',
|
||||
'skipBlank',
|
||||
'epubTheme',
|
||||
'smartSentenceSplitting',
|
||||
'headerMargin',
|
||||
'footerMargin',
|
||||
'leftMargin',
|
||||
'rightMargin',
|
||||
'ttsProvider',
|
||||
'ttsModel',
|
||||
'ttsInstructions',
|
||||
'savedVoices',
|
||||
'pdfHighlightEnabled',
|
||||
'pdfWordHighlightEnabled',
|
||||
'epubHighlightEnabled',
|
||||
'epubWordHighlightEnabled',
|
||||
] as const;
|
||||
|
||||
export type SyncedPreferenceKey = (typeof SYNCED_PREFERENCE_KEYS)[number];
|
||||
export type SyncedPreferences = Pick<AppConfigValues, SyncedPreferenceKey>;
|
||||
export type SyncedPreferencesPatch = Partial<SyncedPreferences>;
|
||||
|
||||
export type ReaderType = 'pdf' | 'epub' | 'html';
|
||||
|
||||
export interface DocumentProgressRecord {
|
||||
documentId: string;
|
||||
readerType: ReaderType;
|
||||
location: string;
|
||||
progress: number | null;
|
||||
clientUpdatedAtMs: number;
|
||||
updatedAtMs: number;
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue