Skip to content

Migrate from legacy static HTML to Next.js full-stack app#6

Draft
Lachy-Dauth wants to merge 43 commits into
full-stack-mvpfrom
claude/book-converter-fullstack-tmVOV
Draft

Migrate from legacy static HTML to Next.js full-stack app#6
Lachy-Dauth wants to merge 43 commits into
full-stack-mvpfrom
claude/book-converter-fullstack-tmVOV

Conversation

@Lachy-Dauth

@Lachy-Dauth Lachy-Dauth commented May 20, 2026

Copy link
Copy Markdown
Owner

Summary

Complete rewrite of Bilingual Books from a legacy static HTML/JavaScript application to a modern Next.js 15 full-stack application with TypeScript, authentication, database persistence, and enhanced features.

Key Changes

  • Framework Migration: Replaced legacy HTML/JS with Next.js 15 (App Router), TypeScript, and Tailwind CSS
  • Authentication & Authorization: Integrated Better-Auth with Google Sign-in, user roles (admin/user), and tier-based access control (free/pro/unlimited)
  • Database Layer: Added PostgreSQL + Prisma ORM with schema for users, accounts, conversions, and statistics
  • Conversion Flows: Refactored into four distinct flows:
    • Paste text → bilingual EPUB
    • Upload EPUB → bilingual EPUB (preserves cover, chapters, TOC)
    • Browse popular Project Gutenberg books
    • Full-text search Gutenberg catalogue
  • Split Modes: Implemented three translation strategies (paragraph, sentence, sentence-aligned) with abbreviation-aware sentence splitting
  • Speed Control: Added configurable concurrency levels (2/6/12 parallel translations) with rate-limit retry/backoff
  • Email System: Gmail API integration (via refresh token) for transactional emails, with admin diagnostics panel
  • Admin Dashboard: User management, statistics tracking, email configuration testing
  • User Dashboard: Conversion history, statistics, account deletion
  • Paywall System: Per-tier monthly word limits with enforcement
  • Analytics & Consent: Google Analytics gated on user consent, cookie preferences UI
  • Styling: Custom CSS variables for theme support (warm/light/dark) with scoped legacy converter styles
  • Docker Deployment: Multi-stage Dockerfile for Railway with health checks and automated migrations

Notable Implementation Details

  • Client-side translation against Google Translate's free endpoint (no API key required)
  • EPUB parsing/building with JSZip, preserving metadata and structure
  • Sentence alignment algorithm for proportional source/target splitting
  • Anonymous user tracking via cookies, with migration to authenticated accounts
  • Middleware for anonymous ID injection
  • Environment validation at server startup with clear error messages
  • Prisma seed script for admin account creation
  • Helper scripts for Gmail token generation and email testing

claude added 30 commits May 20, 2026 04:43
Rewrites the static vanilla-JS site as a Next.js 15 + Postgres + Prisma
+ Better-Auth app while preserving the existing "translate at the edge"
architecture: every per-sentence translation still goes straight from
the browser to translate.googleapis.com — the Railway container never
sees translation traffic.

New features:
- Project Gutenberg search and conversion. Browser hits gutendex.com
  directly; book download proxies through /api/gutenberg/fetch (which
  caches in BookCache table and dodges CORS on raw .epub URLs).
- Optional accounts via email/password and Google OAuth (Better-Auth).
  Anonymous use still works — a bb_anon_id cookie scopes stats and
  migrates to the user on first sign-in.
- Stats dashboards: /dashboard for users, /admin for site-wide totals
  plus a paginated user table at /admin/users with per-user controls
  for tier, role, and active state.
- Paywall hooks in src/lib/paywall.ts with a TIER_LIMITS constant that
  currently returns Infinity for everyone; flip a single value to
  enforce a monthly word cap.
- Admin user is seeded from ADMIN_EMAIL/ADMIN_PASSWORD env vars by
  prisma/seed.ts, called from the Docker entrypoint after migrations.

Original index.html / main.js / epub-converter.js are kept under
legacy/ for reference; their logic is ported to TypeScript modules
under src/lib/converter/ and React components under
src/components/converter/.
The original info modal had a Buy Me a Coffee widget that got dropped
when porting to React. Brings it back as a reusable component and
places it in three spots:

- Info modal (where it was before)
- New site-wide footer that renders on every page
- Next to the Download EPUB button in PasteTab and EpubTab so it shows
  up at the moment a user just received their bilingual book

Uses a styled anchor instead of the upstream widget script because the
script auto-appends to document.body, which fights React's reconciler
inside an SPA modal.
The Dockerfile's `COPY --from=builder /app/public ./public` failed on
Railway because public/ was an empty directory locally and git doesn't
track empty directories, so it didn't exist in the fresh checkout.

Two fixes for belt-and-suspenders:
- Track public/ via a .gitkeep so the directory always lands in the
  build context.
- Add `mkdir -p public` in the builder stage so the COPY is safe even
  if .gitkeep is removed later.
The previous Dockerfile selectively copied a handful of packages from
the builder into the runner stage (prisma, @prisma, bcryptjs, tsx,
.bin). That missed transitive deps — Railway booted and immediately
failed with `Cannot find module 'effect'`, which is a transitive
dependency of @prisma/config that the prisma CLI loads.

Trying to enumerate every transitive dep is brittle, so:

- Drop `output: 'standalone'` from next.config.mjs. We don't need the
  trace-based optimization here; ergonomics beat the ~50MB saving.
- Copy the entire builder node_modules into the runner image. Now both
  `npx prisma migrate deploy` and `npx tsx prisma/seed.ts` resolve
  cleanly because their full dep tree is present.
- Switch CMD to `npm run start` (which calls `next start`) since
  standalone is gone.
- Use `npx --no-install` in the entrypoint so it never tries to fetch
  from npm at boot.
…scroll

Previous approach guessed nav (~50px) and footer (~70px) heights and
set .site-content { min-height: calc(100vh - 140px) }. Any drift in
those component heights — different font, slightly taller nav links,
extra padding — would push total content past the viewport by a few
pixels and add a small permanent scrollbar.

Replace it with the flexbox column pattern:
- body { min-height: 100vh; display: flex; flex-direction: column }
- .site-content { flex: 1 0 auto }
- .site-footer { flex-shrink: 0 }

Short content keeps the footer at the bottom of the viewport; tall
content scrolls normally. No magic numbers, no off-by-N-pixels.
The Gutenberg flow renders <EpubTab gutenbergSeed={...} /> with the
downloaded bytes. EpubTab kicked off parsing with a render-time call:

    if (gutenbergSeed && !parsed && phase === 'idle') {
      void handleFile(gutenbergSeed.bytes, ...)
    }

That's an async function that schedules setState before its parsing
promise resolves. While parsing was in flight, React re-rendered for
other reasons, the condition was still true, handleFile fired again,
and so on — Minified React error #301 (too many re-renders).

Move the seeding into a useEffect keyed on the seed object so it runs
once on mount. Also rewrite the in-flight setSl/setTitleOverride checks
as functional updaters so they read the latest state, not a stale
closure value, when the parse completes.
Previously the Download EPUB button only rendered inline in the .actions
row. After generation that row sits above a very long output list, so
once the user scrolled into the output they had to scroll all the way
back up to download. The legacy static site dodged this by
document.body.prepend()-ing a download bar to the top of the page.

New <DownloadBar> wraps its children in a `position: sticky; top: 0`
container so the Download button is visible immediately when the book
is ready and stays pinned to the top as the user scrolls through the
output. Used in both PasteTab (Download + Start over + BMC) and EpubTab
(Download + BMC). Removed the duplicate inline buttons from the
.actions row so the post-conversion controls live in one place.
Two related changes to the EPUB / Gutenberg flow:

1. Preserve <br> line breaks in extractBlocks.

   The old extractor did `el.textContent.replace(/\s+/g, ' ')` which
   collapsed every whitespace boundary — including the newlines that
   <br/> creates. That's why the Animal-Farm-style three-slogan block
   ("WAR IS PEACE" / "FREEDOM IS SLAVERY" / "IGNORANCE IS STRENGTH")
   came out as a single run-on line in both source and translation.

   The new extractTextWithBreaks walks the element with a TreeWalker,
   appending text nodes verbatim and substituting "\n" for <br/>
   elements. extractBlocks then splits the normalized text on \n so
   each visual line becomes its own Block with its own translation call
   — the line breaks survive the round-trip into the bilingual EPUB.

   The last sub-block of each original element is flagged with
   paragraphEnd: true so we can give it a larger bottom margin.

2. Add Paragraph / Sentence split mode to EpubTab.

   New segmented control above the language inputs. Paragraph mode
   leaves blocks as parsed (one per <br>-separated line). Sentence
   mode further splits non-heading blocks via splitSentences()
   (handles . ! ? plus the CJK 。!? and Arabic ؟). Toggling the
   mode re-expands from rawParsed and resets the output, so each
   mode starts from a clean slate.

   Both modes now use a tighter inter-block gap (0.4em in EPUB,
   ~4px on screen) with a larger gap (1.4em / 18-28px) on
   paragraph-end blocks. This gives the sentence mode visible
   paragraph grouping while keeping pairs from the same paragraph
   tightly stacked.

GutenbergTab picks all of this up automatically since it forwards to
EpubTab with a gutenbergSeed prop.
Some EPUB readers decode the file as Latin-1 even when the XHTML
declares UTF-8, so U+2192 ('→') came out as the classic mojibake
'â†→'. Swap the arrow for plain ASCII 'to' in:

- Title page edition line: "Bilingual edition: en to fr"
- Default book title when user leaves the title field blank
- buildSimpleEpub default title
- PasteTab logConversion bookTitle fallback (in case it ever flows
  back into an EPUB header downstream)

The arrows in dashboard and admin JSX stay — those are rendered to
HTML by React in a browser and decode as UTF-8 reliably.
Third option in the EPUB/Gutenberg mode toggle. Translates each
paragraph as a single chunk — so the translator has full sentence
context — then chops both the source paragraph and its translation
into sentences and aligns them visually.

Implementation:
- SplitMode union gains 'sentence-aligned'.
- expandBlocks treats 'sentence-aligned' identically to 'paragraph'
  during translation. The pipeline produces paragraph-level pairs
  that stream into the on-screen view exactly like paragraph mode.
- After translateAll finishes, sentenceAlignParsed walks each block
  and proportionally maps src sentences to tgt sentences. Sentence
  counts often differ between languages, so we use slice ratios:
  each output pair gets floor(i * src.length / M) ... floor((i+1) *
  src.length / M) of the source list and the same proportion of the
  target list, where M = min(src.length, tgt.length). Equal counts
  pair 1:1; mismatched counts cluster excess sentences into adjacent
  pairs rather than dumping them on the last row.
- Headings stay as a single block in all three modes.
- paragraphEnd is preserved on the final aligned sub-block so the
  larger inter-paragraph gap still works.
Some EPUBs use <br> for visual line-wrap that doesn't match logical
paragraph boundaries (e.g., a poem with <br> after every line, or
mid-sentence line breaks). Treating each <br>-separated line as its
own translation pair gives bad results for those books.

New segmented control under the Split toggle:
- Preserve (default, current behavior): each <br>-separated line is
  its own block. Good for things like the Orwell three-slogan block.
- Collapse: merge sub-blocks back into one block per original <p>.
  Good for books that abuse <br>.

Implementation:
- New collapseLineBreaks() merges consecutive sub-blocks within a
  paragraph (walking until paragraphEnd: true) into a single
  space-joined block.
- expandParsed/expandChapters now take a collapseBreaks flag. The
  collapse runs BEFORE mode-specific expansion, so all three split
  modes compose correctly with both toggle states.
- EpubTab adds the toggle next to the Split control and re-expands
  the displayed chapters when either flips. Re-expanding resets the
  output, so toggling after a conversion starts a clean slate.
…oltips

Three converter-tab improvements:

1) splitSentences understands abbreviations.

   The old "split at any . followed by whitespace" approach treated
   "Dr.", "Mr.", "U.S.", initials like "J. R. R.", "e.g.", "p.m.",
   etc. as sentence boundaries. Now does a naive split, then walks the
   resulting parts: if a chunk ends with a known abbreviation or a
   single uppercase letter, merge it with the next part and retry.

   Abbreviation list is intentionally conservative — only titles,
   well-known Latin (etc, vs, cf, viz, e.g, i.e), dotted forms (a.m,
   p.m, U.S, U.K, U.S.A), and org suffixes (inc, ltd, corp). Ambiguous
   bare-letter forms ("no", "pp", "am", "co") are excluded because
   they're more often real words than abbreviations in book prose.

   CJK punctuation (。!?) now splits without requiring trailing
   whitespace, because Chinese/Japanese typically don't space between
   sentences. Tested against 10 cases — Dr./Mrs./initials/U.S./e.g.
   /p.m. all stay whole; "She said no." correctly splits; CJK splits
   on punctuation alone.

2) Language inputs become combobox-style.

   New LANGUAGES list with code + English name for ~80 languages, and
   a shared LanguageDatalist mounted once in ConverterShell. Both
   PasteTab and EpubTab now use a LanguageInput component that's a
   plain <input list="..."> — typing "en" / "eng" / "english" all
   surface English in the browser's native autocomplete. autoComplete,
   spellCheck, and autoCapitalize are off so the dropdown is clean.

3) HelpTip component with hover/focus tooltip.

   Reusable ? icon next to a label that on hover (or keyboard focus,
   for touch) reveals a small panel with formatted text. Added to:
   - "Split translation by" — explains Paragraph vs Sentence vs
     Sentence (aligned), so users discover the new modes.
   - "Line breaks (<br>)" — explains Preserve vs Collapse.
Browsers render <datalist> as an OS-native dropdown that's basically
unstylable — couldn't make it match the rest of the converter UI.
Replaced LanguageInput with a real combobox component:

- Opens on focus / click / arrow key, closes on Escape, blur outside,
  or Tab. Filters in real time as the user types.
- Match scoring: exact code > code prefix > name prefix > word prefix
  > name contains > code contains. Ties broken by alphabetical name.
- Keyboard navigation: arrow up/down moves highlight, Enter picks,
  highlighted row auto-scrolls into view.
- Each row shows the language name and its code (monospace, muted)
  side by side. Selected option becomes the input value (lowercased
  code).
- A small pill next to the input shows the resolved language name
  when the typed code matches a known language and the dropdown is
  closed — so the user can confirm "en" actually means English.
- "No language matches" empty state for nonsense queries.

CSS uses the existing accent / surface / border tokens so it picks up
both the warm and the black-and-white theme automatically. Custom
scrollbar styling, hover/highlight states, chevron that flips on
focus, and the same border-radius as the rest of the form fields.

LanguageDatalist is kept as a no-op export so any straggler import
still resolves, but ConverterShell no longer renders it.
Make the site closer to GDPR / CCPA / ePrivacy compliant:

- New ConsentProvider stores the user's analytics choice in
  localStorage under bb_consent. Three states: undecided (null),
  'accepted', 'rejected'. Hydrated flag avoids the SSR/CSR flash.
- Analytics component now wraps the GA gtag.js script. It renders
  nothing — no requests, no cookies — until choice === 'accepted',
  so EU traffic doesn't load GA without consent. IP anonymization is
  enabled in the GA config when it does load.
- ConsentBanner renders fixed at the bottom of the viewport while the
  choice is null, with Accept / Reject buttons and a link to the
  privacy policy. Hides immediately after either button is clicked.
- CookiePreferences button in the footer resets the choice so the
  banner reappears — required so users can withdraw consent.
- New /privacy page lists every cookie we set (bb_anon_id,
  bb.session_token, GA), every third party (Google Translate,
  Gutendex/Gutenberg, Google OAuth, Railway), what we collect under
  accounts, conversions, and server logs, user rights, retention,
  and contact. Bracketed [your-email@example.com] / [your name] /
  [your jurisdiction] placeholders for the site owner to fill in.
- New /terms page covers acceptance, account responsibility,
  acceptable use (no copyrighted content you don't own, no abuse of
  the upstream translator), translation-quality disclaimer, no
  warranty, limitation of liability, governing-law placeholder,
  changes, and contact. Same bracketed placeholders.
- Footer gets Privacy / Terms / Cookie preferences links next to the
  tagline and the Buy-Me-a-Coffee button.
- CSS for the banner (fixed bottom, accent buttons), footer links,
  and policy-page typography (.prose-policy with sane defaults for
  h1/h2/h3/p/ul/li/code/a).

Layout no longer unconditionally loads gtag.js — that part is now
inside <Analytics /> which honors consent.
Translation throughput was hardcoded to PARALLEL_TRANSLATIONS = 6
inside translateAll(). Make it configurable from the UI so users can
trade speed against Google Translate's rate limit:

- SpeedMode union ('slow' | 'normal' | 'fast') with a SPEED_LEVELS
  map: slow=2, normal=6 (unchanged default), fast=12.
- translateAll() gains an optional concurrency parameter (defaults to
  PARALLEL_TRANSLATIONS for backwards compatibility).
- EpubTab gets a third segmented control between Split and Line
  breaks. Disabled while translating. Selection feeds into
  translateAll via SPEED_LEVELS[speed].
- HelpTip on the label explains the trade-off: Slow never trips
  Google's 429, Normal is the proven default, Fast may end up no
  faster (or slower) due to backoff retries. Per-button title
  attributes for a quick hover summary.

PasteTab is left sequential — its translations are short and
single-threaded sequencing keeps the streaming output predictable.
Two compounding bugs hid the post-conversion download bar:
1. downloadRef.current was a ref, so assigning it after buildEpub()
   completed did not trigger a re-render. The download bar's
   conditional re-evaluated only on the next unrelated state change.
2. setPhase('done') fired BEFORE buildEpub ran, so the one render
   that did happen had phase='done' but the ref still null —
   downloadReady computed to false.

Convert downloadRef to a piece of state and reorder so the blob is
built first, setDownload({blob, filename}) is called, then
setPhase('done'/'cancelled'). The setPhase render now sees both
phase=done AND download set, so the sticky DownloadBar appears
immediately. The mode/break useEffect and handleFile both call
setDownload(null) to clear it when starting over.
End-to-end sentence-aligned test on Dr. Smith dialog uncovered two
splitter quirks that hurt the alignment when source/target sentence
counts disagreed:

1. Period-then-quote (.\" or .') wasn't a sentence boundary.
   'Dr. Smith said, "It was a long day." She then sat down.' was
   one sentence in English but Google translates it into two French
   sentences (close-guillemet after journée.). The proportional
   alignment then merged the French " » Elle s'est ensuite assise."
   into the wrong pair.

2. Naively adding .\"  + space as a boundary introduced false splits
   for mid-sentence dialog like 'He thought "Wait..." but stayed.'

Fix: split at [.!?؟] optionally followed by ["'”’»)], then whitespace,
ONLY when the next character is NOT a lowercase letter. Uppercase,
digits, opening quotes, and scripts without case (CJK, Arabic, Hindi)
all qualify as sentence-starters; lowercase doesn't.

Verified with 8 dialog cases (parenthetical, single-quote, smart
quotes, ellipsis-mid-sentence, leading quote) plus all 10 previous
abbreviation / CJK / empty cases. Live en->fr pipeline now produces
3:3 alignment for the Dr. Smith paragraph instead of 2:3.
Replace the "press Ctrl+P / Cmd+P yourself" instruction with a real
one-click button that opens the print dialog. Uses the browser's
native print-to-PDF, which:
- handles every script (CJK, Arabic, RTL) and font correctly,
- produces a real, searchable PDF (text not images),
- adds zero JS dependencies,
- works on mobile.

Print stylesheet (@media print) does the heavy lifting:
- Hides navbar, footer, tabs, all form fields, segmented controls,
  status banners, progress bars, cookie banner, download bar.
- Forces white background, black text.
- Each chapter starts on a new page (page-break-before: always),
  except the first chapter.
- Pairs render as 50/50 tables with page-break-inside: avoid so they
  never split across pages.
- paragraph-end gets a larger bottom margin so paragraph groups stay
  visually clustered.
- @page sets sensible 1.5cm / 1.25cm margins.

SaveAsPdfButton lives in PasteTab and EpubTab's DownloadBar so it
appears alongside Download EPUB once a conversion finishes. Info
modal updated with one-line "click Save as PDF" instructions.
Previously {tab === 'X' && <XTab />} unmounted whichever tab the user
wasn't viewing. Each tab keeps its own useState — language codes,
parsed EPUB, translations, download blob, mode/speed/break settings,
in-flight translation progress — and unmounting nuked all of it. A
user mid-conversion in the EPUB tab who clicked "Paste text" to peek
at the other input lost their entire book on the way back.

Render all three tabs unconditionally and toggle the `hidden`
attribute on a wrapper div instead. The inactive tabs stay in the
React tree with their state intact and any in-flight translateAll
promises keep streaming progress into their (currently invisible)
state. Switching back to the tab reveals the live state.

Same accessibility behavior as display:none — hidden subtree is
removed from the a11y tree and from tab order — but more semantic.
…got-password, plus Popular Books tab

Five small improvements bundled together:

1. Advanced options collapsed by default.
   The three "expert" knobs (Split mode, Speed, Line breaks) now live
   inside <details>Advanced options</details> so the converter looks
   clean for first-time users. Defaults changed to the safest combo:
   Paragraph / Normal / Collapse. The previous default (Preserve
   line breaks) caused weirdness on EPUBs that abuse <br> for visual
   line-wrap; Collapse is the right starting point.

2. Settings persist across reloads.
   New useLocalStorage hook syncs state to a localStorage key on every
   change. Applied to Split mode, Speed, Line breaks (EpubTab) and
   the Popular tab's language picker.

3. Cancel button on PasteTab.
   Mid-paste-translation can now be cancelled, mirroring EpubTab. The
   for-loop checks cancelRef on every iteration and translateText is
   passed the cancel signal so an in-flight fetch is short-circuited.
   Cancelled status is logged via the conversions endpoint so admins
   can see how often it happens.

4. Delete-account button on /dashboard.
   New server action deleteOwnAccount() does prisma.user.delete(),
   relying on Session.onDelete: Cascade to invalidate the cookie and
   Conversion.onDelete: SetNull to anonymize past conversions for the
   aggregate stats. UI is a two-step confirm to avoid accidents.

5. Forgot-password flow.
   /forgot-password and /reset-password pages with Better-Auth's
   requestPasswordReset and resetPassword calls. The server's
   sendResetPassword callback currently console.logs the reset URL
   (visible in `railway logs`) with a comment showing how to wire
   Resend / Postmark / SES when ready. Sign-in form now has a
   "Forgot?" link.

6. Env-var validation at boot.
   New src/lib/env.ts validates DATABASE_URL, BETTER_AUTH_SECRET, and
   BETTER_AUTH_URL with helpful error messages. Wired via
   instrumentation.ts so it runs once when the Node process starts,
   not on first request. Build time is exempted (NEXT_PHASE check)
   so deploys with placeholder envs still build.

7. NEW: Popular books tab (extra feature).
   Lists the top 20 Gutenberg books for any language, ordered by
   download count. Language picker uses the existing LanguageInput
   combobox; selection persists in localStorage. Picking a book runs
   the same /api/gutenberg/fetch proxy + EpubTab seeding flow as
   Search Gutenberg. Shared logic extracted into useGutenbergPicker
   hook and GutenbergResultsList component to avoid duplication.

Also fixed a Better-Auth API rename: the client method is
requestPasswordReset, not forgetPassword (newer naming in v1.1+).
Adds resend as a dependency and a small src/lib/email.ts wrapper that
gracefully degrades when RESEND_API_KEY is unset (logs the would-have-
sent message so local dev still works without keys).

sendResetPassword in src/auth.ts now calls sendEmail() with a proper
HTML template — branded, inline-styled (because email clients drop
external CSS), with a primary button, a plain-text fallback URL, and
a small footer with the contact address from /privacy.

New env vars in .env.example:
- RESEND_API_KEY (recommended)
- RESEND_FROM (default 'Bilingual Books <onboarding@resend.dev>',
  which works without DNS setup but only delivers to the email you
  signed up with; production needs a verified domain)

env validator marks RESEND_API_KEY as RECOMMENDED, so the container
boots without it and just warns in logs.

To go live with real emails:
  1. Sign up at resend.com, copy the API key.
  2. Set RESEND_API_KEY in Railway.
  3. (Optional) verify a domain in Resend and update RESEND_FROM.
User has no domain to verify, so Resend's sandbox sender ('onboarding@
resend.dev') would only deliver to the address they signed up with —
useless for real password resets. Switch to nodemailer with Gmail SMTP
which sends from a regular Gmail account using an App Password — no
domain required.

Changes:
- Remove `resend` dep, add `nodemailer` + `@types/nodemailer`.
- Rewrite src/lib/email.ts to use nodemailer.createTransport. Defaults
  to smtp.gmail.com:465 with TLS; SMTP_USER/SMTP_PASS picked up from
  env. Same graceful-degradation pattern: if creds are missing, the
  helper logs the message instead of sending so dev/first-deploy stays
  unbroken.
- .env.example documents Gmail App Password setup (enable 2SV, generate
  at myaccount.google.com/apppasswords, paste the 16-char string into
  SMTP_PASS).
- env validator's RECOMMENDED list now warns about SMTP_USER/SMTP_PASS
  instead of RESEND_API_KEY.

Gmail's outbound limit is 500/day on free accounts, 2000/day on
Workspace — plenty for password resets.
User reported the /forgot-password "Sending..." state hanging
indefinitely. Cause: the SMTP send was awaited inside Better-Auth's
sendResetPassword callback, so the HTTP response waited for nodemailer
to finish — and a misconfigured Gmail account or blocked outbound
SMTP port can take 30-60s to time out.

Two fixes:

1. Don't await sendEmail in the auth callback. void + .catch() logs
   delivery failures server-side, but the response returns the moment
   the token is generated. The user always sees "Check your email"
   within a few hundred ms, even if Gmail rejects the connection.
   Security bonus: not telling the user whether SMTP succeeded means
   we don't leak whether their email actually has an account.

2. Add explicit nodemailer timeouts (10s connect/greeting, 15s socket)
   so a stuck SMTP connection can't sit in the background forever.
   Background failures still show up in Railway logs with the
   recipient address, so misconfiguration is debuggable.
New panel at the bottom of /admin shows the resolved SMTP config
(host, port, user, from, configured-yes/no) and pings the SMTP server
on load to confirm Better-Auth could authenticate. Below that, a
"Send test email" input + button delivers a real test message so you
can verify end-to-end that Gmail (or any SMTP host) is accepting
mail.

Failures bubble back with a stage label — "config" (env vars
missing), "verify" (auth or connection error from transporter.verify),
"send" (connection OK but send rejected). The exact nodemailer error
message is shown so issues like "Invalid login: 535-5.7.8 Username
and Password not accepted" are easy to spot.

GET /api/admin/email-test returns the config + verify result for
the panel; POST sends a test message. Both routes use requireAdmin
so only admins see anything.

Also exports getEmailConfig() and verifyEmailTransport() helpers from
src/lib/email.ts so future diagnostics can reuse them.
…e dev deps

Three independent optimizations to the deploy pipeline:

1. prisma generate ran THREE times per build (postinstall hook +
   explicit RUN step + npm run build prefix). Drop the postinstall
   hook and the build-script prefix; the Dockerfile's single
   `npx prisma generate` is now the only call. ~7s shaved.

2. Add BuildKit syntax directive and a cache mount for ~/.npm on the
   npm ci step. Railway uses BuildKit by default; the cache persists
   across builds so dep-graph changes only refetch the diff instead
   of every tarball. Also add --prefer-offline so npm reads from the
   cache first.

3. Slim the runner stage. Promote `prisma` (used by `prisma migrate
   deploy` in the entrypoint) and `tsx` (used by the seed script) to
   `dependencies` so they survive `npm prune --omit=dev` in the
   runner. Pruning removes eslint, typescript, tailwindcss, postcss,
   autoprefixer, @types/*, etc. — none of which `next start` needs.
   Expected image-size drop ~80-120 MB → faster Railway push/pull.

Also expand .dockerignore (*.log, *.tsbuildinfo, coverage, out,
dist, .vscode, .idea) to shrink the build context.

Lockfile regenerated to reflect the prisma/tsx promotion.
The /admin/email-test panel reported:
  Failed: connect ENETUNREACH 2404:6800:4003:c00::6c:465

Gmail's smtp.gmail.com returns both A and AAAA records; nodemailer
preferred the AAAA result and tried to dial Google's IPv6 endpoint.
Railway's outbound IPv6 isn't routed, hence ENETUNREACH.

Setting family: 4 on the transporter pins DNS resolution to IPv4
records only, which Railway can reach.
…option

Previous commit added family:4 to the nodemailer transport options but
the @types/nodemailer@8 typings reject it on SMTPTransport.Options,
breaking `next build`. Switch to Node's DNS-level preference which is
cleaner anyway: dns.setDefaultResultOrder('ipv4first') at module load
makes the whole process prefer A records over AAAA without touching
the transport config.

This still resolves the original ENETUNREACH-on-IPv6 error from
/admin/email-test against Gmail.
claude added 8 commits May 20, 2026 09:25
Railway's BuildKit rejects --mount=type=cache without an id field:
  dockerfile invalid: flag '--mount=type=cache,target=/root/.npm'
  is missing an id argument at Line 7
Naming the cache "npm-cache" satisfies the requirement without
changing semantics.
Railway's builder rejected both forms:
  flag '--mount=type=cache,target=/root/.npm' is missing an id
  flag '--mount=type=cache,id=npm-cache,target=...' is missing the cacheKey prefix from its id

Railway uses a custom-namespaced cache convention that isn't documented
in a stable form, so removing the mount entirely. The other build-speed
improvements stay in place:

- prisma generate runs once (was 3x)
- Docker layer caching still skips `npm ci` whenever package.json /
  package-lock.json don't change (this is the biggest win on Railway
  anyway)
- Runner image still prunes dev deps for a smaller artifact

Result: code-only deploys reuse the cached deps layer; only when the
lockfile changes do we re-download from npm registry.
Replace the in-converter "Toggle Theme" button (which only flipped
between two palettes) with a proper three-way theme system:

- Warm (default — the existing cream / brown palette)
- Light (the previous body.bw stark white/black, renamed)
- Dark (new): dark blue-grey background #0f1115 / surface #1b1e25 /
  text #e6e6e6, with a brighter warm-orange accent rgb(214,156,86)
  tuned for WCAG AA contrast on the dark background.

Implementation:
- ThemeProvider client context (src/components/theme/ThemeProvider.tsx)
  syncs choice to localStorage under bb_theme, applies the class to
  <html> so it survives navigation.
- ThemeScript (src/components/theme/ThemeScript.tsx) is an inline
  script injected into <head> that reads bb_theme and applies the
  class BEFORE first paint. No flash of the wrong palette on SSR.
- ThemeToggle (src/components/theme/ThemeToggle.tsx) is a compact
  pill with three Lucide icons (Coffee for Warm, Sun for Light, Moon
  for Dark), highlighted with the active state. Rendered inside the
  Navbar so it shows on every page, not just the converter.
- globals.css: all body.bw selectors migrated to html.theme-light
  via a global rename. New html.theme-dark palette added. New
  .theme-toggle / .theme-toggle-btn styles.
- ConverterShell: removed the bottom "Toggle Theme" button — the
  navbar control supersedes it. Old src/components/converter/
  ThemeToggle.tsx deleted (no longer imported).
Two related fixes for the still-failing /admin/email-test:

1. Runner Dockerfile now sets:
     ENV NODE_OPTIONS="--dns-result-order=ipv4first"

   The earlier dns.setDefaultResultOrder('ipv4first') in src/lib/email
   wasn't enough on Railway — Node was still picking AAAA records for
   smtp.gmail.com, opening an IPv6 socket, and failing with
   ENETUNREACH because Railway's IPv6 egress isn't routed. Setting the
   flag via NODE_OPTIONS forces IPv4 ordering at process start, before
   any code (including the email module) runs.

2. New `npm run email:test` script (scripts/test-email.ts) so SMTP
   can be debugged locally without redeploying:

     npm run email:test -- you@example.com

   - dotenv loads .env.local (also .env as fallback) so the same
     SMTP_USER / SMTP_PASS that you'd set in Railway can be tested
     locally.
   - Prints resolved config (host, port, user, from).
   - Runs the same verify() the admin panel uses.
   - Sends a real test message.
   - On failure, suggests the most likely fix (App Password format,
     IPv4 ordering, …).

   dotenv added as devDependency. Script also takes the IPv4 env var
   suggestion for local dev: NODE_OPTIONS="--dns-result-order=ipv4first"
   npm run email:test -- ... if your machine also has flaky IPv6.
Railway's /admin/email-test still showed connect ENETUNREACH on an
IPv6 address even with NODE_OPTIONS=--dns-result-order=ipv4first.
Something in the Railway runtime is bypassing the DNS-order
preference and picking the AAAA record anyway.

Stop relying on DNS preference and do the lookup ourselves with an
explicit family: 4. The first time getTransporter() is called we run
dns.lookup(host, { family: 4 }), get back the IPv4 address, and pass
THAT as the connect host. tls.servername stays set to the original
hostname so the cert validates as 'smtp.gmail.com'. The resolved
transporter is cached for the rest of the process.

If the IPv4 lookup itself fails (no A records, network down, …),
fall back to the hostname so the existing failure mode surfaces in
the diagnostics panel rather than this code path silently swallowing
the error.

verifyEmailTransport() and sendEmail() are now async one extra await
deep but their public surface and callers are unchanged.
Railway blocks outbound SMTP entirely (port 465 + 587 both time out),
so nodemailer / Gmail SMTP cannot work from Railway no matter what
flags we set. Switch the transport to Gmail's REST API over HTTPS
(port 443, which Railway always allows).

Auth model:
- Reuses GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET (already configured
  for Sign-in-with-Google).
- One new env var: GMAIL_REFRESH_TOKEN.
- Optional: GMAIL_SEND_FROM (defaults to bilingualbooksgen@gmail.com).

How sending works:
- On first send, POST to https://oauth2.googleapis.com/token with the
  refresh token → get an access token, cached for ~1h.
- Build an RFC 2822 MIME message, base64url-encode the raw bytes.
- POST { raw } to https://gmail.googleapis.com/.../messages/send with
  the access token as a Bearer. No SMTP, no port issues.
- Subject lines with non-ASCII are RFC 2047 encoded.

Bootstrap helper:
- New `npm run gmail:get-token` script spins up a local server on
  port 47432, opens a Google OAuth consent for the gmail.send scope,
  captures the auth code, exchanges it for a refresh token, and
  prints the token. One-time. The README explanation lives in
  .env.example and at the top of the script.

Diagnostics:
- /admin/email-test panel and `npm run email:test` updated for the
  new config shape (mode, from, client ID, refresh-token present?).
  verify() now refreshes the access token to prove the credentials
  are still valid; failure messages include hints for the common
  errors (invalid_grant, invalid_client).

Cleanup:
- nodemailer + @types/nodemailer + the IPv4-forcing dns code +
  NODE_OPTIONS Docker env are all gone — no longer needed.
- .env.example, src/lib/env.ts updated.

Required Google Cloud Console steps (documented in env.example and
in the get-gmail-refresh-token.ts header):
- Enable Gmail API in the project.
- Add scope https://www.googleapis.com/auth/gmail.send to the OAuth
  consent screen.
- Add http://localhost:47432/oauth/callback to the OAuth client's
  authorized redirect URIs (only used during local token bootstrap).
Password reset wasn't worth more iteration right now. Tear out only
the user-visible surface:

- DELETE: src/app/forgot-password/, src/app/reset-password/
- DELETE: src/components/auth/ForgotPasswordForm.tsx, ResetPasswordForm.tsx
- src/components/auth/SignInForm.tsx: drop the "Forgot?" link next to
  the password field.
- src/auth.ts: remove the sendResetPassword callback so Better-Auth
  doesn't expose /api/auth/request-password-reset. Leave a comment
  showing exactly how to re-enable it later.

KEEP (so re-enabling is a few minutes' work, not a rewrite):
- src/lib/email.ts — Gmail API over HTTPS transport.
- scripts/get-gmail-refresh-token.ts — refresh token bootstrap CLI.
- scripts/test-email.ts — local SMTP-style smoke test.
- src/app/api/admin/email-test/ — admin-only diagnostics endpoint.
- src/components/admin/EmailDiagnostics.tsx — panel on /admin that
  shows config + Auth check + sends a test message.
- npm scripts (email:test, gmail:get-token), env vars, and the
  GMAIL_REFRESH_TOKEN RECOMMENDED warning in src/lib/env.ts.

The admin diagnostics still exercise the full send pipeline so once
you have Gmail OAuth set up in Railway you can verify it works
without re-shipping a public reset flow.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates Bilingual Books from a legacy static site to a Next.js 15 full-stack application with TypeScript, Prisma/Postgres persistence, Better-Auth authentication, and a rebuilt bilingual EPUB conversion UI (paste, upload, Gutenberg browse/search) plus admin/user dashboards.

Changes:

  • Added Next.js App Router app with converter UI (paste + EPUB + Gutenberg flows), theme + consent gating, and dashboards.
  • Introduced authentication (Better-Auth), anonymous tracking/migration, and Prisma schema/migrations for users/sessions/conversions/cache.
  • Added deployment tooling for Railway (Dockerfile, entrypoint, health endpoint) and supporting scripts/docs.

Reviewed changes

Copilot reviewed 97 out of 109 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tsconfig.json TypeScript config for Next.js app
tailwind.config.ts Tailwind content + theme tokens
src/middleware.ts Sets anonymous ID cookie
src/lib/validators.ts Zod schemas for API inputs
src/lib/stats.ts Prisma aggregation helpers
src/lib/paywall.ts Monthly word-limit checks
src/lib/migrate-anon.ts Claim anon conversions on sign-in
src/lib/gutenberg.ts Gutendex client + Gutenberg fetch
src/lib/env.ts Runtime env validation
src/lib/email.ts Gmail OAuth email transport
src/lib/db.ts PrismaClient singleton
src/lib/converter/util.ts Converter utility helpers
src/lib/converter/types.ts Converter type definitions
src/lib/converter/translate.ts Browser translation pipeline
src/lib/converter/sentences.ts Abbreviation-aware sentence split
src/lib/converter/paste.ts Paste-flow sentence splitting
src/lib/converter/languages.ts Supported language list
src/lib/converter/expand.ts Split-mode expansion logic
src/lib/converter/epub-parse.ts EPUB parsing into blocks
src/lib/converter/epub-build.ts EPUB generation + download
src/lib/converter/constants.ts Converter constants + helpers
src/lib/converter/align.ts Sentence-aligned mode
src/lib/client/api.ts Client calls for precheck/logging
src/lib/auth-helpers.ts Session/user/admin helpers
src/lib/anon.ts Anonymous cookie helpers
src/instrumentation.ts Boot-time env validation hook
src/hooks/useLocalStorage.ts localStorage-synced state hook
src/components/theme/ThemeToggle.tsx Theme picker UI
src/components/theme/ThemeScript.tsx Pre-hydration theme script
src/components/theme/ThemeProvider.tsx Theme context + persistence
src/components/Navbar.tsx Site navigation + auth links
src/components/Footer.tsx Footer + privacy/terms/consent
src/components/dashboard/StatsCards.tsx Dashboard stats card grid
src/components/dashboard/DeleteAccountButton.tsx Self-delete UI
src/components/dashboard/ConversionTable.tsx Conversion history table
src/components/converter/useGutenbergPicker.ts Gutenberg selection hook
src/components/converter/SaveAsPdfButton.tsx Print-to-PDF trigger
src/components/converter/PopularTab.tsx Popular Gutenberg tab
src/components/converter/PasteTab.tsx Paste-to-EPUB flow
src/components/converter/LanguageInput.tsx Language combobox input
src/components/converter/InfoModal.tsx Converter info modal
src/components/converter/HelpTip.tsx Inline help tooltip
src/components/converter/GutenbergTab.tsx Gutenberg search tab
src/components/converter/GutenbergResultsList.tsx Gutenberg result cards
src/components/converter/EpubTab.tsx Upload/Gutenberg EPUB flow
src/components/converter/DownloadBar.tsx Sticky download actions bar
src/components/converter/ConverterShell.tsx Tabbed converter shell
src/components/consent/CookiePreferences.tsx Consent reset control
src/components/consent/ConsentProvider.tsx Consent state management
src/components/consent/ConsentBanner.tsx Cookie consent banner
src/components/consent/Analytics.tsx Consent-gated GA loader
src/components/BuyMeACoffee.tsx Donation link component
src/components/auth/SignUpForm.tsx Sign-up form (email/Google)
src/components/auth/SignInForm.tsx Sign-in form (email/Google)
src/components/admin/UserControls.tsx Admin user controls UI
src/components/admin/EmailDiagnostics.tsx Admin email diagnostics UI
src/auth.ts Better-Auth server config
src/auth-client.ts Better-Auth React client
src/app/terms/page.tsx Terms page
src/app/sign-up/page.tsx Sign-up page
src/app/sign-out/page.tsx Sign-out page
src/app/sign-in/page.tsx Sign-in page
src/app/privacy/page.tsx Privacy page
src/app/page.tsx Home routes to converter
src/app/layout.tsx Root layout providers + nav/footer
src/app/dashboard/page.tsx User dashboard page
src/app/dashboard/actions.ts Delete-account server action
src/app/api/health/route.ts Health check endpoint
src/app/api/gutenberg/fetch/route.ts Gutenberg download proxy + cache
src/app/api/conversions/route.ts Log conversions + list history
src/app/api/conversions/precheck/route.ts Paywall precheck endpoint
src/app/api/auth/[...all]/route.ts Better-Auth route handler
src/app/api/admin/users/route.ts Admin user list API
src/app/api/admin/users/[id]/route.ts Admin user detail/update API
src/app/api/admin/stats/route.ts Admin stats API
src/app/api/admin/email-test/route.ts Admin email test API
src/app/admin/users/page.tsx Admin users page
src/app/admin/users/[id]/page.tsx Admin user detail page
src/app/admin/page.tsx Admin overview page
src/app/admin/layout.tsx Admin layout + nav
src/app/admin/actions.ts Admin server actions
setup.py Removed legacy helper script
scripts/test-email.ts Local Gmail email test script
scripts/get-gmail-refresh-token.ts OAuth refresh token helper
README.md New architecture/setup docs
railway.json Railway deployment config
public/.gitkeep Keeps public/ in repo
prisma/seed.ts Seed admin user
prisma/schema.prisma Prisma schema (auth + conversions)
prisma/migrations/migration_lock.toml Prisma migration lock
prisma/migrations/20260520044117_init/migration.sql Initial DB migration
postcss.config.mjs PostCSS config for Tailwind
package.json Dependencies + scripts
next.config.mjs Next config (images)
legacy/main.js Legacy app retained under legacy/
legacy/index.html Legacy UI retained under legacy/
Dockerfile Railway multi-stage build
docker-entrypoint.sh Deploy-time migrate + seed
.gitignore Ignore rules for Next/Prisma/etc
.env.example Environment variable template
.dockerignore Docker build context ignores

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +10 to +16
while (remaining.length > limit) {
let cut = remaining.lastIndexOf('. ', limit);
if (cut < limit / 2) cut = remaining.lastIndexOf(' ', limit);
if (cut <= 0) cut = limit;
out.push(remaining.slice(0, cut + 1).trim());
remaining = remaining.slice(cut + 1);
}
Comment thread src/app/api/admin/email-test/route.ts Outdated
Comment on lines +11 to +61
/** Returns the resolved SMTP config (without exposing the password). */
export async function GET() {
await requireAdmin();
const config = getEmailConfig();
const verify = await verifyEmailTransport();
return NextResponse.json({ config, verify });
}

/**
* Sends a real test email so you can confirm the credentials work
* end-to-end. Body: { to?: string } — defaults to the admin's own email.
*/
export async function POST(req: Request) {
const admin = await requireAdmin();
const body = (await req.json().catch(() => ({}))) as { to?: string };
const to = (body.to || admin.email).trim();

if (!to) {
return NextResponse.json(
{ stage: 'input', error: 'No recipient email provided.' },
{ status: 400 },
);
}

const config = getEmailConfig();
if (!config.configured) {
return NextResponse.json(
{
stage: 'config',
error:
'SMTP_USER / SMTP_PASS are not set in the environment. The server will only log reset emails.',
config,
},
{ status: 500 },
);
}

const verify = await verifyEmailTransport();
if (!verify.ok) {
return NextResponse.json(
{ stage: 'verify', error: verify.error, config },
{ status: 500 },
);
}

try {
await sendEmail({
to,
subject: 'Bilingual Books — SMTP test',
html: `<p>This is a test email from your <code>/admin</code> page. If you're reading it, SMTP is configured correctly.</p>
<p>Sent at ${new Date().toISOString()}.</p>`,
Comment thread scripts/test-email.ts Outdated
Comment on lines +19 to +25
import {
getEmailConfig,
sendEmail,
verifyEmailTransport,
} from '../src/lib/email';

async function main() {
claude added 2 commits May 20, 2026 12:08
Three independent issues caught by Copilot:

1. src/lib/converter/translate.ts:13 — splitForTranslation could emit
   chunks of length limit+1 when no delimiter was found within the
   first `limit` chars. The fallback used `cut = limit` then sliced
   `remaining.slice(0, cut + 1)`, overshooting Google Translate's
   4500-char request budget by one. Split the fallback into its own
   branch that hard-cuts at exactly `limit`. Verified with a 10k-char
   no-delimiter input: chunks now max out at 4500 instead of 4501.

2. src/app/api/admin/email-test/route.ts — Comment and error/subject
   strings still referenced "SMTP" / "SMTP_USER / SMTP_PASS" / "SMTP
   test" even though the handler uses the Gmail OAuth transport.
   Updated to mention GOOGLE_CLIENT_ID/SECRET + GMAIL_REFRESH_TOKEN
   and renamed the test subject to "Gmail API test".

3. scripts/test-email.ts — Static `import` of `../src/lib/email` was
   hoisted above the loadEnv() calls (ESM semantics), so the email
   module read process.env BEFORE .env.local was loaded and always
   reported "not configured" even with valid creds in .env.local.
   Switch to a dynamic import inside main() so loadEnv runs first.
   The inline env-var path (SMTP_USER=… npm run email:test) was
   unaffected and kept working — this fixes only the .env.local
   path that was silently broken.
New i18n infrastructure plus translated strings for the most visible
parts of the UI.

Infrastructure (src/i18n/):
- types.ts: Locale type + 6 supported locales (English, French,
  Spanish, German, Japanese, Chinese Simplified) with native-script
  labels for the switcher (autonyms).
- messages/en.ts is the source of truth and exports TKey; the other
  five locales import the type so missing keys are caught at compile
  time.
- I18nProvider exposes { locale, setLocale, t }, persists choice to
  localStorage bb_locale, and updates document.documentElement.lang
  on change.
- I18nScript is an inline <head> script that applies <html lang>
  before first paint, so the right lang attribute is set on SSR
  output (no flash; helps screen readers / browser translate).
- LocaleSwitcher: small <select> with a Globe icon, styled to match
  the theme-toggle pill in the navbar. Sits in the nav between the
  auth links and the theme toggle.

Components translated this pass:
- Navbar links (Convert / Dashboard / Admin / Sign in / Sign up /
  Sign out) — extracted to a NavLinks client child so the Navbar
  itself stays a server component for session fetching.
- Footer tagline + Privacy / Terms / Cookie preferences.
- ConverterShell: header title ("Bilingual Book Generator"), info
  button aria-label, and the four tab labels (Paste text, Upload
  EPUB, Popular books, Search Gutenberg).

Scope kept tight for v1 — admin pages, dashboard, detailed converter
help text, and policy pages stay English. Adding more strings is just
adding keys to messages/en.ts (compile error in the other locales
until they have a translation) and swapping the hardcoded text for
t('the.key').
@railway-app
railway-app Bot temporarily deployed to stunning-communication / production May 20, 2026 23:23 Inactive
Expand the i18n coverage from just nav + footer + tabs to the full
user-facing surface. Added ~70 keys to messages/en.ts and the matching
French, Spanish, German, Japanese, and Chinese translations.

Coverage now includes:
- Auth: sign-in form, sign-up form, sign-out page.
- Dashboard: title, plan label, stats card labels, recent-conversions
  heading, danger-zone heading, full delete-account confirmation flow.
  The dashboard server page now passes data to a new DashboardContent
  client wrapper so it can call t().
- Conversion table headers + empty state (moved to a client component).
- Cookie consent banner: body text, both buttons.
- Buy-me-a-coffee link (gains an optional labelKey prop) + Save-as-PDF
  button.
- PasteTab: language placeholders, book title field, source text field
  + hint, generate / cancel / start-over buttons, paywall-limit
  messages, cancelled status.
- EpubTab: file label, status messages (rewritten as a StatusKind
  discriminated union with useMemo + t() so the status text
  re-translates on locale change), EPUB info table labels, Advanced
  options summary, Split / Speed / Line-breaks segmented controls,
  language inputs, title-override field, Convert / Cancel buttons,
  progress label, paywall-limit messages.
- PopularTab: heading, language picker, loading / empty states.
- GutenbergTab: search placeholder + hint + buttons.
- GutenbergResultsList: cover-card metadata (Languages / downloads /
  unknown-author / Convert / Loading).
- InfoModal: all six sections (Paste / Upload / Gutenberg / Save as
  EPUB / Save as PDF).

Infrastructure tweak: I18nProvider's t() now accepts an optional
vars parameter and interpolates {placeholders} in the template, e.g.
t('epub.loaded', { chapters: 12, blocks: 480 }).

The detailed paragraphs INSIDE HelpTip tooltips are kept in English
on purpose — they're long, hover-only, and translating them well
would be a lot of work for low UX impact. The labels they describe
(Split mode, Speed, Line breaks) ARE translated.

Out of scope (intentionally untouched): admin pages, privacy/terms
page bodies (legal text, English remains authoritative). The
'policy.englishOnlyNote' key is in place for when we add a note.
@railway-app
railway-app Bot temporarily deployed to stunning-communication / production May 21, 2026 00:26 Inactive
src/app/icon.svg uses Next.js App Router's automatic icon convention
— Next picks it up at build time and exposes it as the document
favicon plus injects the correct <link rel="icon" type="image/svg+xml">
into <head> automatically.

Design: warm-brown rounded square (the site's accent color #8c5214)
with an open book in cream, the right page slightly more transparent
to suggest the second language. Recognizable at 16x16 (browser tabs)
and scales cleanly to any size.
@railway-app
railway-app Bot temporarily deployed to stunning-communication / production May 21, 2026 08:56 Inactive
Full SEO pass for the public surface.

- src/lib/site.ts: canonical SITE_URL (NEXT_PUBLIC_SITE_URL →
  BETTER_AUTH_URL → localhost), name, tagline, description, keywords.
- Root layout metadata: metadataBase, title default + "%s · Bilingual
  Books" template, description, keywords, author, canonical, robots
  (index/follow + googleBot max-image-preview:large), Open Graph
  (website/siteName/title/description/url/locale), Twitter
  summary_large_image. Added viewport.themeColor.
- src/app/opengraph-image.tsx: 1200×630 branded PNG via next/og
  ImageResponse (brand gradient, name, tagline, language row).
  Verified it renders 200 image/png at runtime.
- src/app/robots.ts: allow /, disallow /admin /dashboard /sign-* and
  /api/, point at the sitemap.
- src/app/sitemap.ts: home + privacy + terms (the only indexable
  public pages).
- src/app/manifest.ts: PWA manifest referencing /icon.svg, brand
  theme/background colors.
- Homepage JSON-LD: schema.org WebApplication (free offer, web OS,
  inLanguage list) injected as ld+json.
- noindex on private pages: sign-in, sign-up (metadata), dashboard
  (metadata), admin (layout metadata). Each also gets a clean title.
- Privacy/Terms titles trimmed to "Privacy Policy" / "Terms of
  Service" so the layout template appends "· Bilingual Books" without
  duplicating the brand.

Verified end-to-end: robots.txt, sitemap.xml, manifest.webmanifest,
opengraph-image all return correctly, and the homepage head carries
canonical + OG + Twitter tags resolved against SITE_URL.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants