Migrate from legacy static HTML to Next.js full-stack app#6
Draft
Lachy-Dauth wants to merge 43 commits into
Draft
Migrate from legacy static HTML to Next.js full-stack app#6Lachy-Dauth wants to merge 43 commits into
Lachy-Dauth wants to merge 43 commits into
Conversation
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.
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.
There was a problem hiding this comment.
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 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 on lines
+19
to
+25
| import { | ||
| getEmailConfig, | ||
| sendEmail, | ||
| verifyEmailTransport, | ||
| } from '../src/lib/email'; | ||
|
|
||
| async function main() { |
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
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
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Notable Implementation Details