Merge pull request #95 from richardr1126/redesign/landing-privacy

Redesign public landing & privacy pages
This commit is contained in:
Richard R 2026-05-31 16:56:05 -06:00 committed by GitHub
commit e3044278b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1907 additions and 1099 deletions

View file

@ -41,7 +41,6 @@ docker run --name openreader \
-p 3003:3003 \ -p 3003:3003 \
-p 8333:8333 \ -p 8333:8333 \
-v openreader_docstore:/app/docstore \ -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_BASE=http://host.docker.internal:8880/v1 \
-e API_KEY=none \ -e API_KEY=none \
-e BASE_URL=http://localhost:3003 \ -e BASE_URL=http://localhost:3003 \
@ -55,7 +54,6 @@ What this command enables:
- `-p 3003:3003`: exposes the OpenReader web app/API. - `-p 3003:3003`: exposes the OpenReader web app/API.
- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download. - `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download.
- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
- `-v /path/to/your/library:/app/docstore/library:ro`: mounts a read-only importable library source.
- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). - `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup. - `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup.
- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.

View file

@ -10,7 +10,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons'; import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();

View file

@ -9,7 +9,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal'; import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
export default function SignUpPage() { export default function SignUpPage() {

View file

@ -1,338 +1,128 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import './public.css';
export default async function PublicLayout({ children }: { children: ReactNode }) { export default async function PublicLayout({ children }: { children: ReactNode }) {
const runtimeConfig = await getResolvedRuntimeConfigForRsc(); const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups; const enableUserSignups = runtimeConfig.enableUserSignups;
return ( return (
<> <div className="public-shell">
<style>{` <div className="public-aurora" aria-hidden="true" />
/* ── Keyframes ───────────────────── */ <div className="public-grain" aria-hidden="true" />
@keyframes landing-drift {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(15px, -10px) scale(1.03); }
66% { transform: translate(-10px, 8px) scale(0.97); }
}
@keyframes landing-fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes landing-scale-in {
from { opacity: 0; transform: scale(0.92); }
to { opacity: 1; transform: scale(1); }
}
/* ── Root ────────────────────────── */ <div className="public-frame">
.landing { <div className="public-topbar">
--g-display: var(--font-display), system-ui, -apple-system, sans-serif; <div className="public-wrap">
--g-body: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; <header className="public-topbar-inner public-reveal-1">
--g-system: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; <Link href="/" className="public-brand" aria-label="OpenReader home">
--g-bg: var(--background);
--g-fg: var(--foreground);
--g-surface: var(--base);
--g-border: var(--offbase);
--g-accent: var(--accent);
--g-accent2: var(--secondary-accent);
--g-muted: var(--muted);
font-family: var(--g-body);
background: var(--g-bg);
color: var(--g-fg);
min-height: 100vh;
overflow-x: hidden;
position: relative;
}
/* ── Ambient orbs ────────────────── */
.landing-orbs {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.landing-orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.12;
animation: landing-drift 20s ease-in-out infinite;
}
.landing-orb-1 {
width: 500px; height: 500px;
background: var(--g-accent);
top: -10%; left: -8%;
animation-delay: 0s;
}
.landing-orb-2 {
width: 400px; height: 400px;
background: var(--g-accent2);
top: 40%; right: -12%;
animation-delay: -7s;
}
.landing-orb-3 {
width: 350px; height: 350px;
background: var(--g-accent);
bottom: -5%; left: 30%;
animation-delay: -14s;
}
/* ── Content wrapper ─────────────── */
.landing-content {
position: relative;
z-index: 1;
}
/* ── Glass panel ─────────────────── */
.landing-panel {
background: color-mix(in srgb, var(--g-surface), transparent 30%);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid color-mix(in srgb, var(--g-border), transparent 50%);
border-radius: 1.25rem;
}
/* ── Sticky header wrapper ───────── */
.landing-header-wrap {
position: sticky;
top: 0;
z-index: 100;
padding: 1rem 1.5rem 0;
max-width: 72rem;
margin: 0 auto;
animation: landing-fade-up 0.6s ease-out both;
}
/* ── Header ──────────────────────── */
.landing-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
flex-wrap: wrap;
gap: 0.75rem;
box-shadow: 0 2px 16px color-mix(in srgb, var(--g-bg), transparent 40%);
}
.landing-logo {
font-family: var(--g-display);
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.02em;
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--g-fg);
}
.landing-logo-icon {
width: 20px;
height: 20px;
}
.landing-header-nav {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
.landing-header-nav a {
font-family: var(--g-system);
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--g-border);
background: var(--g-surface);
text-decoration: none;
color: var(--g-fg);
transform: translateZ(0);
transition: all 200ms ease-in-out;
}
.landing-header-nav a:hover {
background: var(--g-border);
transform: scale(1.09);
color: var(--g-accent);
}
.landing-header-nav a:focus {
outline: none;
box-shadow: 0 0 0 2px var(--g-accent), 0 0 0 4px var(--g-bg);
}
.landing-header-nav a.landing-primary {
background: var(--g-accent);
color: var(--g-bg);
border-color: var(--g-accent);
font-weight: 500;
}
.landing-header-nav a.landing-primary:hover {
background: var(--g-accent2);
border-color: var(--g-accent2);
color: var(--g-bg);
transform: scale(1.09);
}
/* ── Buttons ─────────────────────── */
.landing-btn {
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.4rem;
transform: translateZ(0);
transition: transform 200ms ease-in-out, background 200ms, box-shadow 200ms;
}
.landing-btn:hover {
transform: scale(1.02);
}
.landing-btn:focus {
outline: none;
box-shadow: 0 0 0 2px var(--g-accent), 0 0 0 4px var(--g-bg);
}
.landing-btn-accent {
background: var(--g-accent);
color: var(--g-bg);
}
.landing-btn-accent:hover {
background: var(--g-accent2);
}
.landing-btn-ghost {
color: var(--g-fg);
background: var(--g-surface);
border: 1px solid var(--g-border);
}
.landing-btn-ghost:hover {
background: var(--g-border);
color: var(--g-accent);
}
/* ── Footer ──────────────────────── */
.landing-footer {
text-align: center;
padding: 2rem 1.5rem 3rem;
font-family: var(--g-display);
font-size: 0.75rem;
font-weight: 500;
color: var(--g-muted);
letter-spacing: 0.05em;
position: relative;
z-index: 1;
}
.landing-footer-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem 0;
}
.landing-footer-dot {
display: inline-block;
width: 4px; height: 4px;
border-radius: 50%;
background: var(--g-accent);
margin: 0 0.6rem;
vertical-align: middle;
opacity: 0.6;
}
.landing-footer a {
color: var(--g-muted);
text-decoration: none;
transition: color 0.2s;
}
.landing-footer a:hover {
color: var(--g-fg);
}
.landing-footer-link-dotted {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.landing-footer-link-bold {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.landing * {
animation: none !important;
transition: none !important;
}
}
`}</style>
<div className="landing">
{/* ── Ambient orbs ────────────── */}
<div className="landing-orbs" aria-hidden="true">
<div className="landing-orb landing-orb-1" />
<div className="landing-orb landing-orb-2" />
<div className="landing-orb landing-orb-3" />
</div>
<div className="landing-content">
{/* ── HEADER ─────────────────── */}
<div className="landing-header-wrap">
<header className="landing-header landing-panel">
<Link href="/" className="landing-logo">
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="landing-logo-icon" aria-hidden="true" /> <img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
OpenReader <span className="public-brand-copy">
<span className="public-brand-text">OpenReader</span>
<span className="public-brand-tag">Read&nbsp;·&nbsp;Listen</span>
</span>
</Link> </Link>
<nav className="landing-header-nav">
<Link href="/app" className="landing-primary">Open App</Link>
<Link href="/signin">Sign In</Link>
{enableUserSignups && <Link href="/signup">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/">Docs</Link>
</nav>
</header>
</div>
{/* ── PAGE CONTENT ───────────── */} <nav className="public-nav" aria-label="Primary">
{children} <Link
href="https://docs.openreader.richardr.dev/"
{/* ── FOOTER ─────────────────── */} className="public-nav-link"
<footer className="landing-footer"> >
<div className="landing-footer-inner"> Docs
</Link>
<a <a
href="https://github.com/richardr1126/openreader#readme" href="https://github.com/richardr1126/openreader#readme"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="landing-footer-link-bold" className="public-nav-link"
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z"/></svg> GitHub
Self host
</a> </a>
<span className="landing-footer-dot" /> <span className="public-nav-divider" aria-hidden="true" />
<Link href="/privacy" className="landing-footer-link-bold"> <Link href="/signin" className={buttonClass({ variant: 'ghost', size: 'sm' })}>
Privacy Sign in
</Link> </Link>
<span className="landing-footer-dot" /> <Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
<span> Open app
Powered by{' '} </Link>
</nav>
</header>
</div>
</div>
{children}
<footer className="public-footer">
<div className="public-wrap">
<div className="public-footer-inner">
<div className="public-footer-brand">
<div className="public-footer-mark">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
<span className="public-brand-text">OpenReader</span>
</div>
<p className="public-footer-label">
An open-source reading room that turns documents into
synchronized, listenable audio that&rsquo;s yours to self-host.
</p>
<div className="public-footer-cta">
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
Create account
</Link>
) : null}
<a <a
href="https://huggingface.co/hexgrad/Kokoro-82M" href="https://github.com/richardr1126/openreader"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="landing-footer-link-dotted" className={buttonClass({ variant: 'ghost', size: 'sm' })}
> >
hexgrad/Kokoro-82M Star on GitHub
</a> </a>
{' '}and{' '} </div>
<a </div>
href="https://deepinfra.com/models?type=text-to-speech"
target="_blank" <nav className="public-footer-cols" aria-label="Footer">
rel="noopener noreferrer" <div className="public-footer-col">
className="landing-footer-link-dotted" <p className="public-footer-col-title">Product</p>
> <Link href="/app">Open app</Link>
Deepinfra <Link href="/signin">Sign in</Link>
<a href="https://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer">
Documentation
</a> </a>
</span> </div>
<div className="public-footer-col">
<p className="public-footer-col-title">Project</p>
<a href="https://github.com/richardr1126/openreader#readme" target="_blank" rel="noopener noreferrer">
GitHub
</a>
<a href="https://github.com/richardr1126/openreader/discussions" target="_blank" rel="noopener noreferrer">
Discussions
</a>
<a href="https://github.com/richardr1126/openreader/issues" target="_blank" rel="noopener noreferrer">
Issues
</a>
</div>
<div className="public-footer-col">
<p className="public-footer-col-title">Legal</p>
<Link href="/privacy">Privacy &amp; data</Link>
<a href="https://github.com/richardr1126/openreader/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
MIT license
</a>
</div>
</nav>
</div>
<div className="public-footer-base">
<div className="prism-divider" />
<p>© {new Date().getFullYear()} OpenReader · MIT licensed · Self-host friendly</p>
</div>
</div> </div>
</footer> </footer>
</div> </div>
</div> </div>
</>
); );
} }

View file

@ -1,30 +1,32 @@
import type { CSSProperties } from 'react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc'; import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Read and Listen to Documents', title: 'Open Source Read-Along Workspace',
description: description:
'OpenReader lets you upload EPUB, PDF, TXT, MD, and DOCX files for synchronized text-to-speech reading with multi-provider TTS support.', 'OpenReader converts EPUB, PDF, TXT, MD, and DOCX files into synchronized read-along audio with multi-provider text-to-speech support.',
keywords: keywords:
'PDF reader, EPUB reader, text to speech, tts open ai, kokoro tts, kitten tts, OpenReader, TTS PDF reader, ebook reader, epub tts, document reader', 'OpenReader, document reader, PDF read aloud, EPUB read aloud, text to speech, OpenAI compatible TTS, self-hosted reader',
alternates: { alternates: {
canonical: '/', canonical: '/',
}, },
openGraph: { openGraph: {
type: 'website', type: 'website',
locale: 'en_US', locale: 'en_US',
url: 'https://openreader.richardr.dev', url: process.env.BASE_URL || 'https://openreader.richardr.dev',
siteName: 'OpenReader', siteName: 'OpenReader',
title: 'OpenReader | Read and Listen to Documents', title: 'OpenReader | Read documents with synchronized audio',
description: description:
'Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with synchronized read-along playback using OpenAI-compatible TTS providers.', 'Upload documents and turn them into a synchronized listening experience with word-level highlighting and audiobook export.',
images: [ images: [
{ {
url: '/web-app-manifest-512x512.png', url: '/web-app-manifest-512x512.png',
width: 512, width: 512,
height: 512, height: 512,
alt: 'OpenReader Logo', alt: 'OpenReader icon',
}, },
], ],
}, },
@ -41,422 +43,332 @@ export const metadata: Metadata = {
}, },
}; };
// Words that receive the looping read-along highlight sweep in the hero. This
// is the product's actual word-level highlighting, used as the page's signature.
const SWEEP = ['highlighted', 'word', 'by', 'word.'];
const PROVIDERS = ['Kokoro', 'KittenTTS', 'Orpheus', 'OpenAI', 'Replicate', 'DeepInfra'];
const FORMATS = ['EPUB', 'PDF', 'TXT', 'MD', 'DOCX'];
export default async function LandingPage() { export default async function LandingPage() {
const runtimeConfig = await getResolvedRuntimeConfigForRsc(); const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups; const enableUserSignups = runtimeConfig.enableUserSignups;
const instanceBadge =
process.env.RICHARDRDEV_PRODUCTION === 'true'
? 'Official OpenReader instance'
: 'Open-source document reader';
return ( return (
<> <main className="public-main">
<style>{` {/* ───────────────────────────── Hero ───────────────────────────── */}
/* ── Hero ────────────────────────── */ <section className="public-hero">
.landing-hero { <div className="public-wrap public-hero-grid">
max-width: 72rem; <div className="public-hero-lede public-reveal-1">
margin: 0 auto; <p className="public-eyebrow">
padding: 3rem 1.5rem 4rem; <span className="public-eyebrow-dot" aria-hidden="true" />
text-align: center; {instanceBadge}
animation: landing-fade-up 0.45s ease-out both; </p>
}
.landing-hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--g-display);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
padding: 0.4rem 1rem;
border-radius: 2rem;
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
background: color-mix(in srgb, var(--g-accent), transparent 92%);
margin-bottom: 2rem;
}
.landing-hero h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(2rem, 5.5vw, 4rem);
line-height: 1.1;
letter-spacing: -0.03em;
max-width: 18ch;
margin: 0 auto 1.5rem;
}
.landing-hero h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-hero-desc {
font-family: var(--g-body);
font-size: 1.1rem;
line-height: 1.7;
color: var(--g-muted);
max-width: 52ch;
margin: 0 auto 2.5rem;
}
.landing-hero-actions {
display: flex;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
/* ── Features ────────────────────── */ <h1 className="public-hero-title">
.landing-features { Hear every document,
max-width: 72rem; <br />
margin: 0 auto; <span className="public-sweep" aria-hidden="true">
padding: 0 1.5rem 5rem; {SWEEP.map((word, i) => (
display: grid; <span
grid-template-columns: 1fr; key={word + i}
gap: 1.25rem; className="public-sweep-word"
} style={{ '--i': i } as CSSProperties}
@media (min-width: 640px) { >
.landing-features { {word}
grid-template-columns: 1fr 1fr; {i < SWEEP.length - 1 ? ' ' : ''}
} </span>
} ))}
@media (min-width: 1024px) { </span>
.landing-features { <span className="public-visually-hidden">highlighted word by word.</span>
grid-template-columns: 1fr 1fr 1fr;
}
}
.landing-feature-card {
padding: 2rem;
animation: landing-scale-in 0.45s ease-out both;
transition: transform 0.3s, border-color 0.3s;
}
.landing-feature-card:hover {
transform: translateY(-4px);
border-color: color-mix(in srgb, var(--g-accent), transparent 50%);
}
.landing-feature-icon {
width: 3rem; height: 3rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--g-accent), transparent 85%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
font-size: 1.25rem;
color: var(--g-accent);
font-weight: 700;
font-family: var(--g-display);
}
.landing-feature-card h3 {
font-family: var(--g-display);
font-weight: 700;
font-size: 1.15rem;
margin: 0 0 0.6rem;
letter-spacing: -0.01em;
}
.landing-feature-card p {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-muted);
}
/* ── TTS spotlight ────────────────── */
.landing-tts {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-tts-inner {
display: grid;
grid-template-columns: 1fr;
gap: 2.5rem;
padding: 2.5rem;
}
@media (min-width: 768px) {
.landing-tts-inner {
grid-template-columns: 1fr 1fr;
}
}
.landing-tts-lead h2 {
font-family: var(--g-display);
font-weight: 700;
font-size: clamp(1.4rem, 3vw, 2rem);
letter-spacing: -0.02em;
margin: 0 0 0.75rem;
line-height: 1.2;
}
.landing-tts-lead h2 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.landing-tts-lead > p {
font-size: 0.95rem;
line-height: 1.7;
color: var(--g-muted);
}
.landing-tts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.landing-tts-list li {
display: flex;
gap: 0.75rem;
align-items: flex-start;
}
.landing-tts-list-icon {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: color-mix(in srgb, var(--g-accent), transparent 85%);
display: flex;
align-items: center;
justify-content: center;
color: var(--g-accent);
font-size: 0.85rem;
font-weight: 700;
font-family: var(--g-display);
margin-top: 0.1rem;
}
.landing-tts-list h4 {
font-family: var(--g-display);
font-weight: 600;
font-size: 0.92rem;
margin: 0 0 0.2rem;
}
.landing-tts-list p {
font-size: 0.84rem;
line-height: 1.55;
color: var(--g-muted);
margin: 0;
}
/* ── Formats ribbon ──────────────── */
.landing-formats {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
text-align: center;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-formats-label {
font-family: var(--g-display);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--g-muted);
margin-bottom: 1.25rem;
}
.landing-formats-row {
display: flex;
justify-content: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.landing-format-pill {
font-family: var(--g-display);
font-weight: 600;
font-size: 0.82rem;
padding: 0.5rem 1.25rem;
border-radius: 2rem;
background: color-mix(in srgb, var(--g-surface), transparent 30%);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid color-mix(in srgb, var(--g-border), transparent 50%);
transition: border-color 0.25s, transform 0.2s;
cursor: default;
}
.landing-format-pill:hover {
border-color: var(--g-accent);
transform: translateY(-2px);
}
/* ── CTA section ─────────────────── */
.landing-cta {
max-width: 48rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
animation: landing-fade-up 0.45s ease-out both;
}
.landing-cta-card {
padding: 3rem 2rem;
text-align: center;
position: relative;
overflow: hidden;
}
.landing-cta-glow {
position: absolute;
width: 200px; height: 200px;
border-radius: 50%;
background: var(--g-accent);
filter: blur(80px);
opacity: 0.08;
top: -50px; right: -50px;
pointer-events: none;
}
.landing-cta-card h2 {
font-family: var(--g-display);
font-weight: 700;
font-size: clamp(1.4rem, 3vw, 2rem);
letter-spacing: -0.02em;
margin: 0 0 0.6rem;
position: relative;
}
.landing-cta-card > p {
font-size: 0.95rem;
color: var(--g-muted);
margin-bottom: 2rem;
max-width: 40ch;
margin-left: auto;
margin-right: auto;
position: relative;
}
.landing-cta-actions {
display: flex;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
position: relative;
}
`}</style>
{/* ── HERO ───────────────────── */}
<section className="landing-hero">
<div className="landing-hero-badge">
{process.env.RICHARDRDEV_PRODUCTION === 'true'
? "Official OpenReader Instance"
: "Open Source Document Reader"
}
</div>
<h1>
Your documents, <span>read&nbsp;aloud</span>
</h1> </h1>
<p className="landing-hero-desc">
Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with your <p className="public-hero-copy">
preferred OpenAI-compatible TTS provider. Your reading progress OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a
syncs across devices automatically. synchronized read-along surface, reading your original file in a
native viewer with genuine text-to-speech, word-level
highlighting, and audiobook export. It&rsquo;s open source, and
entirely yours to self-host.
</p> </p>
<div className="landing-hero-actions">
<Link href="/app" className="landing-btn landing-btn-accent"> <div className="public-actions">
Open App <Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 8h10M9 4l4 4-4 4" /></svg> Open the reader
</Link> </Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link> {enableUserSignups ? (
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>} <Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link> Create account
</Link>
) : (
<Link href="/signin" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Sign in
</Link>
)}
<Link
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'lg' })}
>
Read the docs
</Link>
</div>
<div className="public-formats" aria-label="Supported formats">
<span className="public-formats-label">Reads</span>
<div className="public-formats-list">
{FORMATS.map((fmt) => (
<span key={fmt} className="public-format-chip">
{fmt}
</span>
))}
</div>
</div>
</div>
{/* Reader mockup: demonstrates the synchronized highlight + playback */}
<aside className="public-reader public-reveal-2" aria-hidden="true">
<div className="public-reader-glow" aria-hidden="true" />
<div className="public-reader-bar">
<span className="public-reader-dot" data-tone="a" />
<span className="public-reader-dot" data-tone="b" />
<span className="public-reader-dot" data-tone="c" />
<span className="public-reader-file">wizard-of-oz.epub</span>
<span className="public-reader-voice">Kokoro · af_sky</span>
</div>
<div className="public-reader-body">
<p className="public-reader-chapter">Chapter 1 · The Cyclone</p>
<p className="public-reader-text">
Dorothy lived in the midst of the great Kansas prairies, with
Uncle Henry, who was a farmer, and Aunt Em, who was the
farmer&rsquo;s wife.{' '}
<span className="public-reader-sentence">
Their house was small, for the lumber to build it had to be
carried by <span className="public-reader-word">wagon</span>{' '}
many miles.
</span>{' '}
There were four walls, a floor and a roof, which made one room.
</p>
</div>
<div className="public-player">
<button type="button" className="public-player-play" aria-label="Playing" tabIndex={-1}>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M8 5v14l11-7z" fill="currentColor" />
</svg>
</button>
<div className="public-wave" aria-hidden="true">
{Array.from({ length: 28 }).map((_, i) => (
<span
key={i}
className="public-wave-bar"
style={{ '--b': i } as CSSProperties}
/>
))}
</div>
<span className="public-player-time">04:12 / 18:30</span>
<span className="public-player-speed">1.1×</span>
</div>
</aside>
</div>
<div className="public-wrap">
<div className="public-providers public-reveal-3" aria-label="Supported text-to-speech providers">
<span className="public-providers-label">Speaks through</span>
<div className="public-providers-track">
{PROVIDERS.map((p) => (
<span key={p} className="public-provider">
{p}
</span>
))}
</div>
</div>
</div> </div>
</section> </section>
{/* ── FEATURES ───────────────── */} {/* ────────────────────────── How it works ───────────────────────── */}
<section className="landing-features"> <section className="public-section" aria-labelledby="how-heading">
<div className="landing-feature-card landing-panel"> <div className="public-wrap">
<div className="landing-feature-icon" aria-hidden="true">&uarr;</div> <div className="public-section-head">
<h3>Upload documents</h3> <p className="public-kicker">The flow</p>
<p> <h2 id="how-heading">From a raw file to a voice in three moves.</h2>
Drag and drop EPUB, PDF, TXT, Markdown, or DOCX files directly
into the app. Documents process instantly for reading and
text-to-speech playback.
</p>
</div> </div>
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&para;</div> <ol className="public-steps">
<h3>Your library</h3> <li className="public-step">
<span className="public-step-num">01</span>
<h3>Upload a document</h3>
<p> <p>
Build a personal library with folders. Documents sync Drop an EPUB, PDF, TXT, Markdown, or DOCX into your library, or
automatically so your collection is always within import one straight from the server, and it stays organized for
reach. every session after.
</p> </p>
</div> </li>
<div className="landing-feature-card landing-panel"> <li className="public-step">
<div className="landing-feature-icon" aria-hidden="true">&harr;</div> <span className="public-step-num">02</span>
<h3>Cross-device sync</h3> <h3>Pick a voice</h3>
<p> <p>
Reading progress, preferences, and library state sync across Choose a provider and model: hosted OpenAI, Replicate, or
devices. Pick up exactly where you left off on any browser. DeepInfra, or your own self-hosted Kokoro, KittenTTS, or Orpheus
server. Set the speed to your pace.
</p> </p>
</li>
<li className="public-step">
<span className="public-step-num">03</span>
<h3>Read, listen, export</h3>
<p>
Follow word-level highlighting right on the original page as it
plays, pick up where you left off on any device, and export a
chaptered m4b or mp3 audiobook for the road.
</p>
</li>
</ol>
</div> </div>
</section> </section>
{/* ── TTS SPOTLIGHT ──────────── */} {/* ──────────────────────────── Features ─────────────────────────── */}
<section className="landing-tts"> <section className="public-section" aria-labelledby="features-heading">
<div className="landing-tts-inner landing-panel"> <div className="public-wrap">
<div className="landing-tts-lead"> <div className="public-section-head">
<h2> <p className="public-kicker">Under the hood</p>
<span>Text-to-speech</span> that follows along as you read <h2 id="features-heading">Engineered for deep reading, not playback demos.</h2>
</h2> </div>
<div className="public-features">
<article className="public-feature public-feature-wide">
<span className="public-feature-kicker">Formats</span>
<h3>Native EPUB and PDF, kept intact</h3>
<p> <p>
OpenReader highlights every word as it&rsquo;s spoken, turning Your file renders in a built-in EPUB and PDF reader, never
any document into a synchronized read-along experience. Connect flattened to plain text. Layout-aware parsing (PP-DocLayoutV3,
any OpenAI-compatible TTS provider &mdash; including Kokoro, ONNX) maps the structure underneath, so read-along highlighting
KittenTTS, Deepinfra, or your own self-hosted endpoint. follows the true reading order, even in dense, multi-column
PDFs.
</p> </p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Alignment</span>
<h3>Word-by-word timing</h3>
<p>
ONNX Whisper alignment through a JetStream-backed compute worker
maps each spoken word back to the page, so the cursor tracks
speech precisely.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Voices</span>
<h3>Multi-provider TTS</h3>
<p>
Mix cloud APIs with OpenAI-compatible local servers. Bring your
own keys and endpoints, with no lock-in to a single vendor.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Export</span>
<h3>Audiobook output</h3>
<p>
Render chaptered m4b and mp3 files with resumable processing,
ready for any offline player you already use.
</p>
</article>
<article className="public-feature">
<span className="public-feature-kicker">Sync</span>
<h3>Progress that follows you</h3>
<p>
Reading position, queue state, and per-document settings sync
across browser sessions and devices through your account.
</p>
</article>
<article className="public-feature public-feature-wide">
<span className="public-feature-kicker">Backend</span>
<h3>A stack you actually control</h3>
<p>
Run on embedded SeaweedFS or any S3-compatible bucket, back it
with SQLite or Postgres, and ship it with Docker on amd64 or
arm64, with built-in auth and automatic startup migrations.
</p>
</article>
</div> </div>
<ul className="landing-tts-list">
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Word-level highlighting</h4>
<p>Each word lights up in sync with the audio so you never lose your place.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Multiple voices &amp; providers</h4>
<p>Choose from dozens of voices across OpenAI, Kokoro, KittenTTS, Deepinfra, or any compatible endpoint.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Speed controls</h4>
<p>Independent model speed and playback speed sliders from 0.5x to 3x.</p>
</div>
</li>
<li>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span>
<div>
<h4>Audiobook export</h4>
<p>Convert any document to a downloadable MP3 or M4A audiobook with chapter metadata.</p>
</div>
</li>
</ul>
</div> </div>
</section> </section>
{/* ── FORMATS ────────────────── */} {/* ───────────────────────── Self-host CTA ───────────────────────── */}
<section className="landing-formats"> <section className="public-section" aria-labelledby="selfhost-heading">
<p className="landing-formats-label">Supported formats</p> <div className="public-wrap">
<div className="landing-formats-row"> <div className="public-callout">
<span className="landing-format-pill">EPUB</span> <div className="public-callout-glow" aria-hidden="true" />
<span className="landing-format-pill">PDF</span> <div className="public-callout-copy">
<span className="landing-format-pill">TXT</span> <p className="public-kicker">Open source · MIT</p>
<span className="landing-format-pill">MD</span> <h2 id="selfhost-heading">Run your own private reading stack.</h2>
<span className="landing-format-pill">DOCX</span> <p className="public-callout-text">
</div> Deploy OpenReader for yourself or your team in minutes. The docs
</section> cover Docker, provider integration, object storage, and the
external compute worker. Every piece is yours to host.
{/* ── CTA ────────────────────── */}
<section className="landing-cta">
<div className="landing-cta-card landing-panel">
<div className="landing-cta-glow" aria-hidden="true" />
<h2>Start reading now</h2>
<p>
Open the app and upload a document to begin.
Your progress syncs across devices automatically.
</p> </p>
<div className="landing-cta-actions"> <div className="public-actions">
<Link href="/app" className="landing-btn landing-btn-accent">Open App</Link> <a
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link> href="https://github.com/richardr1126/openreader#readme"
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>} target="_blank"
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link> rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'lg' })}
>
View the repository
</a>
<a
href="https://docs.openreader.richardr.dev/docker-quick-start"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'lg' })}
>
Deployment guides
</a>
</div>
</div>
<div className="public-terminal" aria-hidden="true">
<div className="public-terminal-bar">
<span className="public-reader-dot" data-tone="a" />
<span className="public-reader-dot" data-tone="b" />
<span className="public-reader-dot" data-tone="c" />
<span className="public-terminal-title">quick start</span>
</div>
<pre className="public-terminal-body">
<code>
<span className="public-term-comment"># pull and run</span>
{'\n'}
<span className="public-term-prompt">$</span> docker run --name openreader \{'\n'}
{' '}-p <span className="public-term-accent">3003:3003</span> -p{' '}
<span className="public-term-accent">8333:8333</span> \{'\n'}
{' '}-v <span className="public-term-accent">openreader_docstore:/app/docstore</span> \{'\n'}
{' '}-e BASE_URL=
<span className="public-term-accent">http://localhost:3003</span> \{'\n'}
{' '}-e AUTH_SECRET=
<span className="public-term-accent">$(openssl rand -hex 32)</span> \{'\n'}
{' '}-e ADMIN_EMAILS=
<span className="public-term-accent">you@example.com</span> \{'\n'}
{' '}ghcr.io/richardr1126/openreader:latest{'\n'}
{'\n'}
<span className="public-term-comment"># open the reading room</span>
{'\n'}
<span className="public-term-prompt">$</span> open{' '}
<span className="public-term-accent">localhost:3003</span>
<span className="public-term-caret" />
</code>
</pre>
</div>
</div> </div>
</div> </div>
</section> </section>
</> </main>
); );
} }

View file

@ -1,11 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Privacy & Data Usage | OpenReader', title: 'Privacy & Data Usage',
description: description:
'Learn how OpenReader handles your data, what is stored in your browser, and what is sent to the server.', 'How OpenReader collects, stores, and processes data, including usage analytics controls and your account rights.',
alternates: { alternates: {
canonical: '/privacy', canonical: '/privacy',
}, },
@ -18,7 +19,7 @@ export const metadata: Metadata = {
export default async function PrivacyPage() { export default async function PrivacyPage() {
const effectiveDate = 'February 17, 2026'; const effectiveDate = 'February 17, 2026';
const isRichardrDevProductionInstance = const isRichardrDevProductionInstance =
process.env.RICHARDRDEV_PRODUCTION === 'true'; process.env.RICHARDRDEV_PRODUCTION?.trim().toLowerCase() === 'true';
const hdrs = await headers(); const hdrs = await headers();
const host = hdrs.get('host') ?? 'this server'; const host = hdrs.get('host') ?? 'this server';
@ -26,308 +27,184 @@ export default async function PrivacyPage() {
const origin = `${proto}://${host}`; const origin = `${proto}://${host}`;
return ( return (
<> <main className="public-main policy-main">
<style>{` <div className="public-wrap">
/* ── Privacy body ───────────────── */ <section className="public-panel policy-hero public-reveal-1">
.privacy-body { <h1>Privacy, data flow, and account controls.</h1>
max-width: 42rem; <p>
margin: 0 auto; This policy explains what data OpenReader processes, how it is used, and the controls available to you.
padding: 3rem 1.5rem 4rem; It applies to the instance currently running at <strong>{origin}</strong>. Effective date: {effectiveDate}.
animation: landing-fade-up 0.7s ease-out 0.15s both;
}
.privacy-body h1 {
font-family: var(--g-display);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.25rem);
letter-spacing: -0.03em;
margin: 0 0 0.5rem;
}
.privacy-body h1 span {
background: linear-gradient(135deg, var(--g-accent), var(--g-accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.privacy-subtitle {
font-size: 0.95rem;
color: var(--g-muted);
margin: 0 0 2.5rem;
line-height: 1.6;
}
.privacy-card {
padding: 2rem;
margin-bottom: 1.25rem;
}
.privacy-card-label {
font-family: var(--g-display);
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--g-accent);
margin: 0 0 0.75rem;
}
.privacy-card p,
.privacy-card li {
font-size: 0.92rem;
line-height: 1.65;
color: var(--g-fg);
}
.privacy-card ul {
list-style: none;
margin: 0;
padding: 0;
}
.privacy-card li {
position: relative;
padding-left: 1.1rem;
margin-bottom: 0.4rem;
}
.privacy-card li::before {
content: '';
position: absolute;
left: 0;
top: 0.55em;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--g-accent);
opacity: 0.5;
}
.privacy-highlight {
background: color-mix(in srgb, var(--g-accent), transparent 88%);
border: 1px solid color-mix(in srgb, var(--g-accent), transparent 70%);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
margin-bottom: 1.25rem;
font-size: 0.88rem;
line-height: 1.6;
color: var(--g-fg);
}
.privacy-highlight strong {
color: var(--g-accent);
font-weight: 600;
}
.privacy-note {
font-size: 0.8rem;
color: var(--g-muted);
line-height: 1.6;
margin-top: 2rem;
}
.privacy-note a {
color: var(--g-accent);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
}
.privacy-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--g-system);
font-size: 0.875rem;
font-weight: 500;
color: var(--g-accent);
text-decoration: none;
margin-top: 2rem;
transition: opacity 0.2s;
}
.privacy-back:hover {
opacity: 0.75;
}
`}</style>
<div className="privacy-body">
<h1>Privacy &amp; <span>Data Usage</span></h1>
<p className="privacy-subtitle">
Effective Date: {effectiveDate}
</p> </p>
<div className="policy-badges" aria-label="Key policy facts">
<div className="privacy-highlight"> <span className="policy-badge">No personal data sales</span>
This OpenReader instance is hosted at <strong>{origin}</strong>. <span className="policy-badge">Analytics consent controls</span>
The operator of this service is responsible for handling your information. <span className="policy-badge">Account export + deletion tools</span>
<span className="policy-badge">Encrypted document storage</span>
</div> </div>
{isRichardrDevProductionInstance ? ( {isRichardrDevProductionInstance ? (
<div className="privacy-highlight"> <div className="policy-highlight">
<strong>US-only availability:</strong> This instance is intended for <strong>US-only availability:</strong> this official instance is intended for users located in the United States,
users located in the United States. Requests from outside the US, and requests outside the US may be blocked.
or requests without reliable country metadata, may be blocked.
</div> </div>
) : null} ) : null}
<div className="privacy-highlight">
<strong>OpenReader does not sell your personal information.</strong> We do not sell data to data brokers or third parties.
We use data solely to provide and improve the reading experience.
</div>
<div className="space-y-8">
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
1. Information We Collect (CCPA Categories)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We collect information that identifies, relates to, describes, references, or is reasonably capable of being associated with you (&quot;<strong>Personal Information</strong>&quot;).
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Categories Collected</div>
<ul className="space-y-3">
<li>
<strong className="text-foreground">Identifiers:</strong> Email address, IP address, unique personal identifier (session token), and account name.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Authentication, security, providing service.</div>
</li>
<li>
<strong className="text-foreground">Customer Records:</strong> Uploaded documents (PDF, EPUB), reading progress, bookmarks, and preferences.
<div className="text-xs text-muted-foreground mt-1">Source: Directly from you. Purpose: Providing core reading functionality.</div>
</li>
<li>
<strong className="text-foreground">Internet Activity:</strong> Browsing history within the app, interaction with features (Analytics).
<div className="text-xs text-muted-foreground mt-1">Source: Automatic collection. Purpose: Debugging, performance optimization.</div>
</li>
</ul>
</div>
</section> </section>
<section> {/* Claim audit
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> Code-verified: consent/GPC analytics handling, analytics toggle, export/delete controls, US geo-gate, AES256 storage headers.
<span className="w-2 h-2 rounded-full bg-accent"></span> Deployment-dependent: processing/storage region and exact provider infrastructure locations.
2. How We Use Your Information Operator-statement: OpenReader does not sell personal information.
</h2> */}
<ul className="list-disc pl-5 space-y-2 text-sm text-foreground/90"> <div className="policy-grid public-reveal-2">
<li>To provide, support, and personalize the OpenReader application.</li> <aside className="policy-nav" aria-label="Privacy sections">
<li>To process your uploaded documents for display and text-to-speech conversion.</li> <p className="policy-nav-title">On this page</p>
<li>To maintain the safety, security, and integrity of our service.</li> <ul className="policy-nav-list">
<li>To debug and repair errors that impair existing intended functionality.</li> <li><a href="#info-collected">1. Information collected</a></li>
<li><a href="#usage-purpose">2. How data is used</a></li>
<li><a href="#sharing">3. Sharing and service providers</a></li>
<li><a href="#rights">4. Your rights</a></li>
<li><a href="#retention">5. Retention and security</a></li>
<li><a href="#processing-location">6. Processing location</a></li>
<li><a href="#contact">7. Contact and open source</a></li>
</ul>
</aside>
<div className="policy-sections public-panel">
<section id="info-collected" className="policy-section">
<h2>1. Information we collect</h2>
<p>
OpenReader collects data needed to operate the service and maintain your reading state.
Categories include account identifiers, uploaded content, and product usage telemetry.
</p>
<ul className="policy-fact-list">
<li><strong>Identifiers:</strong> Email, session-related identifiers, account name, and IP metadata for authentication and security.</li>
<li><strong>Reader content:</strong> Uploaded documents, reading progress, bookmarks, and playback settings required for core functionality.</li>
<li><strong>Usage events:</strong> Feature interactions and performance analytics used to debug and improve product reliability.</li>
</ul> </ul>
</section> </section>
<section> <section id="usage-purpose" className="policy-section">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <h2>2. How we use your data</h2>
<span className="w-2 h-2 rounded-full bg-accent"></span> <ul className="policy-list">
3. Sharing &amp; Selling <li>Deliver and personalize document reading and text-to-speech playback.</li>
</h2> <li>Process uploaded files for parsing, synchronization, and optional audiobook export.</li>
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> <li>Keep the platform secure and prevent abuse.</li>
<strong>We do not sell your personal information.</strong> <li>Diagnose issues and improve performance of existing functionality.</li>
</ul>
</section>
<section id="sharing" className="policy-section">
<h2>3. Service providers and analytics</h2>
<p>
OpenReader does not sell personal information. We use service providers only to run product
infrastructure and user-initiated features.
</p> </p>
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> <div className="policy-highlight">
We may &quot;share&quot; (as defined by CPRA for cross-context behavioral advertising) anonymous usage data with analytics providers solely to improve our app. You can opt-out of this sharing via the Cookie Banner, and Global Privacy Control (GPC) signals are automatically honored. Optional analytics is controlled by your consent choice, and Global Privacy Control (GPC) opt-out
</p> signals are honored.
<div className="privacy-card landing-panel"> </div>
<div className="privacy-card-label">Service Providers (Sub-processors)</div> <ul className="policy-fact-list">
{isRichardrDevProductionInstance ? ( {isRichardrDevProductionInstance ? (
<ul className="grid gap-2 sm:grid-cols-2 mt-2"> <>
<li className="text-sm"><strong>Vercel:</strong> Hosting, Edge Functions &amp; Analytics</li> <li><strong>Hosting:</strong> Vercel for application hosting, edge runtime, and performance analytics.</li>
<li className="text-sm"><strong>Neon (PostgreSQL):</strong> Database Storage</li> <li><strong>Database:</strong> Neon PostgreSQL for account and document metadata storage.</li>
<li className="text-sm"><strong>Railway (S3):</strong> Encrypted Object Storage (Documents)</li> <li><strong>File storage:</strong> Railway S3-compatible object storage for encrypted uploaded documents and audio artifacts.</li>
<li className="text-sm"><strong>DeepInfra:</strong> Text-to-Speech Processing (User-Initiated)</li> <li><strong>TTS processing:</strong> User-initiated TTS is handled by shared providers: Kitten TTS FastAPI (self-hosted on a local Pi cluster) and Replicate.</li>
</ul> </>
) : ( ) : (
<ul className="grid gap-2 sm:grid-cols-2 mt-2"> <>
<li className="text-sm"><strong>Hosting Provider:</strong> Application Hosting &amp; Logs</li> <li><strong>Hosting provider:</strong> The deployment host operating this OpenReader instance.</li>
<li className="text-sm"><strong>Database Service:</strong> Relational Database Storage</li> <li><strong>Database + storage:</strong> Configured SQL and object storage services chosen by the instance operator.</li>
<li className="text-sm"><strong>Object Storage:</strong> Encrypted File Storage (Documents)</li> <li><strong>TTS provider:</strong> The configured cloud or self-hosted speech provider for generated audio.</li>
<li className="text-sm"><strong>TTS Provider:</strong> Text-to-Speech Processing (Based on your configured provider)</li> </>
</ul>
)} )}
</div>
</section>
<section>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-accent"></span>
4. Your Rights (CCPA/CPRA)
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
You have the following rights regarding your personal information:
</p>
<div className="privacy-card landing-panel">
<ul className="space-y-2">
<li><strong>Right to Know:</strong> You may request details about the categories and specific pieces of personal information we have collected.</li>
<li><strong>Right to Delete:</strong> You may request deletion of your personal information (via &quot;Delete Account&quot; in Settings).</li>
<li><strong>Right to Correct:</strong> You may update your account information in Settings.</li>
<li><strong>Right to Opt-Out:</strong> We do not sell data. You may opt-out of analytics &quot;sharing&quot; via our Cookie Banner.</li>
<li><strong>Right to Non-Discrimination:</strong> We will not discriminate against you for exercising your privacy rights.</li>
</ul> </ul>
</div> </section>
<div className="mt-4">
<p className="text-sm text-foreground/90 mb-2"> <section id="rights" className="policy-section">
<strong>How to Exercise Your Rights:</strong> <h2>4. Your privacy rights</h2>
<p>
Depending on your jurisdiction, rights may include access, correction, deletion, and opt-out controls.
OpenReader includes in-product controls for exporting and deleting account data.
</p> </p>
<ul className="list-disc pl-5 text-sm text-foreground/90"> <ul className="policy-list">
<li><strong>Export Data:</strong> Use the &quot;Export My Data&quot; button in Settings to download your account metadata plus object-storage-backed document and audiobook files.</li> <li><strong>Right to know:</strong> request details about data categories and usage.</li>
<li><strong>Delete Data:</strong> Use the &quot;Delete Account&quot; button in Settings.</li> <li><strong>Right to delete:</strong> delete your account and associated records in Settings.</li>
<li><strong>Right to correct:</strong> update account data where applicable.</li>
<li><strong>Right to opt out:</strong> disable non-essential analytics through consent controls.</li>
<li><strong>Right to non-discrimination:</strong> exercising privacy rights does not reduce service access.</li>
</ul> </ul>
<div className="policy-highlight">
<strong>In-app controls:</strong> Use <em>Export My Data</em> in Settings to download account metadata plus
object-storage-backed files, or use <em>Delete Account</em> for permanent account removal.
</div> </div>
</section> </section>
<section> <section id="retention" className="policy-section">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <h2>5. Retention and security</h2>
<span className="w-2 h-2 rounded-full bg-accent"></span> <p>
5. Data Retention Account data and uploaded files are retained while your account remains active. Data is encrypted at
</h2> rest in storage. OpenReader does not currently provide end-to-end encryption.
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> </p>
We retain your account data and uploaded files only for as long as you maintain an account. <p>
Uploaded documents are stored <strong>encrypted at rest (AES-256)</strong>. The owner of this instance may be able to access stored metadata and uploaded files needed to operate
Upon account deletion, all data is permanently removed from our active databases and storage buckets immediately. the service.
</p>
<p>
Passwords are not stored as readable plaintext; the authentication system stores credential values as
non-plaintext verification data.
</p>
<p>
Account deletion triggers removal of active account records and associated storage artifacts as part
of the deletion flow.
</p> </p>
</section> </section>
<section> <section id="processing-location" className="policy-section">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <h2>6. Processing location</h2>
<span className="w-2 h-2 rounded-full bg-accent"></span>
6. US Processing &amp; Location
</h2>
{isRichardrDevProductionInstance ? ( {isRichardrDevProductionInstance ? (
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> <p>
For the official OpenReader instance, personal For the official instance, access is restricted to users in the United States. Processing location
information is processed and stored in the United States. depends on the configured infrastructure and providers used by this deployment.
By using this service, you acknowledge that your data is handled
in the US and that the service is available only for US users.
</p> </p>
) : ( ) : (
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> <p>
Data processing location depends on your deployment and hosting Data processing location depends on the deployment environment and provider configuration selected by
provider configuration. the instance operator.
</p> </p>
)} )}
</section> </section>
<section> <section id="contact" className="policy-section">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <h2>7. Contact and open source</h2>
<span className="w-2 h-2 rounded-full bg-accent"></span> <p>
7. Contact Us Questions or concerns can be raised through the project repository. Self-hosting is available if you
</h2> want full infrastructure control.
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
If you have questions or concerns about this Privacy Policy, please contact the instance administrator via the repository:
</p> </p>
<div className="policy-actions">
<a <a
href="https://github.com/richardr1126/openreader/issues" href="https://github.com/richardr1126/openreader/issues"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-accent hover:underline font-medium" className={buttonClass({ variant: 'primary', size: 'md' })}
> >
OpenReader Issues Open an Issue
</a> </a>
</section>
</div>
<p className="privacy-note mt-12 pt-8 border-t border-border">
For maximum privacy, you can self-host OpenReader using the{' '}
<a <a
href="https://github.com/richardr1126/openreader#readme" href="https://github.com/richardr1126/openreader#readme"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
> >
open-source repository Self-Hosting Guide
</a>. </a>
</p> <Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
Back to landing
<Link href="/?redirect=false" className="privacy-back">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M13 8H3M7 4l-4 4 4 4" /></svg>
Back to home
</Link> </Link>
</div> </div>
</> </section>
</div>
</div>
</div>
</main>
); );
} }

1159
src/app/(public)/public.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,7 @@ import { Figtree } from "next/font/google";
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics"; import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
import { CookieConsentBanner } from "@/components/CookieConsentBanner"; import { CookieConsentBanner } from "@/components/CookieConsentBanner";
import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config"; import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config";
import { tryGetOrigin } from "@/lib/shared/urls";
import pkg from "../../package.json"; import pkg from "../../package.json";
const figtree = Figtree({ const figtree = Figtree({
@ -34,13 +35,16 @@ export const viewport: Viewport = {
viewportFit: "cover", viewportFit: "cover",
}; };
const defaultOrigin = "https://openreader.richardr.dev";
const validatedOrigin = tryGetOrigin(process.env.BASE_URL) || defaultOrigin;
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
default: "OpenReader", default: "OpenReader",
template: "%s | OpenReader", template: "%s | OpenReader",
}, },
manifest: "/manifest.json", manifest: "/manifest.json",
metadataBase: new URL("https://openreader.richardr.dev"), metadataBase: new URL(validatedOrigin),
verification: { verification: {
google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U", google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U",
}, },

View file

@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons'; import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
export function CodeBlock({ children }: { children: string }) { export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);

View file

@ -1,6 +1,6 @@
import { Fragment, KeyboardEvent } from 'react'; import { Fragment, KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean; isOpen: boolean;

View file

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics'; import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
export function CookieConsentBanner() { export function CookieConsentBanner() {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
@ -49,8 +49,8 @@ export function CookieConsentBanner() {
🍪 We use cookies 🍪 We use cookies
</h3> </h3>
<p className="text-sm leading-relaxed text-foreground/90"> <p className="text-sm leading-relaxed text-foreground/90">
We use strictly necessary cookies for authentication and optional cookies for anonymous analytics We use strictly necessary cookies for authentication. Optional analytics is enabled only when you consent.
to improve the app. See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details. See our <Link href="/privacy" className="font-medium text-accent hover:underline">Privacy Policy</Link> for details.
</p> </p>
</div> </div>

View file

@ -29,7 +29,20 @@ function PrivacyModalBody({ origin }: { origin: string }) {
<p className="leading-relaxed"> <p className="leading-relaxed">
We value your privacy. This application uses strictly necessary cookies for authentication We value your privacy. This application uses strictly necessary cookies for authentication
and anonymous analytics to improve performance. Your documents are stored securely and encrypted at rest. and optional analytics only when consent allows it. Your documents are stored encrypted at rest.
</p>
<p className="leading-relaxed">
OpenReader does not currently provide end-to-end encryption.
</p>
<p className="leading-relaxed">
The owner of this instance may be able to access stored metadata and uploaded files needed to operate the
service.
</p>
<p className="leading-relaxed">
Passwords are not stored as readable plaintext.
</p> </p>
<p className="leading-relaxed"> <p className="leading-relaxed">

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ProgressCardProps { interface ProgressCardProps {
progress: number; progress: number;

View file

@ -52,7 +52,6 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery'; import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import { import {
buttonClass,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
@ -60,6 +59,7 @@ import {
segmentedButtonClass, segmentedButtonClass,
segmentedGroupClass, segmentedGroupClass,
} from '@/components/formPrimitives'; } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog'; import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';

View file

@ -9,12 +9,12 @@ import {
Badge, Badge,
Section, Section,
ToggleRow, ToggleRow,
buttonClass,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
listboxOptionsClass, listboxOptionsClass,
} from '@/components/formPrimitives'; } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders'; import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';

View file

@ -13,12 +13,12 @@ import {
Field, Field,
Section, Section,
ToggleRow, ToggleRow,
buttonClass,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
listboxOptionsClass, listboxOptionsClass,
} from '@/components/formPrimitives'; } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
type ProviderType = TtsProviderId; type ProviderType = TtsProviderId;

View file

@ -4,13 +4,6 @@ export {
Field, Field,
Section, Section,
ToggleRow, ToggleRow,
buttonClass,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
inputClass, inputClass,
listboxButtonClass, listboxButtonClass,
listboxOptionClass, listboxOptionClass,
@ -18,3 +11,13 @@ export {
segmentedButtonClass, segmentedButtonClass,
segmentedGroupClass, segmentedGroupClass,
} from '@/components/formPrimitives'; } from '@/components/formPrimitives';
export {
buttonClass,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
} from '@/components/ui/buttonPrimitives';

View file

@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
import { LoadingSpinner } from '@/components/Spinner'; import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
function sleep(ms: number) { function sleep(ms: number) {
return new Promise<void>((resolve) => setTimeout(resolve, ms)); return new Promise<void>((resolve) => setTimeout(resolve, ms));

View file

@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';

View file

@ -3,7 +3,7 @@
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents'; import { BaseDocument } from '@/types/documents';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
interface DocumentSelectionModalProps { interface DocumentSelectionModalProps {
isOpen: boolean; isOpen: boolean;

View file

@ -15,7 +15,8 @@ import {
clampSegmentPreloadSentenceLookahead, clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength, clampTtsSegmentMaxBlockLength,
} from '@/types/config'; } from '@/types/config';
import { Section, ToggleRow, CheckItem, buttonClass, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives'; import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons'; import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf'; import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse'; import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';

View file

@ -1,4 +1,4 @@
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
export function ZoomControl({ export function ZoomControl({
value, value,

View file

@ -1,51 +1,35 @@
'use client'; 'use client';
import { useId, type ReactNode } from 'react'; import { useId, type ReactNode } from 'react';
import {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
} from '@/components/ui/buttonPrimitives';
/** /**
* Shared compact form primitives used by settings-like surfaces across * Shared compact form primitives used by settings-like surfaces across
* the app (settings modal, document settings, and admin panels). * the app (settings modal, document settings, and admin panels).
*/ */
export const btnBase = export {
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'; btnBase,
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`; btnDanger,
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`; btnGhost,
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`; btnOutline,
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`; btnPrimary,
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`; btnSecondary,
buttonClass,
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'; type ButtonSize,
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon'; type ButtonVariant,
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
}; };
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-xs rounded-md',
sm: 'h-7 px-2.5 text-xs rounded-md',
md: 'h-8 px-3 text-sm rounded-md',
lg: 'h-10 px-4 text-base rounded-lg',
icon: 'h-8 w-8 rounded-md p-0',
};
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
}
// Inputs use `bg-base` so they remain visible regardless of whether the // Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section). // surrounding container is `bg-background` (Card) or `bg-base` (Section).
// Using the same `bg-background` as the Card would make the input blend in. // Using the same `bg-background` as the Card would make the input blend in.

View file

@ -4,7 +4,7 @@ import { Fragment, type ReactNode } from 'react';
import { Transition } from '@headlessui/react'; import { Transition } from '@headlessui/react';
import { XCircleIcon } from '@/components/icons/Icons'; import { XCircleIcon } from '@/components/icons/Icons';
import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds'; import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ReaderSidebarShellProps { interface ReaderSidebarShellProps {
isOpen: boolean; isOpen: boolean;

View file

@ -8,7 +8,7 @@ import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext'; import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext'; import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons'; import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives'; import { buttonClass } from '@/components/ui/buttonPrimitives';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator'; import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan'; import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';

View file

@ -0,0 +1,38 @@
export const btnBase =
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`;
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`;
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
export const btnGhost = `${btnBase} bg-transparent text-foreground hover:bg-base hover:text-accent`;
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
const BUTTON_VARIANT_CLASS: Record<ButtonVariant, string> = {
primary: btnPrimary,
secondary: btnSecondary,
outline: btnOutline,
danger: btnDanger,
ghost: btnGhost,
};
const BUTTON_SIZE_CLASS: Record<ButtonSize, string> = {
xs: 'h-6 px-2 text-xs rounded-md',
sm: 'h-7 px-2.5 text-xs rounded-md',
md: 'h-8 px-3 text-sm rounded-md',
lg: 'h-10 px-4 text-base rounded-lg',
icon: 'h-8 w-8 rounded-md p-0',
};
export function buttonClass({
variant = 'secondary',
size = 'md',
className = '',
}: {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
} = {}) {
return [BUTTON_VARIANT_CLASS[variant], BUTTON_SIZE_CLASS[size], className].filter(Boolean).join(' ');
}

View file

@ -13,6 +13,7 @@ import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres"; import * as authSchemaPostgres from "@/db/schema_auth_postgres";
import { hashForLog, serverLogger } from '@/lib/server/logger'; import { hashForLog, serverLogger } from '@/lib/server/logger';
import { logDegraded, logServerError } from '@/lib/server/errors/logging'; import { logDegraded, logServerError } from '@/lib/server/errors/logging';
import { tryGetOrigin } from "@/lib/shared/urls";
// Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded // Heavy modules (S3 SDK, blobstore, rate-limiter, claim-data) are loaded
// lazily via dynamic import() inside the beforeDelete / onLinkAccount // lazily via dynamic import() inside the beforeDelete / onLinkAccount
@ -20,14 +21,6 @@ import { logDegraded, logServerError } from '@/lib/server/errors/logging';
// ... // ...
function tryGetOrigin(url: string | undefined): string | null {
if (!url) return null;
try {
return new URL(url).origin;
} catch {
return null;
}
}
function getTrustedOrigins(): string[] { function getTrustedOrigins(): string[] {
const origins = new Set<string>(); const origins = new Set<string>();

9
src/lib/shared/urls.ts Normal file
View file

@ -0,0 +1,9 @@
export function tryGetOrigin(url: string | undefined): string | null {
if (!url) return null;
try {
const origin = new URL(url).origin;
return origin === 'null' ? null : origin;
} catch {
return null;
}
}

View file

@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { tryGetOrigin } from '../../src/lib/shared/urls';
describe('tryGetOrigin utility function', () => {
test('returns null for undefined, null, or empty string', () => {
expect(tryGetOrigin(undefined)).toBeNull();
expect(tryGetOrigin('')).toBeNull();
});
test('extracts origin from valid HTTP/HTTPS URLs', () => {
expect(tryGetOrigin('http://localhost:3000')).toBe('http://localhost:3000');
expect(tryGetOrigin('https://openreader.richardr.dev')).toBe('https://openreader.richardr.dev');
expect(tryGetOrigin('https://my-custom-domain.com/some/path?query=1')).toBe('https://my-custom-domain.com');
});
test('returns null for malformed URLs/strings without protocols', () => {
expect(tryGetOrigin('malformed-url')).toBeNull();
expect(tryGetOrigin('localhost:3000')).toBeNull();
expect(tryGetOrigin('my-custom-domain.com')).toBeNull();
});
test('returns null for invalid protocols/schemes', () => {
expect(tryGetOrigin('ftp://some-server.com')).toBe('ftp://some-server.com'); // standard URL supports ftp
expect(tryGetOrigin('invalid://foo')).toBeNull(); // non-standard/unsupported origins return null
expect(tryGetOrigin('http://[invalid-ipv6]')).toBeNull();
});
});