refactor(ui): centralize button primitives and update imports across app

Move all button-related class utilities and types from formPrimitives to a new
ui/buttonPrimitives module. Update all component imports to reference the new
location, ensuring consistent button styling and easier future maintenance.
Remove redundant button code from formPrimitives and re-export as needed for
admin UI compatibility. Add public.css for landing and privacy layout styles.
This commit is contained in:
Richard R 2026-05-31 14:10:59 -06:00
parent da1cb1e2be
commit 50234d34a9
23 changed files with 1179 additions and 1086 deletions

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,74 @@
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-atmosphere" aria-hidden="true" />
/* ── Keyframes ───────────────────── */ <div className="public-frame">
@keyframes landing-drift { <div className="public-topbar">
0%, 100% { transform: translate(0, 0) scale(1); } <div className="public-wrap">
33% { transform: translate(15px, -10px) scale(1.03); } <header className="public-topbar-inner public-reveal-1">
66% { transform: translate(-10px, 8px) scale(0.97); } <Link href="/" className="public-brand" aria-label="OpenReader home">
}
@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 ────────────────────────── */
.landing {
--g-display: var(--font-display), system-ui, -apple-system, sans-serif;
--g-body: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--g-system: var(--font-display), -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--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>
<span className="public-brand-text">OpenReader</span>
<span className="public-brand-tag">Read + Listen</span>
</span>
</Link> </Link>
<nav className="landing-header-nav">
<Link href="/app" className="landing-primary">Open App</Link> <nav className="public-nav" aria-label="Primary">
<Link href="/signin">Sign In</Link> <Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
{enableUserSignups && <Link href="/signup">Sign Up</Link>} Open App
<Link href="https://docs.openreader.richardr.dev/">Docs</Link> </Link>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'sm' })}>
Sign In
</Link>
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'sm' })}>
Sign Up
</Link>
) : null}
<Link
href="https://docs.openreader.richardr.dev/"
className={buttonClass({ variant: 'ghost', size: 'sm' })}
>
Docs
</Link>
</nav> </nav>
</header> </header>
</div> </div>
{/* ── PAGE CONTENT ───────────── */}
{children}
{/* ── FOOTER ─────────────────── */}
<footer className="landing-footer">
<div className="landing-footer-inner">
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-bold"
>
<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>
Self host
</a>
<span className="landing-footer-dot" />
<Link href="/privacy" className="landing-footer-link-bold">
Privacy
</Link>
<span className="landing-footer-dot" />
<span>
Powered by{' '}
<a
href="https://huggingface.co/hexgrad/Kokoro-82M"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-dotted"
>
hexgrad/Kokoro-82M
</a>
{' '}and{' '}
<a
href="https://deepinfra.com/models?type=text-to-speech"
target="_blank"
rel="noopener noreferrer"
className="landing-footer-link-dotted"
>
Deepinfra
</a>
</span>
</div>
</footer>
</div> </div>
{children}
<footer className="public-footer">
<div className="public-wrap">
<div className="public-footer-inner">
<p className="public-footer-label">Open source document reader with synchronized text-to-speech playback.</p>
<div className="public-footer-links">
<Link href="/privacy">Privacy</Link>
<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://docs.openreader.richardr.dev/" target="_blank" rel="noopener noreferrer">
Documentation
</a>
</div>
</div>
</div>
</footer>
</div> </div>
</> </div>
); );
} }

View file

@ -1,13 +1,14 @@
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: '/',
}, },
@ -16,15 +17,15 @@ export const metadata: Metadata = {
locale: 'en_US', locale: 'en_US',
url: 'https://openreader.richardr.dev', 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',
}, },
], ],
}, },
@ -45,418 +46,160 @@ 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>{` <div className="public-wrap">
/* ── Hero ────────────────────────── */ <section className="public-panel public-hero-shell public-reveal-1">
.landing-hero { <article className="public-hero-panel">
max-width: 72rem; <p className="public-eyebrow">{instanceBadge}</p>
margin: 0 auto; <h1 className="public-hero-title">
padding: 3rem 1.5rem 4rem; Turn static files into a <em>living read-along surface</em>.
text-align: center; </h1>
animation: landing-fade-up 0.45s ease-out both; <p className="public-hero-copy">
} OpenReader is a self-host-friendly workspace for EPUB, PDF, TXT, MD, and DOCX. It combines
.landing-hero-badge { synchronized speech, fast navigation, and cloud or local TTS providers so long-form reading is easier
display: inline-flex; to sustain.
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 ────────────────────── */
.landing-features {
max-width: 72rem;
margin: 0 auto;
padding: 0 1.5rem 5rem;
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
}
@media (min-width: 640px) {
.landing-features {
grid-template-columns: 1fr 1fr;
}
}
@media (min-width: 1024px) {
.landing-features {
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>
<p className="landing-hero-desc">
Upload EPUB, PDF, TXT, MD, and DOCX files, then listen with your
preferred OpenAI-compatible TTS provider. Your reading progress
syncs across devices automatically.
</p>
<div className="landing-hero-actions">
<Link href="/app" className="landing-btn landing-btn-accent">
Open App
<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>
</Link>
<Link href="/signin" className="landing-btn landing-btn-ghost">Sign In</Link>
{enableUserSignups && <Link href="/signup" className="landing-btn landing-btn-ghost">Sign Up</Link>}
<Link href="https://docs.openreader.richardr.dev/" className="landing-btn landing-btn-ghost">Docs</Link>
</div>
</section>
{/* ── FEATURES ───────────────── */}
<section className="landing-features">
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&uarr;</div>
<h3>Upload documents</h3>
<p>
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 className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&para;</div>
<h3>Your library</h3>
<p>
Build a personal library with folders. Documents sync
automatically so your collection is always within
reach.
</p>
</div>
<div className="landing-feature-card landing-panel">
<div className="landing-feature-icon" aria-hidden="true">&harr;</div>
<h3>Cross-device sync</h3>
<p>
Reading progress, preferences, and library state sync across
devices. Pick up exactly where you left off on any browser.
</p>
</div>
</section>
{/* ── TTS SPOTLIGHT ──────────── */}
<section className="landing-tts">
<div className="landing-tts-inner landing-panel">
<div className="landing-tts-lead">
<h2>
<span>Text-to-speech</span> that follows along as you read
</h2>
<p>
OpenReader highlights every word as it&rsquo;s spoken, turning
any document into a synchronized read-along experience. Connect
any OpenAI-compatible TTS provider &mdash; including Kokoro,
KittenTTS, Deepinfra, or your own self-hosted endpoint.
</p> </p>
<div className="public-actions">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'lg' })}>
Open Reader Workspace
</Link>
<Link href="/signin" className={buttonClass({ variant: 'secondary', size: 'lg' })}>
Sign In
</Link>
{enableUserSignups ? (
<Link href="/signup" className={buttonClass({ variant: 'outline', size: 'lg' })}>
Create Account
</Link>
) : null}
<Link href="https://docs.openreader.richardr.dev/" className={buttonClass({ variant: 'ghost', size: 'lg' })}>
Read Docs
</Link>
</div>
<dl className="public-meta">
<div className="public-meta-item">
<dt className="public-meta-label">Input formats</dt>
<dd className="public-meta-value">EPUB, PDF, TXT, MD, DOCX</dd>
</div>
<div className="public-meta-item">
<dt className="public-meta-label">Read-along</dt>
<dd className="public-meta-value">Word-level sync highlighting</dd>
</div>
<div className="public-meta-item">
<dt className="public-meta-label">Providers</dt>
<dd className="public-meta-value">OpenAI-compatible + local</dd>
</div>
<div className="public-meta-item">
<dt className="public-meta-label">Export</dt>
<dd className="public-meta-value">Resumable MP3 + M4B workflows</dd>
</div>
</dl>
</article>
<aside className="public-signal-panel" aria-label="What makes OpenReader different">
<p className="public-signal-title">Reader pipeline</p>
<ul className="public-signal-list">
<li className="public-signal-item">
<h3>Layout-aware parsing</h3>
<p>PDF pages are parsed into structured blocks for precise highlight alignment and consistent sentence boundaries.</p>
</li>
<li className="public-signal-item">
<h3>Segment preloading</h3>
<p>Sentence-level audio segments are generated and cached, reducing stalls while you read continuously.</p>
</li>
<li className="public-signal-item">
<h3>Device sync</h3>
<p>Progress, queue state, and document settings follow your account across browser sessions and devices.</p>
</li>
<li className="public-signal-item">
<h3>Self-host path</h3>
<p>Run with Docker, pair with S3 or SeaweedFS, and plug in hosted or self-managed TTS endpoints.</p>
</li>
</ul>
</aside>
</section>
<section className="public-section public-reveal-2" aria-labelledby="capability-heading">
<div className="public-section-head">
<h2 id="capability-heading">Engine room capabilities</h2>
<p>Built for deep reading, not just playback demos.</p>
</div> </div>
<ul className="landing-tts-list"> <div className="public-feature-grid public-rail">
<li> <article className="public-feature-card">
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span> <span className="public-feature-kicker">Parsing</span>
<div> <h3>Structured document understanding</h3>
<h4>Word-level highlighting</h4> <p>Geometry-aware parsing gives stable reading order and cleaner highlighting, especially for complex PDF layouts.</p>
<p>Each word lights up in sync with the audio so you never lose your place.</p> </article>
</div> <article className="public-feature-card">
</li> <span className="public-feature-kicker">Alignment</span>
<li> <h3>ONNX-powered timing maps</h3>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span> <p>Speech alignment data links each spoken unit back to text so the cursor follows naturally during playback.</p>
<div> </article>
<h4>Multiple voices &amp; providers</h4> <article className="public-feature-card">
<p>Choose from dozens of voices across OpenAI, Kokoro, KittenTTS, Deepinfra, or any compatible endpoint.</p> <span className="public-feature-kicker">Providers</span>
</div> <h3>Cloud + self-hosted voices</h3>
</li> <p>Use OpenAI, DeepInfra, Replicate, or OpenAI-compatible local servers such as Kokoro or KittenTTS.</p>
<li> </article>
<span className="landing-tts-list-icon" aria-hidden="true">&bull;</span> <article className="public-feature-card">
<div> <span className="public-feature-kicker">Distribution</span>
<h4>Speed controls</h4> <h3>Audiobook-ready output</h3>
<p>Independent model speed and playback speed sliders from 0.5x to 3x.</p> <p>Export chapterized audio and keep metadata clean for downstream players, with resumable processing for large docs.</p>
</div> </article>
</li> </div>
<li> </section>
<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>
</section>
{/* ── FORMATS ────────────────── */} <section className="public-section public-reveal-3" aria-labelledby="workflow-heading">
<section className="landing-formats"> <div className="public-section-head">
<p className="landing-formats-label">Supported formats</p> <h2 id="workflow-heading">Typical workflow</h2>
<div className="landing-formats-row"> <p>Minimal friction from upload to listen.</p>
<span className="landing-format-pill">EPUB</span> </div>
<span className="landing-format-pill">PDF</span> <div className="public-path public-rail">
<span className="landing-format-pill">TXT</span> <article className="public-path-step" data-step="01">
<span className="landing-format-pill">MD</span> <h3>Drop a file</h3>
<span className="landing-format-pill">DOCX</span> <p>Import a document into your library and keep it organized for repeat sessions.</p>
</div> </article>
</section> <article className="public-path-step" data-step="02">
<h3>Choose a voice stack</h3>
<p>Pick provider, model, and speed profile tuned to your reading pace.</p>
</article>
<article className="public-path-step" data-step="03">
<h3>Read, listen, export</h3>
<p>Track progress with synchronized highlighting and export audiobook files when you need offline playback.</p>
</article>
</div>
</section>
{/* ── CTA ────────────────────── */} <section className="public-panel public-callout public-reveal-3" aria-label="Open source call to action">
<section className="landing-cta"> <h2>Ship your own private reading stack.</h2>
<div className="landing-cta-card landing-panel">
<div className="landing-cta-glow" aria-hidden="true" />
<h2>Start reading now</h2>
<p> <p>
Open the app and upload a document to begin. Run OpenReader locally or deploy it for your team. The docs cover Docker setup, provider integration,
Your progress syncs across devices automatically. object storage, and compute worker configuration.
</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: 'md' })}
>
View Repository
</a>
<a
href="https://docs.openreader.richardr.dev/"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
>
Deployment Guides
</a>
</div> </div>
</div> </section>
</section> </div>
</> </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',
}, },
@ -17,8 +18,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 === '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 +26,163 @@ 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; </p>
} <div className="policy-badges" aria-label="Key policy facts">
.privacy-body h1 { <span className="policy-badge">No personal data sales</span>
font-family: var(--g-display); <span className="policy-badge">Analytics consent controls</span>
font-weight: 800; <span className="policy-badge">Account export + deletion tools</span>
font-size: clamp(1.5rem, 4vw, 2.25rem); <span className="policy-badge">Encrypted document storage</span>
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>
<div className="privacy-highlight">
This OpenReader instance is hosted at <strong>{origin}</strong>.
The operator of this service is responsible for handling your information.
</div>
{isRichardrDevProductionInstance ? (
<div className="privacy-highlight">
<strong>US-only availability:</strong> This instance is intended for
users located in the United States. Requests from outside the US,
or requests without reliable country metadata, may be blocked.
</div> </div>
) : null} {isRichardrDevProductionInstance ? (
<div className="policy-highlight">
<div className="privacy-highlight"> <strong>US-only availability:</strong> this official instance is intended for users located in the United States,
<strong>OpenReader does not sell your personal information.</strong> We do not sell data to data brokers or third parties. and requests outside the US may be blocked.
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> </div>
</section> ) : null}
</section>
<section> <div className="policy-grid public-reveal-2">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <aside className="policy-nav" aria-label="Privacy sections">
<span className="w-2 h-2 rounded-full bg-accent"></span> <p className="policy-nav-title">On this page</p>
2. How We Use Your Information <ul className="policy-nav-list">
</h2> <li><a href="#info-collected">1. Information collected</a></li>
<ul className="list-disc pl-5 space-y-2 text-sm text-foreground/90"> <li><a href="#usage-purpose">2. How data is used</a></li>
<li>To provide, support, and personalize the OpenReader application.</li> <li><a href="#sharing">3. Sharing and service providers</a></li>
<li>To process your uploaded documents for display and text-to-speech conversion.</li> <li><a href="#rights">4. Your rights</a></li>
<li>To maintain the safety, security, and integrity of our service.</li> <li><a href="#retention">5. Retention and security</a></li>
<li>To debug and repair errors that impair existing intended functionality.</li> <li><a href="#processing-location">6. Processing location</a></li>
<li><a href="#contact">7. Contact and open source</a></li>
</ul> </ul>
</section> </aside>
<section> <div className="policy-sections public-panel">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> <section id="info-collected" className="policy-section">
<span className="w-2 h-2 rounded-full bg-accent"></span> <h2>1. Information we collect</h2>
3. Sharing &amp; Selling <p>
</h2> OpenReader collects data needed to operate the service and maintain your reading state.
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> Categories include account identifiers, uploaded content, and product usage telemetry.
<strong>We do not sell your personal information.</strong> </p>
</p> <ul className="policy-fact-list">
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> <li><strong>Identifiers:</strong> Email, session-related identifiers, account name, and IP metadata for authentication and security.</li>
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. <li><strong>Reader content:</strong> Uploaded documents, reading progress, bookmarks, and playback settings required for core functionality.</li>
</p> <li><strong>Usage events:</strong> Feature interactions and performance analytics used to debug and improve product reliability.</li>
<div className="privacy-card landing-panel"> </ul>
<div className="privacy-card-label">Service Providers (Sub-processors)</div> </section>
<section id="usage-purpose" className="policy-section">
<h2>2. How we use your data</h2>
<ul className="policy-list">
<li>Deliver and personalize document reading and text-to-speech playback.</li>
<li>Process uploaded files for parsing, synchronization, and optional audiobook export.</li>
<li>Keep the platform secure and prevent abuse.</li>
<li>Diagnose issues and improve performance of existing functionality.</li>
</ul>
</section>
<section id="sharing" className="policy-section">
<h2>3. Sharing and service providers</h2>
<p>
OpenReader does not sell personal information. Limited processing may be delegated to infrastructure
or model providers as required to run the service.
</p>
<ul className="policy-fact-list">
{isRichardrDevProductionInstance ? (
<>
<li><strong>Hosting:</strong> Vercel for application hosting, edge runtime, and performance analytics.</li>
<li><strong>Database:</strong> Neon PostgreSQL for account and document metadata storage.</li>
<li><strong>File storage:</strong> Railway S3-compatible object storage for encrypted uploaded documents and audio artifacts.</li>
<li><strong>TTS processing:</strong> DeepInfra or configured OpenAI-compatible providers, only when initiated by user actions.</li>
</>
) : (
<>
<li><strong>Hosting provider:</strong> The deployment host operating this OpenReader instance.</li>
<li><strong>Database + storage:</strong> Configured SQL and object storage services chosen by the instance operator.</li>
<li><strong>TTS provider:</strong> The configured cloud or self-hosted speech provider for generated audio.</li>
</>
)}
</ul>
</section>
<section id="rights" className="policy-section">
<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>
<ul className="policy-list">
<li><strong>Right to know:</strong> request details about data categories and usage.</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>
<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>
</section>
<section id="retention" className="policy-section">
<h2>5. Retention and security</h2>
<p>
Account data and uploaded files are retained while your account remains active. Documents are stored
encrypted at rest. When account deletion completes, active database and storage records are removed.
</p>
</section>
<section id="processing-location" className="policy-section">
<h2>6. Processing location</h2>
{isRichardrDevProductionInstance ? ( {isRichardrDevProductionInstance ? (
<ul className="grid gap-2 sm:grid-cols-2 mt-2"> <p>
<li className="text-sm"><strong>Vercel:</strong> Hosting, Edge Functions &amp; Analytics</li> For the official instance, processing and storage occur in the United States. By using this service,
<li className="text-sm"><strong>Neon (PostgreSQL):</strong> Database Storage</li> you acknowledge that data is handled in the US and that availability may be limited to US users.
<li className="text-sm"><strong>Railway (S3):</strong> Encrypted Object Storage (Documents)</li> </p>
<li className="text-sm"><strong>DeepInfra:</strong> Text-to-Speech Processing (User-Initiated)</li>
</ul>
) : ( ) : (
<ul className="grid gap-2 sm:grid-cols-2 mt-2"> <p>
<li className="text-sm"><strong>Hosting Provider:</strong> Application Hosting &amp; Logs</li> Data processing location depends on the deployment environment and provider configuration selected by
<li className="text-sm"><strong>Database Service:</strong> Relational Database Storage</li> the instance operator.
<li className="text-sm"><strong>Object Storage:</strong> Encrypted File Storage (Documents)</li> </p>
<li className="text-sm"><strong>TTS Provider:</strong> Text-to-Speech Processing (Based on your configured provider)</li>
</ul>
)} )}
</div> </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>
4. Your Rights (CCPA/CPRA) 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">
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>
</div>
<div className="mt-4">
<p className="text-sm text-foreground/90 mb-2">
<strong>How to Exercise Your Rights:</strong>
</p> </p>
<ul className="list-disc pl-5 text-sm text-foreground/90"> <div className="policy-actions">
<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> <a
<li><strong>Delete Data:</strong> Use the &quot;Delete Account&quot; button in Settings.</li> href="https://github.com/richardr1126/openreader/issues"
</ul> target="_blank"
</div> rel="noopener noreferrer"
</section> className={buttonClass({ variant: 'primary', size: 'md' })}
>
<section> Open an Issue
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> </a>
<span className="w-2 h-2 rounded-full bg-accent"></span> <a
5. Data Retention href="https://github.com/richardr1126/openreader#readme"
</h2> target="_blank"
<p className="mb-4 text-sm leading-relaxed text-foreground/90"> rel="noopener noreferrer"
We retain your account data and uploaded files only for as long as you maintain an account. className={buttonClass({ variant: 'outline', size: 'md' })}
Uploaded documents are stored <strong>encrypted at rest (AES-256)</strong>. >
Upon account deletion, all data is permanently removed from our active databases and storage buckets immediately. Self-Hosting Guide
</p> </a>
</section> <Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
Back to home
<section> </Link>
<h2 className="text-xl font-bold mb-4 flex items-center gap-2"> </div>
<span className="w-2 h-2 rounded-full bg-accent"></span> </section>
6. US Processing &amp; Location </div>
</h2>
{isRichardrDevProductionInstance ? (
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
For the official OpenReader instance, personal
information is processed and stored in the United States.
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 className="mb-4 text-sm leading-relaxed text-foreground/90">
Data processing location depends on your deployment and hosting
provider configuration.
</p>
)}
</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>
7. Contact Us
</h2>
<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>
<a
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline font-medium"
>
OpenReader Issues
</a>
</section>
</div> </div>
<p className="privacy-note mt-12 pt-8 border-t border-border">
For maximum privacy, you can self-host OpenReader using the{' '}
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
>
open-source repository
</a>.
</p>
<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>
</div> </div>
</> </main>
); );
} }

733
src/app/(public)/public.css Normal file
View file

@ -0,0 +1,733 @@
@keyframes public-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -10px, 0); }
}
@keyframes public-reveal {
from {
opacity: 0;
transform: translate3d(0, 24px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@keyframes public-pan {
from { background-position: 0 0; }
to { background-position: 200px 200px; }
}
.public-shell {
--pub-bg: var(--background);
--pub-fg: var(--foreground);
--pub-surface: var(--base);
--pub-border: var(--offbase);
--pub-accent: var(--accent);
--pub-accent-soft: var(--secondary-accent);
--pub-muted: var(--muted);
min-height: 100vh;
color: var(--pub-fg);
background: var(--pub-bg);
position: relative;
isolation: isolate;
overflow-x: clip;
}
.public-shell::before {
content: '';
position: fixed;
inset: -20vh -25vw;
z-index: -2;
pointer-events: none;
background:
radial-gradient(circle at 15% 20%, color-mix(in srgb, var(--pub-accent) 24%, transparent), transparent 55%),
radial-gradient(circle at 85% 15%, color-mix(in srgb, var(--pub-accent-soft) 18%, transparent), transparent 56%),
radial-gradient(circle at 55% 75%, color-mix(in srgb, var(--pub-accent) 10%, transparent), transparent 60%);
}
.public-shell::after {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.24;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--pub-border) 52%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--pub-border) 52%, transparent) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: radial-gradient(circle at center, black 45%, transparent 95%);
}
.public-atmosphere {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.07;
background-image: radial-gradient(circle, currentColor 1px, transparent 1px);
background-size: 12px 12px;
animation: public-pan 34s linear infinite;
}
.public-frame {
position: relative;
z-index: 1;
}
.public-wrap {
width: min(1180px, calc(100% - 2rem));
margin-inline: auto;
}
.public-topbar {
position: sticky;
top: 0;
z-index: 50;
padding: 1rem 0 0;
}
.public-topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border: 1px solid color-mix(in srgb, var(--pub-border) 70%, transparent);
background: color-mix(in srgb, var(--pub-surface) 78%, transparent);
backdrop-filter: blur(14px) saturate(1.35);
-webkit-backdrop-filter: blur(14px) saturate(1.35);
border-radius: 0.9rem;
padding: 0.65rem 0.75rem;
box-shadow: 0 15px 42px color-mix(in srgb, var(--pub-bg) 74%, transparent);
}
.public-brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
text-decoration: none;
color: var(--pub-fg);
padding: 0.35rem 0.55rem;
border-radius: 0.6rem;
transition: background-color 180ms ease-out;
}
.public-brand:hover {
background: color-mix(in srgb, var(--pub-border) 38%, transparent);
}
.public-brand-mark {
width: 1.2rem;
height: 1.2rem;
}
.public-brand-text {
font-weight: 700;
letter-spacing: -0.02em;
font-size: 0.95rem;
}
.public-brand-tag {
display: block;
color: var(--pub-muted);
letter-spacing: 0.08em;
font-size: 0.61rem;
text-transform: uppercase;
margin-top: 0.1rem;
}
.public-nav {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.42rem;
flex-wrap: wrap;
}
.public-main {
padding: clamp(1.5rem, 3.2vw, 2.5rem) 0 clamp(4rem, 7vw, 6rem);
}
.public-hero-shell {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
align-items: stretch;
padding: clamp(1.1rem, 2vw, 1.5rem);
}
.public-panel {
--panel-base: color-mix(in srgb, var(--pub-surface) 83%, transparent);
--panel-glow: none;
border: 1px solid color-mix(in srgb, var(--pub-border) 72%, transparent);
background: var(--panel-glow), var(--panel-base);
backdrop-filter: blur(6px);
border-radius: 1rem;
}
.public-hero-panel {
padding: clamp(0.35rem, 0.8vw, 0.75rem);
}
.public-hero-shell {
--panel-glow:
radial-gradient(260px 220px at 98% 96%, color-mix(in srgb, var(--pub-accent) 24%, transparent), transparent 72%),
radial-gradient(220px 180px at 4% 6%, color-mix(in srgb, var(--pub-accent-soft) 14%, transparent), transparent 75%);
}
.public-eyebrow {
display: inline-flex;
align-items: center;
gap: 0.45rem;
border-radius: 999px;
padding: 0.3rem 0.75rem;
border: 1px solid color-mix(in srgb, var(--pub-accent) 60%, transparent);
background: color-mix(in srgb, var(--pub-accent) 14%, transparent);
color: var(--pub-accent);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.public-hero-title {
margin: 0.9rem 0 0;
font-size: clamp(2.2rem, 6vw, 4.2rem);
line-height: 0.96;
letter-spacing: -0.05em;
max-width: 13ch;
}
.public-hero-title em {
font-style: normal;
color: var(--pub-accent);
}
.public-hero-copy {
margin: 1.1rem 0 0;
max-width: 58ch;
line-height: 1.7;
color: color-mix(in srgb, var(--pub-fg) 76%, var(--pub-muted));
font-size: 1rem;
}
.public-actions {
margin-top: 1.55rem;
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.public-meta {
margin-top: 1.8rem;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.public-meta-item {
border: 1px dashed color-mix(in srgb, var(--pub-border) 82%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--pub-bg) 50%, transparent);
padding: 0.7rem 0.8rem;
}
.public-meta-label {
display: block;
font-size: 0.67rem;
letter-spacing: 0.1em;
color: var(--pub-muted);
text-transform: uppercase;
margin-bottom: 0.32rem;
}
.public-meta-value {
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.public-signal-panel {
border-left: 1px solid color-mix(in srgb, var(--pub-border) 72%, transparent);
padding: 0.9rem 0.75rem 0.6rem 1.05rem;
position: relative;
}
.public-signal-title {
font-size: 0.78rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pub-muted);
margin: 0;
}
.public-signal-list {
margin: 0.8rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 0;
}
.public-signal-item {
border-top: 1px solid color-mix(in srgb, var(--pub-border) 58%, transparent);
padding: 0.72rem 0.2rem 0.72rem 0;
position: relative;
}
.public-signal-item:first-child {
border-top: 0;
padding-top: 0;
}
.public-signal-item h3 {
margin: 0;
font-size: 0.92rem;
letter-spacing: -0.01em;
}
.public-signal-item p {
margin: 0.4rem 0 0;
color: color-mix(in srgb, var(--pub-fg) 72%, var(--pub-muted));
font-size: 0.85rem;
line-height: 1.55;
}
.public-section {
margin-top: clamp(2rem, 5vw, 3.3rem);
}
.public-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.public-section-head h2 {
margin: 0;
font-size: clamp(1.2rem, 2.8vw, 1.85rem);
letter-spacing: -0.03em;
}
.public-section-head p {
margin: 0;
color: var(--pub-muted);
font-size: 0.9rem;
}
.public-rail {
border: 1px solid color-mix(in srgb, var(--pub-border) 72%, transparent);
background: color-mix(in srgb, var(--pub-surface) 80%, transparent);
border-radius: 0.9rem;
overflow: hidden;
}
.public-feature-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 0;
}
.public-feature-card {
padding: 1.05rem 1rem;
}
.public-feature-card h3 {
margin: 0;
font-size: 1rem;
letter-spacing: -0.01em;
}
.public-feature-card p {
margin: 0.5rem 0 0;
font-size: 0.9rem;
color: color-mix(in srgb, var(--pub-fg) 74%, var(--pub-muted));
line-height: 1.58;
}
.public-feature-kicker {
display: inline-block;
margin-bottom: 0.45rem;
color: var(--pub-accent);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.public-path {
display: grid;
grid-template-columns: 1fr;
gap: 0;
}
.public-path-step {
padding: 0.95rem 1rem;
position: relative;
}
.public-path-step::before {
content: attr(data-step);
position: absolute;
top: 0.85rem;
right: 0.9rem;
font-size: 0.7rem;
font-weight: 700;
color: var(--pub-muted);
letter-spacing: 0.08em;
}
.public-path-step h3 {
margin: 0;
font-size: 0.96rem;
}
.public-path-step p {
margin: 0.5rem 0 0;
color: color-mix(in srgb, var(--pub-fg) 72%, var(--pub-muted));
font-size: 0.88rem;
line-height: 1.56;
}
.public-callout {
margin-top: clamp(1.7rem, 4.2vw, 2.9rem);
padding: clamp(1.3rem, 3vw, 2rem);
--panel-glow:
radial-gradient(280px 220px at 0% 100%, color-mix(in srgb, var(--pub-accent) 25%, transparent), transparent 73%),
radial-gradient(180px 140px at 92% 12%, color-mix(in srgb, var(--pub-accent-soft) 12%, transparent), transparent 72%);
}
.public-callout h2 {
margin: 0;
font-size: clamp(1.35rem, 3.5vw, 2.2rem);
line-height: 1.08;
letter-spacing: -0.04em;
max-width: 16ch;
}
.public-callout p {
margin: 0.8rem 0 0;
color: color-mix(in srgb, var(--pub-fg) 74%, var(--pub-muted));
max-width: 58ch;
line-height: 1.68;
}
.public-footer {
padding: 1.6rem 0 2.4rem;
}
.public-footer-inner {
border: 1px solid color-mix(in srgb, var(--pub-border) 72%, transparent);
border-radius: 0.9rem;
background: color-mix(in srgb, var(--pub-surface) 84%, transparent);
padding: 1rem;
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
}
.public-footer-label {
margin: 0;
color: var(--pub-muted);
font-size: 0.78rem;
}
.public-footer-links {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.public-footer-links a {
color: var(--pub-fg);
font-size: 0.85rem;
text-underline-offset: 3px;
}
.public-footer-links a:hover {
color: var(--pub-accent);
}
.public-reveal-1 {
animation: public-reveal 580ms ease-out both;
}
.public-reveal-2 {
animation: public-reveal 580ms ease-out 120ms both;
}
.public-reveal-3 {
animation: public-reveal 580ms ease-out 220ms both;
}
.policy-main {
padding-top: clamp(1.2rem, 2.6vw, 2rem);
}
.policy-hero {
padding: clamp(1.3rem, 3vw, 2rem);
}
.policy-hero h1 {
margin: 0;
font-size: clamp(1.65rem, 4.3vw, 3rem);
line-height: 0.98;
letter-spacing: -0.05em;
}
.policy-hero p {
margin: 0.8rem 0 0;
color: color-mix(in srgb, var(--pub-fg) 74%, var(--pub-muted));
line-height: 1.66;
max-width: 68ch;
}
.policy-badges {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.policy-badge {
border: 1px solid color-mix(in srgb, var(--pub-border) 70%, transparent);
border-radius: 999px;
padding: 0.35rem 0.72rem;
background: color-mix(in srgb, var(--pub-bg) 50%, transparent);
font-size: 0.74rem;
color: color-mix(in srgb, var(--pub-fg) 74%, var(--pub-muted));
}
.policy-grid {
margin-top: 0.85rem;
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
}
.policy-nav {
border: 1px solid color-mix(in srgb, var(--pub-border) 70%, transparent);
border-radius: 0.9rem;
background: color-mix(in srgb, var(--pub-surface) 82%, transparent);
padding: 0.95rem;
align-self: start;
}
.policy-nav-title {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--pub-muted);
}
.policy-nav-list {
list-style: none;
margin: 0.7rem 0 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.policy-nav-list a {
display: block;
text-decoration: none;
color: color-mix(in srgb, var(--pub-fg) 86%, var(--pub-muted));
font-size: 0.86rem;
border: 1px solid transparent;
border-radius: 0.58rem;
padding: 0.45rem 0.52rem;
transition: border-color 140ms ease-out, color 140ms ease-out, background-color 140ms ease-out;
}
.policy-nav-list a:hover {
border-color: color-mix(in srgb, var(--pub-accent) 55%, transparent);
color: var(--pub-accent);
background: color-mix(in srgb, var(--pub-accent) 11%, transparent);
}
.policy-sections {
display: grid;
gap: 0;
overflow: hidden;
}
.policy-section {
padding: 1.1rem 1.1rem 1rem;
border-top: 1px solid color-mix(in srgb, var(--pub-border) 64%, transparent);
scroll-margin-top: 5.7rem;
}
.policy-section:first-child {
border-top: 0;
}
.policy-section h2 {
margin: 0;
font-size: 1.05rem;
letter-spacing: -0.01em;
}
.policy-section p {
margin: 0.62rem 0 0;
line-height: 1.64;
font-size: 0.9rem;
color: color-mix(in srgb, var(--pub-fg) 80%, var(--pub-muted));
}
.policy-list {
margin: 0.72rem 0 0;
padding-left: 1rem;
display: grid;
gap: 0.38rem;
}
.policy-list li {
line-height: 1.58;
color: color-mix(in srgb, var(--pub-fg) 82%, var(--pub-muted));
font-size: 0.89rem;
}
.policy-fact-list {
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.38rem;
}
.policy-fact-list li {
border-left: 2px solid color-mix(in srgb, var(--pub-accent) 42%, transparent);
background: color-mix(in srgb, var(--pub-bg) 45%, transparent);
padding: 0.56rem 0.66rem;
font-size: 0.89rem;
line-height: 1.56;
color: color-mix(in srgb, var(--pub-fg) 82%, var(--pub-muted));
}
.policy-highlight {
margin-top: 0.75rem;
border: 1px solid color-mix(in srgb, var(--pub-accent) 65%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, var(--pub-accent) 10%, transparent);
padding: 0.75rem;
font-size: 0.86rem;
line-height: 1.56;
color: color-mix(in srgb, var(--pub-fg) 85%, var(--pub-muted));
}
.policy-highlight strong {
color: var(--pub-accent);
}
.policy-actions {
margin-top: 1rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.public-link-inline {
color: var(--pub-accent);
text-decoration: underline;
text-underline-offset: 3px;
}
@media (min-width: 760px) {
.public-wrap {
width: min(1180px, calc(100% - 2.8rem));
}
.public-hero-shell {
grid-template-columns: 1.25fr 0.75fr;
}
.public-meta {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.public-feature-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.public-feature-card:nth-child(odd),
.public-path-step:nth-child(odd) {
border-right: 1px solid color-mix(in srgb, var(--pub-border) 62%, transparent);
}
.public-feature-card:nth-child(n + 3) {
border-top: 1px solid color-mix(in srgb, var(--pub-border) 62%, transparent);
}
.public-path-step:nth-child(n + 2) {
border-left: 1px solid color-mix(in srgb, var(--pub-border) 62%, transparent);
}
.public-path {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.public-footer-inner {
grid-template-columns: 1fr auto;
align-items: center;
}
.policy-grid {
grid-template-columns: 280px minmax(0, 1fr);
}
.policy-nav {
position: sticky;
top: 5.5rem;
}
.policy-fact-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1100px) {
.public-feature-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.public-feature-card {
border-top: 0 !important;
border-right: 0 !important;
border-left: 1px solid color-mix(in srgb, var(--pub-border) 62%, transparent);
}
.public-feature-card:first-child {
border-left: 0;
}
.public-topbar-inner {
padding: 0.75rem 0.9rem;
}
}
@media (max-width: 759px) {
.public-hero-shell {
padding: 1rem 0.9rem;
}
.public-signal-panel {
border-left: 0;
border-top: 1px solid color-mix(in srgb, var(--pub-border) 72%, transparent);
padding: 0.95rem 0.2rem 0.1rem;
}
.public-feature-card + .public-feature-card,
.public-path-step + .public-path-step {
border-top: 1px solid color-mix(in srgb, var(--pub-border) 62%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
.public-atmosphere,
.public-reveal-1,
.public-reveal-2,
.public-reveal-3,
.public-signal-panel,
.public-shell::before,
.public-shell::after {
animation: none !important;
}
}

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);

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(' ');
}