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 { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams();

View file

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

View file

@ -1,338 +1,74 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
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 }) {
const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups;
return (
<>
<style>{`
/* ── Keyframes ───────────────────── */
@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 ────────────────────────── */
.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">
<div className="public-shell">
<div className="public-atmosphere" aria-hidden="true" />
<div className="public-frame">
<div className="public-topbar">
<div className="public-wrap">
<header className="public-topbar-inner public-reveal-1">
<Link href="/" className="public-brand" aria-label="OpenReader home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/icon.svg" alt="" className="landing-logo-icon" aria-hidden="true" />
OpenReader
<img src="/icon.svg" alt="" className="public-brand-mark" aria-hidden="true" />
<span>
<span className="public-brand-text">OpenReader</span>
<span className="public-brand-tag">Read + Listen</span>
</span>
</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 className="public-nav" aria-label="Primary">
<Link href="/app" className={buttonClass({ variant: 'primary', size: 'sm' })}>
Open App
</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>
</header>
</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>
{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>
);
}

View file

@ -1,13 +1,14 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { getResolvedRuntimeConfigForRsc } from '@/lib/server/runtime-config-rsc';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export const metadata: Metadata = {
title: 'Read and Listen to Documents',
title: 'Open Source Read-Along Workspace',
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:
'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: {
canonical: '/',
},
@ -16,15 +17,15 @@ export const metadata: Metadata = {
locale: 'en_US',
url: 'https://openreader.richardr.dev',
siteName: 'OpenReader',
title: 'OpenReader | Read and Listen to Documents',
title: 'OpenReader | Read documents with synchronized audio',
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: [
{
url: '/web-app-manifest-512x512.png',
width: 512,
height: 512,
alt: 'OpenReader Logo',
alt: 'OpenReader icon',
},
],
},
@ -45,418 +46,160 @@ export default async function LandingPage() {
const runtimeConfig = await getResolvedRuntimeConfigForRsc();
const enableUserSignups = runtimeConfig.enableUserSignups;
const instanceBadge =
process.env.RICHARDRDEV_PRODUCTION === 'true'
? 'Official OpenReader Instance'
: 'Open Source Document Reader';
return (
<>
<style>{`
/* ── Hero ────────────────────────── */
.landing-hero {
max-width: 72rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
text-align: center;
animation: landing-fade-up 0.45s ease-out both;
}
.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 ────────────────────── */
.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.
<main className="public-main">
<div className="public-wrap">
<section className="public-panel public-hero-shell public-reveal-1">
<article className="public-hero-panel">
<p className="public-eyebrow">{instanceBadge}</p>
<h1 className="public-hero-title">
Turn static files into a <em>living read-along surface</em>.
</h1>
<p className="public-hero-copy">
OpenReader is a self-host-friendly workspace for EPUB, PDF, TXT, MD, and DOCX. It combines
synchronized speech, fast navigation, and cloud or local TTS providers so long-form reading is easier
to sustain.
</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>
<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>
</section>
<div className="public-feature-grid public-rail">
<article className="public-feature-card">
<span className="public-feature-kicker">Parsing</span>
<h3>Structured document understanding</h3>
<p>Geometry-aware parsing gives stable reading order and cleaner highlighting, especially for complex PDF layouts.</p>
</article>
<article className="public-feature-card">
<span className="public-feature-kicker">Alignment</span>
<h3>ONNX-powered timing maps</h3>
<p>Speech alignment data links each spoken unit back to text so the cursor follows naturally during playback.</p>
</article>
<article className="public-feature-card">
<span className="public-feature-kicker">Providers</span>
<h3>Cloud + self-hosted voices</h3>
<p>Use OpenAI, DeepInfra, Replicate, or OpenAI-compatible local servers such as Kokoro or KittenTTS.</p>
</article>
<article className="public-feature-card">
<span className="public-feature-kicker">Distribution</span>
<h3>Audiobook-ready output</h3>
<p>Export chapterized audio and keep metadata clean for downstream players, with resumable processing for large docs.</p>
</article>
</div>
</section>
{/* ── FORMATS ────────────────── */}
<section className="landing-formats">
<p className="landing-formats-label">Supported formats</p>
<div className="landing-formats-row">
<span className="landing-format-pill">EPUB</span>
<span className="landing-format-pill">PDF</span>
<span className="landing-format-pill">TXT</span>
<span className="landing-format-pill">MD</span>
<span className="landing-format-pill">DOCX</span>
</div>
</section>
<section className="public-section public-reveal-3" aria-labelledby="workflow-heading">
<div className="public-section-head">
<h2 id="workflow-heading">Typical workflow</h2>
<p>Minimal friction from upload to listen.</p>
</div>
<div className="public-path public-rail">
<article className="public-path-step" data-step="01">
<h3>Drop a file</h3>
<p>Import a document into your library and keep it organized for repeat sessions.</p>
</article>
<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="landing-cta">
<div className="landing-cta-card landing-panel">
<div className="landing-cta-glow" aria-hidden="true" />
<h2>Start reading now</h2>
<section className="public-panel public-callout public-reveal-3" aria-label="Open source call to action">
<h2>Ship your own private reading stack.</h2>
<p>
Open the app and upload a document to begin.
Your progress syncs across devices automatically.
Run OpenReader locally or deploy it for your team. The docs cover Docker setup, provider integration,
object storage, and compute worker configuration.
</p>
<div className="landing-cta-actions">
<Link href="/app" className="landing-btn landing-btn-accent">Open App</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 className="public-actions">
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
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>
</section>
</>
</section>
</div>
</main>
);
}

View file

@ -1,11 +1,12 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { headers } from 'next/headers';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export const metadata: Metadata = {
title: 'Privacy & Data Usage | OpenReader',
title: 'Privacy & Data Usage',
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: {
canonical: '/privacy',
},
@ -17,8 +18,7 @@ export const metadata: Metadata = {
export default async function PrivacyPage() {
const effectiveDate = 'February 17, 2026';
const isRichardrDevProductionInstance =
process.env.RICHARDRDEV_PRODUCTION === 'true';
const isRichardrDevProductionInstance = process.env.RICHARDRDEV_PRODUCTION === 'true';
const hdrs = await headers();
const host = hdrs.get('host') ?? 'this server';
@ -26,308 +26,163 @@ export default async function PrivacyPage() {
const origin = `${proto}://${host}`;
return (
<>
<style>{`
/* ── Privacy body ───────────────── */
.privacy-body {
max-width: 42rem;
margin: 0 auto;
padding: 3rem 1.5rem 4rem;
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>
<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.
<main className="public-main policy-main">
<div className="public-wrap">
<section className="public-panel policy-hero public-reveal-1">
<h1>Privacy, data flow, and account controls.</h1>
<p>
This policy explains what data OpenReader processes, how it is used, and the controls available to you.
It applies to the instance currently running at <strong>{origin}</strong>. Effective date: {effectiveDate}.
</p>
<div className="policy-badges" aria-label="Key policy facts">
<span className="policy-badge">No personal data sales</span>
<span className="policy-badge">Analytics consent controls</span>
<span className="policy-badge">Account export + deletion tools</span>
<span className="policy-badge">Encrypted document storage</span>
</div>
) : 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>
{isRichardrDevProductionInstance ? (
<div className="policy-highlight">
<strong>US-only availability:</strong> this official instance is intended for users located in the United States,
and requests outside the US may be blocked.
</div>
</section>
) : null}
</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>
2. How We Use Your Information
</h2>
<ul className="list-disc pl-5 space-y-2 text-sm text-foreground/90">
<li>To provide, support, and personalize the OpenReader application.</li>
<li>To process your uploaded documents for display and text-to-speech conversion.</li>
<li>To maintain the safety, security, and integrity of our service.</li>
<li>To debug and repair errors that impair existing intended functionality.</li>
<div className="policy-grid public-reveal-2">
<aside className="policy-nav" aria-label="Privacy sections">
<p className="policy-nav-title">On this page</p>
<ul className="policy-nav-list">
<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>
</section>
</aside>
<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>
3. Sharing &amp; Selling
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
<strong>We do not sell your personal information.</strong>
</p>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
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.
</p>
<div className="privacy-card landing-panel">
<div className="privacy-card-label">Service Providers (Sub-processors)</div>
<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>
</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 ? (
<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 className="text-sm"><strong>Neon (PostgreSQL):</strong> Database Storage</li>
<li className="text-sm"><strong>Railway (S3):</strong> Encrypted Object Storage (Documents)</li>
<li className="text-sm"><strong>DeepInfra:</strong> Text-to-Speech Processing (User-Initiated)</li>
</ul>
<p>
For the official instance, processing and storage occur in the United States. By using this service,
you acknowledge that data is handled in the US and that availability may be limited to US users.
</p>
) : (
<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 className="text-sm"><strong>Database Service:</strong> Relational Database Storage</li>
<li className="text-sm"><strong>Object Storage:</strong> Encrypted File Storage (Documents)</li>
<li className="text-sm"><strong>TTS Provider:</strong> Text-to-Speech Processing (Based on your configured provider)</li>
</ul>
<p>
Data processing location depends on the deployment environment and provider configuration selected by
the instance operator.
</p>
)}
</div>
</section>
</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>
</div>
<div className="mt-4">
<p className="text-sm text-foreground/90 mb-2">
<strong>How to Exercise Your Rights:</strong>
<section id="contact" className="policy-section">
<h2>7. Contact and open source</h2>
<p>
Questions or concerns can be raised through the project repository. Self-hosting is available if you
want full infrastructure control.
</p>
<ul className="list-disc pl-5 text-sm text-foreground/90">
<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>Delete Data:</strong> Use the &quot;Delete Account&quot; button in Settings.</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>
5. Data Retention
</h2>
<p className="mb-4 text-sm leading-relaxed text-foreground/90">
We retain your account data and uploaded files only for as long as you maintain an account.
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.
</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>
6. US Processing &amp; Location
</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 className="policy-actions">
<a
href="https://github.com/richardr1126/openreader/issues"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'primary', size: 'md' })}
>
Open an Issue
</a>
<a
href="https://github.com/richardr1126/openreader#readme"
target="_blank"
rel="noopener noreferrer"
className={buttonClass({ variant: 'outline', size: 'md' })}
>
Self-Hosting Guide
</a>
<Link href="/?redirect=false" className={buttonClass({ variant: 'ghost', size: 'md' })}>
Back to home
</Link>
</div>
</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>
</>
</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 { CopyIcon, CheckIcon } from '@/components/icons/Icons';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false);

View file

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

View file

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { getConsentState, setConsentState } from '@/lib/client/analytics';
import { Transition } from '@headlessui/react';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
export function CookieConsentBanner() {
const [show, setShow] = useState(false);

View file

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

View file

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

View file

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

View file

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

View file

@ -4,13 +4,6 @@ export {
Field,
Section,
ToggleRow,
buttonClass,
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
listboxButtonClass,
listboxOptionClass,
@ -18,3 +11,13 @@ export {
segmentedButtonClass,
segmentedGroupClass,
} 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 { getAuthClient } from '@/lib/client/auth-client';
import { LoadingSpinner } from '@/components/Spinner';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
function sleep(ms: number) {
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 { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
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 { Fragment, useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface DocumentSelectionModalProps {
isOpen: boolean;

View file

@ -15,7 +15,8 @@ import {
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} 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 type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
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({
value,

View file

@ -1,51 +1,35 @@
'use client';
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
* the app (settings modal, document settings, and admin panels).
*/
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,
export {
btnBase,
btnDanger,
btnGhost,
btnOutline,
btnPrimary,
btnSecondary,
buttonClass,
type ButtonSize,
type ButtonVariant,
};
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
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
// 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 { XCircleIcon } from '@/components/icons/Icons';
import { useReaderSidebarBounds } from '@/hooks/useReaderSidebarBounds';
import { buttonClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
interface ReaderSidebarShellProps {
isOpen: boolean;

View file

@ -8,7 +8,7 @@ import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
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 { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
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(' ');
}