The product is a public, highly available portfolio with a very small trusted-author set: exactly one administrator. Reads should remain useful if Firebase is unavailable, while writes must fail closed. Content must have first-class English and Korean representations and remain manageable without code changes.
The application uses the Next.js App Router, TypeScript, Tailwind CSS, Firebase Authentication, Cloud Firestore, and Firebase Storage. Next.js exports static HTML and browser assets to out/ for Firebase Hosting. The historical Vinext/Sites configuration is retained but is not used by the production scripts.
Public browser ─┬─> Next.js UI ──> bundled seed fallback
└─> Firestore public read snapshots
Admin browser ──> Google OAuth (Firebase Auth)
├──> client route guard (exact verified email + google.com provider)
├──> Firestore writes ──> rules repeat identity + schema checks
└──> Storage uploads ──> rules repeat identity + file checks
The public page renders immediately from realistic local seed data. When Firebase is configured, live snapshots from the named seozcom Firestore database replace each content collection independently. A failed remote read keeps the last usable data instead of blanking the page.
Localized fields are maps with exactly en and ko string keys. This avoids duplicate documents and guarantees that the CMS edits both representations together.
profile/mainIdentity and global presentation data: localized name, title, headline, summary, location, contact email, avatar URL, social links, optional focus (label and an ordered items array), and an ISO update timestamp. Focus supports up to six entries; label and item fields require both languages with 1–120 characters each. Admin Profile edits the label and list; missing focus falls back to seed content, while an empty list hides the strip. Client and Firestore rules validate the same bounds without broadening admin access. Storage rules remain unchanged.
press/main and press/{cardId}main holds bilingual eyebrow, title, body, and a unique ordered cardIds list (maximum 12). Each card document has a matching id, a kind enum, bilingual meta, title, and imageAlt, plus image and url. Section configuration cannot be deleted; removing all cards explicitly leaves the introduction visible. Card writes and removal are admin-only; section and card edits publish atomically. Missing configuration preserves seed content for existing installations without a migration.
timeline/{itemId}An ordered activity entity containing localized title, role, organization, location, and Markdown description; period; external links; label IDs; section ID; optional image; feature flag; numeric order; and timestamp. An optional highlights list represents compact sub-entries with bilingual titles and notes, one image, and multiple links. The parent Markdown description remains the fallback for older records.
sections/{sectionId} and labels/{labelId}Sections define public grouping and visibility. Labels define filtering vocabulary and an accent color. Referencing them by stable IDs keeps timeline documents compact and makes renaming safe.
The language state has this precedence:
lang query parameter.Changes update the URL with history.replaceState, localStorage, a SameSite=Lax cookie, and the document language attribute. Content and interface copy use the same Language union, so unsupported language states are not representable in application code.
Authentication uses Firebase Google OAuth. The admin UI requires all three conditions:
seojuyung@gmail.com;google.com.This client check improves UX but is not trusted. Firestore and Storage rules repeat the email, verification, and provider checks for every mutation. Unknown collections and paths end with explicit deny rules. Public reads are intentional because the portfolio is public.
No server credential is shipped to the browser. Firebase Web App configuration and the named database ID are runtime-validated before SDK initialization. The local seed script uses an explicitly configured Application Default Credential when present and otherwise uses the locally authenticated Firebase CLI account; neither credential path is bundled into the application.
Firestore rules use keys().hasOnly(...), type checks, length limits, bounded list sizes, bounded order values, and document-ID equality. Storage rules accept only authenticated admin uploads under portfolio/*, limit files to 5 MB, and require an image MIME type.
Firestore Rules cannot deeply iterate arbitrary nested arrays. The rule set therefore bounds links, socialLinks, and per-record highlights, while the typed CMS owns their detailed shape. A future server mutation layer could add deep validation if the author set expands or untrusted collaborators are introduced.
profile.md. Dense speaking history is normalized into bilingual yearly digests, while career, mentoring, open-source, publication, award, and education milestones remain independently searchable records. This keeps the source history complete without sacrificing scanability.public/seoz.jpg; Firebase may replace it at runtime with an uploaded CMS image using the same profile field.getTalkMetrics(content.timeline) from live timeline snapshots, independently of archive filters. The chapter uses the same function on its filtered items. Highlights take precedence over Markdown list lines; standalone records count once. Categories follow detailed-line label IDs, with parent-label fallback for older records; categories may overlap and unrelated labels do not imply a talk. Each authored entry counts once even if it covers multiple appearances; no totals are stored or hardcoded. Seed content uses the identical calculation. This introduces no schema, collection, or security rule changes. The portrait has no top badge. Software experience and employer tenure occupy the first row, with combined speaking engagements and open-source commits below; both rows stack on narrow screens.press/main and press/{cardId} through one collection snapshot. Its bilingual introduction and ordered list of up to 12 cards are editable in the admin Press tab, including image upload, type, source labels, title, alt text, and destination URL. Missing or malformed configuration uses src/data/press.ts through the tested seed fallback; empty saved lists remain empty. Saves atomically write the introduction, ordered IDs, and cards while deleting removed cards, independently of the profile. A separate document per card keeps full validation within Firestore’s rule expression budget. The collection allows public reads and only verified Google admin writes, with explicit document identity and schema rules, bounded bilingual text, icon kinds, IDs, HTTP(S) destinations, and safe image locations. Storage continues to enforce the existing admin-only portfolio/* image path, MIME type, and size bound.react-markdown; raw HTML is not enabled.The CMS provides Press introduction and card editing, profile editing, bilingual timeline CRUD, multiple links per item, optional per-line highlights with image upload and links, label assignment, image upload, section and label CRUD, feature flags, and timeline reordering. Reordering uses a Firestore batch so swapped order values commit atomically.
The public view is a direct toggle from the CMS. Firestore snapshots make saved edits visible without a rebuild or redeploy.
The production build exports / and /admin/ to out/; Firebase Hosting serves each exported route without a catch-all SPA rewrite. Hashed Next.js assets use immutable caching. Public Firebase environment values are embedded at build time and must be supplied in the GitHub build environment or ignored local .env.local. A configuration change requires a rebuild. No application server is required: Authentication, Firestore snapshots, and Storage uploads run through the browser SDK.
The default Firebase project is seoz-com, with named Firestore database seozcom. Firebase rules are versioned beside source and deployed separately because they are the authorization boundary. Seed imports are deliberate operations that overwrite matching document IDs, never part of automatic deployment. GitHub remains the source repository; Gabia remains the registrar. Custom-domain DNS must be edited at the authoritative DNS provider. Firebase Hosting manages TLS certificates for each connected hostname. See README for the deployment and cutover runbook.
Recommended operational checks:
Single-year talks digests hide the repeated header block (feature/tag badges, title, and role/organization). Year badges, engagement lists, and accessible year-specific disclosure controls align on the same desktop grid row without an empty header or divider. On mobile, engagement details stack beneath the year and control. The underlying fields remain stored for CMS editing, search, and filtering. Other archive entries retain their existing header structure.
The public archive and its section navigator place “Presentations, lectures & interviews” directly after “Career journey” in both languages, including when live section ordering differs. Other chapters retain their configured relative order.
In Admin → Timeline, edit a speaking year to create, edit, or remove its detailed entries, then Save item. Legacy bilingual Markdown bullets are converted to editable entries on read without reseeding; Markdown links remain intact. Each entry supports bilingual title/detail, up to 12 label IDs from Labels, up to 3 links, and up to six photos (HTTP(S), local path, or image upload). Removing all entries preserves an empty year without reviving its legacy description. Years remain the public grouping; label and search filters select individual entries and recalculate the chapter metrics. Label buttons display counts across the full archive. Existing single-image entries remain compatible.
Saved speaking years use timeline/{id}.entryIds (up to 40 ordered document IDs) and an empty highlights list. Each talkEntries/{yearId}--{entryId} document contains id, yearId, and entry, with bilingual title/detail, links, optional tags, and optional images. Legacy highlights remain readable. Saving a year atomically writes its entries and removes deleted documents. Deleting a year also removes its saved entries. Individual documents keep full validation within the Firestore expression budget. Firestore validates entry identities, bilingual fields, links, label IDs, photo paths, and bounded lists. Storage continues to use verified-Google-admin-only portfolio/* writes with image MIME and 5 MB limits. Deploy updated rules with npm run firebase:deploy before saving the new fields in production. The local preview does not deploy rules. Verify with npx tsx scripts/test-talk-entries.ts and npx firebase emulators:exec --only firestore --project demo-portfolio 'npx tsx scripts/test-talk-entries.ts --emulator'.
The bundled profile has an empty headline in both languages. The hero renders its headline only when nonempty CMS content is available (or in an admin preview). Initial loading, missing profiles, and offline fallback show no default headline; the remaining seed content stays available. No schema or security rule changes are required.
Archive entry labels follow the title inline as fixed-height compact badges and wrap with the text on narrow screens. Photos occupy a separate wrapping row below the entry text and links, so gallery images never stretch label height.
The admin timeline editor starts with Section, Date / period, Organization / event, Role, and Team, before the remaining item fields. Team labels the existing bilingual title field; stored data, public rendering, validation, and security rules remain unchanged. English and Korean inputs stay paired in each row.
On mobile and tablet (below the lg breakpoint), the hero portrait appears before the headline and introduction. At desktop widths, the introduction stays on the left and the portrait on the right. The portrait appears once in the document and precedes the introduction in reading order.
Header and footer name labels use Admin → Profile → Name, selecting the English or Korean field for the active language. Live profile updates and the bundled profile fallback use the same localization path; no schema or security rules change is needed.
On mobile and tablet, the location/leader line sits above the portrait and the current-role panel occupies its own row above the photo without covering it. Desktop keeps the location above the left introduction and the role overlay at the bottom of the right portrait.
The mobile header is a single 64px row containing the localized name, navigation, theme toggle, and language controls. Navigation can scroll horizontally on narrow screens without adding a second row; desktop retains the 72px header.
In the admin timeline editor, each detailed line has Move up and Move down buttons, including keyboard access and disabled boundary controls. Reordering moves the complete bilingual entry with its labels, photos, and links; Save item publishes the chosen order through the existing ordered highlights/entry IDs. The photo picker uses an Upload photo button next to Add photo URL, disabled during uploads or at the six-photo limit. Upload results follow the entry identity if a line is removed or reordered. These controls reuse the current schema and security rules.
Archive detailed-entry titles use the primary ink color, extra-bold weight, and responsive 16–18px type with 28px line height to distinguish them from descriptions and compact labels.
Markdown links within detailed-entry titles open in a new tab with noopener noreferrer, keeping the portfolio open. Titles continue to use react-markdown without raw HTML support.
The localized header name uses bold 14px type on mobile and 20px on desktop, giving the personal identity more prominence within the existing compact header.
Activity entry photos open a native modal dialog through PhotoGallery, with contained full-size images and a localized position counter. Navigation wraps within the selected entry and appears only for multiple photos. The dialog traps focus, makes the page inert, locks background scrolling, and restores focus to the thumbnail on close. Escape, the close button, and backdrop clicks dismiss it; left/right arrow keys navigate. Mobile places 44px navigation controls beneath the image. English/Korean labels, visible focus, and a motion-free layout preserve accessibility. This presentation change reuses existing photo fields and requires no schema or security-rule changes.
Public career cards display the bilingual role as the main heading and the team (stored title) beneath it, below the organization badge. Other archive sections retain their existing title and role hierarchy. This changes presentation only and preserves the existing seed fallback, data model, and security rules.
Timeline items support up to six photos via Item photos in the timeline editor (URL or upload, with individual removal). Public item photos appear beneath the summary and open the same bilingual modal viewer as detailed-line photos. The optional timeline.images array takes precedence over the legacy imageUrl; an empty array hides photos, while records without it retain their existing thumbnail as a single-photo gallery. Saving validates the six-photo limit and HTTP(S)/local URLs, mirrored in Firestore rules. Storage uses the existing admin-only image path and 5 MB limit. Deploy Firestore rules before saving item galleries in production.
Admin Timeline groups records by section in configured section order, with bilingual section headings and item counts. Each group retains item order and supports moving records up or down within that group; boundary buttons are disabled. Unknown or missing section references remain visible under Other / unassigned (기타 / 미지정). Grouping includes hidden public sections so administrators can still manage their records. This uses the existing section and order fields without schema or security-rule changes.
The software-experience statistic keeps its duration together on one line: “30+ Years” in English and “30년 이상” in Korean, with the software-experience label right-aligned at the right edge of the cell, matching the other statistics. The label may wrap below on narrow columns while the duration stays on one line.
Korean Google and startup tenure values use “년 이상” (for example, “11년 이상” and “6년 이상”); English retains the plus suffix. Google tenure remains calculated from March 2015. Korean tenure values use responsive type in a two-column layout: Google tenure stays on the left, while Samsung and startup tenure are grouped on the right with right-aligned values and labels.
Engagement statistics use detailed-line label IDs rather than title text: lecture, presentation, talk, panel, roundtable, or interview → talks/lectures/panels/roundtables/interviews; interview or media → interviews/media; panel or roundtable → panels/roundtables; mentoring → mentoring sessions. Multiple matching labels count once per category, and categories can overlap. Each nonempty line counts once in total engagements even without a category label. Missing line tags inherit parent labels for legacy compatibility; an explicit empty list does not. Legacy Markdown and standalone items use parent labels. No schema or security-rule changes are needed. Validate with npx tsx scripts/test-talk-metrics.ts.
The public speaking metric combines talks, lectures, presentations, panels, roundtables, and interviews, counting each line once across these labels. Its English label is “Talks, lectures, panels, roundtables & interviews” and its Korean label is “발표·강연·패널·간담회·인터뷰”. The separate panel/roundtable tile is removed from both statistics areas. The archive breakdown shows only the combined speaking metric; the hero places it beside open-source commits.
The hero statistics use two columns per row: software experience and employer tenure above, combined speaking engagements and open-source commits below. The interviews/media tile is removed. On narrow screens the four cells stack in that reading order; each value keeps its localized label.
Hero statistic accent values share clamp(2rem, 3.2vw, 3rem) (32–48px with a default 16px root), including software duration, Google tenure, speaking count, and commits. Supporting labels and tenure figures use larger type for readability. Employer tenure keeps Google left and supporting experience right; cells allow wrapping where needed on narrow screens.
The combined count reads only talks timeline records. Highlights take precedence over legacy Markdown bullets; records without either count as one standalone title. Empty entries are skipped, translations count once, missing highlight tags inherit parent tags, and explicit empty tags remain unclassified. The hero uses the full timeline while the chapter uses filtered records. The chapter displays the exact computed category count without a “+” suffix; its header also displays an exact count of all nonempty engagements regardless of category in both languages. Generic media tags alone do not enter the combined count.
The Korean software-experience label (“소프트웨어 경력”) stays on one line without a fixed maximum width. The statistics cell can wrap the label below the duration on narrow screens.
Supporting English employer tenures align to the right with a compact gap between each value and label. The Korean Samsung duration includes its unit (“6년”); Korean supporting tenures retain their right-aligned stacked layout.
Timeline Save item keeps the editor open and displays saving, success, or error feedback in its footer. Close dismisses the editor. New items retain their generated document ID for subsequent saves, and saved yearly entry IDs are retained so later deletions remove the correct child records. Edits made while a save is pending stay in the draft. No schema or security-rule changes are required.