Compare commits

...
95 Commits
Author SHA1 Message Date
Amruth Pillai 3f6e22addb chore(locales): update PO revision dates and add attachment translations 2026-07-04 22:52:09 +02:00
Amruth Pillai 25b70c24f1 chore(deps): update pnpm to version 11.10.0 2026-07-04 22:49:43 +02:00
Amruth Pillai da40422dfa docs(changelog): add ai agent + command palette polish to v5.2.0
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
Amruth Pillai e15edafbff fix(server): restore @uiw/color-convert runtime dependency
The utils color fallback restore re-added the @uiw/color-convert import
to packages/utils but not to apps/server, whose bundle keeps the package
external. Production server startup failed with ERR_MODULE_NOT_FOUND,
breaking the E2E workflow on main. Re-add the dependency.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:40:36 +02:00
autofix-ci[bot] d5b177aa89 [autofix.ci] apply automated fixes 2026-07-04 20:29:16 +00:00
Amruth Pillai d32227ff43 test(web): assert donation-toast cookie security attributes
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:26:25 +02:00
Amruth Pillai 7a0d1e93f3 fix(review): restore cookie attrs + color fallback, drop stale knip entry
Code-review findings:
- donation-toast: restore path/secure/sameSite cookie attributes the
  inlined useCookie dropped (security/scope regression).
- utils/color: restore @uiw fallback so percentage-notation rgb() still
  converts (custom style-rule colors are arbitrary strings); add tests
  pinning the one real difference vs the black fallback.
- knip: drop stale npm-check-updates ignoreDependencies entry.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:25:06 +02:00
Amruth Pillai 560956bbe6 test(api): add normalizeAgentResumePatchOperations to ./resume mock
service.ts calls it (added in 82d961241, merged from origin/main) but the
mock omitted it, breaking the patch-apply test. Identity mock matches the
test's pass-through expectation.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:04:28 +02:00
Amruth Pillai 7f458dc58d docs(utils): correct color fallback comment to state real ceiling
Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 22:00:58 +02:00
Amruth Pillai 361480445f chore(config): finding 5 — drop vitest scripts and config from packages/config
packages/config has no source or test files; the 4 vitest scripts and
vitest.config.ts exist purely for pipeline symmetry. Remove them.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:49 +02:00
Amruth Pillai 57fb23145c chore: finding 4 — remove npm-check-updates from root devDependencies
No script invokes it; pnpm dlx npm-check-updates still works on-demand.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:39 +02:00
Amruth Pillai 6207cbc026 chore(env): finding 3 — remove dead CROWDIN_PROJECT_ID/CROWDIN_API_TOKEN/GOOGLE_CLOUD_API_KEY
These three vars are only read by GitHub Actions workflows and tooling/fonts
scripts that access process.env directly — never by app/server runtime code.
Removes them from the env schema (server.ts) and turbo.json globalEnv.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:30 +02:00
Amruth Pillai a149e614a7 refactor(ui): finding 2 — delete packages/ui use-controlled-state (zero importers)
apps/web has its own copy with 3 importers; the packages/ui copy had none.
Deletes the hook and its 78-line test.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:20 +02:00
Amruth Pillai eab7534ea4 refactor(ui,web): finding 1 — inline use-cookie into donation-toast, drop js-cookie from ui
The 107-line useCookie hook had exactly one consumer (donation-toast) that
only read + set-with-expiry. Inline the two Cookies.* calls directly and
delete the hook + its 128-line test. Drop js-cookie and @types/js-cookie
from packages/ui/package.json (apps/web retains its own js-cookie dep).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:57:10 +02:00
Amruth Pillai 79a69c5507 refactor(web): finding 1 — extract SectionItemDialog shell from 14 section dialogs
Each of the 14 section-item dialog files (award→volunteer) had identical
~50-line Create/Update shells (DialogContent + header + form + footer).
Added section-item-dialog.tsx with a SectionItemDialog wrapper that takes
title, icon, onSubmit, onCancel, isSubmitting, submitLabel, singleColumn?.
cover-letter and summary-item use singleColumn=true for their one-column
layout. custom.tsx is untouched (it creates section definitions, not items).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:50:09 +02:00
Amruth Pillai 70df113ee6 refactor(web): finding 9 — public-resume reuses useResumeExport hook
Loosen useResumeExport param to ExportableResume { name, slug, data } so the
public resume page (where name may be '' for non-owner viewers) can reuse it.
getExportName() falls back to data.basics.name then slug, matching the
original inline logic.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:48:04 +02:00
Amruth Pillai 44e9a8a29f refactor(web): finding 6 — delete normalizeResumePreviewProps, inline defaults
normalizeResumePreviewProps had exactly one production caller (preview.tsx).
Defaults now live in the ResumePreview destructuring; the normalizer and its
test cases are removed.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:53 +02:00
Amruth Pillai e47cb37ab9 refactor(web): finding 2 — replace dialog renderer indirection with plain typed arrays
defineDialogRenderer/defineDialogRendererRegistry were identity functions.
Each registry now exports a readonly AnyDialogRendererEntry[] directly;
renderer-registry.ts retains only the types. The unused 'domain' field and
its wrapping object are gone; renderers.tsx spreads the arrays directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:37 +02:00
Amruth Pillai 02538836a9 refactor(web): finding 5 — replace match(boolean) with ternary + ActionButton wrapper
Three auth-settings components (password, two-factor, social-provider) each
duplicated an identical m.div hover/tap wrapper for both branches of
match(boolean). Extracted one ActionButton wrapper and replaced match with
a plain ternary; removed ts-pattern imports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:22 +02:00
Amruth Pillai 22398a502b refactor(web): finding 4 — extract runSignIn helper in SocialAuthButtons
Three near-identical toast→auth→error→invalidate handlers collapsed into
one generic runSignIn(fn) function.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:11 +02:00
Amruth Pillai e00348ef84 refactor(web): finding 3 — trim CountUp to {to, duration?, separator?}
No production caller passes from/direction/delay/startWhen/onStart/onEnd.
Removed those props and their setTimeout bookkeeping; updated tests.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:47:01 +02:00
Amruth Pillai 8d17ec6583 refactor(web): finding 8 — drop localStorage migration sentinel, use null-check
Number(null) === 0 caused the old code to use a separate :initialized key as
a migration sentinel. A plain null check on localStorage.getItem() is
sufficient and removes the sentinel key entirely.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:46:50 +02:00
Amruth Pillai e93a56d753 refactor(web): finding 1 - extract TypographyGroupFields component
Extract useTypographyForm helper (captures form creation + sync) and
TypographyForm type. Extract TypographyGroupFields component with
prefix "body" | "heading" to replace 2x4 duplicated form.Field blocks.
Font Weight label ("Font Weights" vs "Font Weight") is preserved per
prefix.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:57 +02:00
Amruth Pillai 975cea84e3 refactor(web): finding 5 - replace ts-pattern matchers with data maps
Replace getItemTitle/getItemSubtitle exhaustive ts-pattern matchers with
TITLE_FIELD/SUBTITLE_FIELD lookup maps + special-case branches for
summary and cover-letter. Adds a shared truncateHtml helper. Removes
ts-pattern import.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:44 +02:00
Amruth Pillai 34398a578b refactor(web): finding 8 - inline 7 one-caller alias hooks from draft.ts
useInitializeResumeStore, useMergeResumeMetadata, useSaveStatus,
useCanUndo, useCanRedo, useUndoResume, useRedoResume each had exactly
one caller. Inline useResumeStore selectors at call sites and remove
the wrappers. Skipped usePatchResume (4 callers).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:32 +02:00
Amruth Pillai 27efeab796 refactor(web): finding 7 - extract makeCustomSection, derive isStandard
Extract makeCustomSection factory to eliminate duplicate CustomSection
object literal. Replace hand-maintained SectionType array in
isStandardSectionId with membership check via `id in sections`.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:20 +02:00
Amruth Pillai f5ec471318 refactor(web): finding 6 - remove useQuery picture preview fetch
The uploads endpoint is public (Cache-Control: public, no auth headers),
so a plain img src suffices. Remove createPicturePreviewUrl, the useQuery
call, and the object-URL cleanup useEffect; simplify PicturePreviewControls
to use normalizedPictureUrl directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:29:10 +02:00
Amruth Pillai 376977a9f7 refactor(web): finding 9 - simplify useBuilderSidebar selector
Remove generic selector overload from useBuilderSidebar; callers
destructure the full return object instead of using a selector that
provides no meaningful benefit (state is rebuilt on every render).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:59 +02:00
Amruth Pillai 9b9d5c833c refactor(web): findings 2/3/4 in page/design/custom-styles
Finding 2: Replace 4 number + 3 switch form.Field blocks with
pageNumberFields/pageSwitchFields array maps.
Finding 3: Extract ColorFormField helper for primary/text/background
color fields in ColorSectionForm.
Finding 4: Remove labelPrefix (was always component-specific), replace
local slugify with @reactive-resume/utils/string import; fix ariaLabel
template literals to keep test labels accurate.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:28:47 +02:00
Amruth Pillai 15448cad6a fix(web): raise lib to ES2023 for consumed api source
agent/service.ts uses findLastIndex/Array.prototype.with (ES2023); web
type-checks api source and its ES2022 lib lacked them. lib only affects
type defs, not Vite runtime output.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:04:52 +02:00
Amruth Pillai afd734dd61 refactor(import): add v4section/v4url aliases, inline clamp wrappers, replace classes with fns
Finding 5: Introduce V4Url + V4Section<T> type aliases in reactive-resume-v4-json.tsx; collapse
310-line V4ResumeData type (13x 5-field section header, 11x {label,href}) into typed aliases.
Inferred shape is structurally identical.

Finding 6: Remove 9 single-caller clamp/pxToPt wrappers; replace compile-time constants
(rotation=0, sidebarWidth=35, shadowWidth=0, gapX=4, gapY=6) with literals; inline dynamic
calls (clamp(x, 32, 512) etc.) at the two body/heading typography call sites.

Finding 7: Convert three stateless single-method importer classes to plain functions.
Extract shared rethrowAsImportError() to error.ts, replacing triplicated ZodError catch blocks.
Update web import dialog + all in-package test files to use function API.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 21:02:17 +02:00
Amruth Pillai 493ef12a9a refactor(utils→import): move single-consumer date/html/level helpers, inline field into fonts
Finding 4 — consumer-count verification:
  @reactive-resume/utils/date  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/html  → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/level → 1 consumer (packages/import/src/json-resume.tsx)
  @reactive-resume/utils/url   → 4 consumers (auth, api/ai, import, server) — SKIPPED, stays in utils
  @reactive-resume/utils/field → 1 consumer (packages/fonts/src/index.ts)

Move date/html/level source + test files into packages/import and update json-resume.tsx imports.
Inline the two-line unique() helper into fonts/src/index.ts and drop the ./field subpath.
Drop the three moved subpaths from packages/utils exports.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:55:22 +02:00
Amruth Pillai a5935dee0f refactor(utils): replace MONTH_NAMES array, drop @uiw/color-convert, use z.enum for localeSchema
Finding 1: Replace hand-rolled MONTH_NAMES[12] array with Intl.DateTimeFormat("en-US", {month:"long"}).
Finding 2: Drop @uiw/color-convert fallback — parseColorString already rejects the same inputs, return "#000000" instead. Remove dep from utils and server package.json.
Finding 3: Replace 56 z.literal calls in z.union with z.enum([...]); identical parse behavior and inferred type.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:53:51 +02:00
Amruth Pillai 5226f04e86 fix(email): use automatic JSX runtime so templates render under tsx
Templates import no React (react-email convention); tsx resolves each
file's nearest tsconfig, so jsx:preserve made esbuild emit classic
React.createElement and background email rendering threw
'React is not defined'.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:45:05 +02:00
Amruth Pillai a1fb0597a3 Merge remote-tracking branch 'origin/main' into chore/ponytail-cleanup 2026-07-04 20:42:34 +02:00
Amruth Pillai a2a2c0a768 Merge branch 'main' into chore/ponytail-cleanup 2026-07-04 20:40:21 +02:00
Amruth Pillai f2ec6a499f refactor(server): replace hand-rolled withTimeout, merge web handlers, clean checks
- health.ts: swap hand-rolled withTimeout for es-toolkit's (fn-taking API); remove
  redundant inner try/catches from checkDatabase/checkStorage since runCheck catches
  all errors (findings 13, health cleanup)
- web.ts: merge handleWebApp/handleWebAppHead into one function; method is the only
  difference — isHead determines body presence (finding 14)
- app.ts: collapse two separate GET/HEAD wildcard routes into app.on(["GET","HEAD"])
- Update web.test.ts and app.test.ts to drop handleWebAppHead references

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:39:14 +02:00
Amruth Pillai 0fb81ad772 refactor(api): replace file-based statistics cache with in-memory Map
Removes fs, path, env, getLocalDataDirectory imports from statistics service.
A module-level Map<string, {value, cachedAt}> gives the same TTL semantics
without touching disk. Adds clearStatisticsCache() for test isolation and
updates the test to call it in afterEach instead of relying on unique
LOCAL_STORAGE_PATH temp dirs (finding 5).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:36:15 +02:00
Amruth Pillai 19470c8cd2 refactor(api,server): storage — sync S3 client, dead upload types, shared inferContentType
- S3StorageService: replace async createClient/getClient/clientPromise chain with a
  synchronous constructor field; new S3Client() is synchronous (finding 7)
- uploadFile: delete speculative screenshot/pdf upload types (never called); hardcode
  picture key and simplify to 3 lines (finding 6)
- Export inferContentType from @reactive-resume/api/features/storage (finding 8)
- uploads.ts: import inferContentType from storage instead of local duplicate; inline
  buildResponseHeaders into handleUpload (findings 11, 12)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:33:34 +02:00
Amruth Pillai 3f050e5213 refactor(api): introduce mapAgentEnvironmentError oRPC middleware (finding 1)
Replace 12 near-identical try/catch blocks in threads/actions/attachments/messages
handlers with a single AnyMiddleware that maps the AGENT_ENVIRONMENT_UNAVAILABLE
sentinel to PRECONDITION_FAILED ORPCError.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:31:25 +02:00
Amruth Pillai 91c4a2421c refactor(api): simplify agent service — findLastIndex, shared select, combined aggregate
- attachModelPartsToLatestUserMessage: findLastIndex + Array.with, drop wrapper (finding 2)
- getOrCreateForResume: extract findActiveThreadForResume, remove 16-line dup select (finding 3)
- attachments.create: combine sum+count into one query (finding 4)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:29:19 +02:00
Amruth Pillai 82d961241e fix: polish ai agent and command palette 2026-07-04 20:24:39 +02:00
Amruth Pillai f3a60432df refactor(pdf): introduce createBaseTemplateStyles factory (~1,150 lines removed)
Move 14 identical style slots (text/heading/div/inline/link/small/bold/
richParagraph/richListItemRow/richListItemMarker/richListItemContent/
splitRow/alignEnd/picture) from all 15 template Page.tsx files into a
single createBaseTemplateStyles factory in templates/shared. Each template
now spreads …base and keeps only its real overrides. Resolved StyleSheet
values are identical to before. Update rtl-fixture and rich-text-template-
styles tests to guard the factory file rather than each template directly.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:13:42 +02:00
Amruth Pillai 0701f3b62a refactor(pdf): extract EmailContactItem/PhoneContactItem/LocationContactItem
Add three shared contact-item components to packages/pdf/src/templates/shared/
contact-item.tsx alongside the existing WebsiteContactItem and CustomFieldContactItem.
Replace ~18 lines of inline email/phone/location JSX in all 15 template headers
with the shared components (EmailContactItem accepts an optional iconName prop
for ditgar's "at" variant; rhyhorn's array-push pattern also updated).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 20:04:37 +02:00
Amruth Pillai cf738b9306 refactor(pdf): replace ts-pattern match chains with satisfies Record maps
Two match chains in sections.tsx are replaced with plain lookup maps typed
via `satisfies Record<CustomSectionType, ...>` which preserves compile-time
exhaustiveness without the ts-pattern dependency. Remove ts-pattern from
packages/pdf/package.json.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:52:13 +02:00
Amruth Pillai fcc10c6b31 refactor(pdf): deduplicate parseFiniteNumber/parsePxValue/parseFontSize
Export parseFiniteNumber and parsePxValue from icon-size.ts (already the
canonical home of these helpers). Remove the three private copies in
rich-text-spacing.ts and import the shared ones instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:49:39 +02:00
Amruth Pillai 3e96605d4c fix(web): avoid retesting new AI providers 2026-07-04 19:42:41 +02:00
Amruth Pillai 7e35e8b657 chore(schema): delete tautology test, add helpers, collapse styleRuleSlots
- Delete templates.test.ts: re-tests z.enum semantics with a hardcoded
  fixture that duplicates the template list in templates.ts.
- default.ts: add 2-line section(icon) helper; 12 repeated 7-field blocks
  collapse to one-liners. Output is byte-identical.
- data.ts: add 2-line itemSection<T> factory; 12 baseSectionSchema.extend()
  blocks collapse to one-liners. Inferred types unchanged.
- data.ts: replace 15-field hand-listed styleRuleSlotsSchema with
  z.partialRecord(styleSlotSchema, styleIntentSchema); parse behaviour and
  TypeScript type are equivalent (unknown keys still rejected).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:38:01 +02:00
Amruth Pillai 8de15822fb chore(db): delete tautological schema-mirror tests, collapse db singleton
- Delete 4 test files (auth/resume/agent schema mirrors + relations type check)
  that re-assert Drizzle table/column names copied verbatim from the schema files.
- Collapse makeDrizzleClient() + createDatabase() into a two-line module-level
  singleton; preserves globalThis.__drizzle caching behaviour.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:36:22 +02:00
Amruth Pillai e2099b9002 refactor(auth): replace z.enum with plain TS union; drop zod dependency
AuthProvider was the only zod usage in the package — a z.enum solely to
infer a type. Replace with a plain union type and remove zod from
packages/auth/package.json dependencies.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:24 +02:00
Amruth Pillai 5762eb6a3e refactor(ai): collapse parser prompts to template; replace makeEmptyItem with structuredClone
- Replace pdf-parser-system.md + docx-parser-system.md with a single
  parser-system.md template; prompts.ts substitutes 6 placeholders per
  source type. Produced strings are byte-identical to the former files.
- Remove makeEmptyItem recursive walker (all SECTION_ITEM_SHAPES leaves are
  already zero-valued) and call structuredClone(shape) instead.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:13 +02:00
Amruth Pillai dfe75390cd refactor(docx): getBaseRun helper, hoist mainConfig, derive sections, remove dead OL code
- Add getBaseRun() next to getHtmlStyle() and replace 8 inline baseRun spreads (~27 lines).
- Hoist one mainConfig object in buildDocument; sidebar call spreads only the two
  differing color keys (~17 lines).
- Remove BUILT_IN_SECTIONS Set; derive membership via `sectionId in data.sections` (~14 lines).
- Delete unreachable ordered-list numbering machinery in html-to-docx.ts: numberingRef is
  always undefined through all call paths, so isOrdered && numberingRef is always false (~15 lines).

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:27:01 +02:00
Amruth Pillai 439ae114f9 refactor(mcp): derive tool metadata from single TOOL_META record
- Extract one TOOL_META record (title/description/inputSchema/annotations per
  tool) consumed by both registerTools and buildMcpServerCard, eliminating the
  ~200-line duplication in the server card.
- Collapse TOOL_ANNOTATIONS from 14 × 4-line inline objects to 5 named
  annotation-preset consts (READ_IDEMPOTENT, WRITE_NON_IDEMPOTENT, etc.),
  saving ~55 lines.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:26:44 +02:00
github-actions[bot]andCrowdin Bot 9b41edb43d Sync Translations from Crowdin (#3213)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 19:09:57 +02:00
Amruth Pillai d87c6758ab chore: update dependencies 2026-07-04 19:06:31 +02:00
Amruth Pillai 44fa2badb4 fix(ai): pass system prompt via system option instead of system-role message
Some providers (OpenAI Responses API, others) reject system-role messages in
the messages array with AI_InvalidPromptError. Move the analyze/parse system
prompts to the generateText system option, matching the chat handler.
2026-07-04 18:31:40 +02:00
Amruth Pillai 0abb5a07e6 fix(i18n): strip merge conflict markers reintroduced by crowdin sync 2026-07-04 18:18:58 +02:00
Amruth Pillai a9a38ff5dc fix(e2e): restore template name as img alt in gallery so template-switch test passes 2026-07-04 18:17:04 +02:00
github-actions[bot]andCrowdin Bot bf70705f1f [skip ci] chore(i18n): sync translations from crowdin (#3211)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:09:01 +02:00
github-actions[bot]andCrowdin Bot 332aa210c4 [skip ci] chore(i18n): sync translations from crowdin (#3209)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-07-04 18:08:01 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> da6a9f2c78 fix: repair MDX parse error in community spotlight to unblock link check (#3210)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 16:07:50 +00:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 4541cf1cdc Update from code changes: refresh builder guides for v5.2.0 (#3208)
* docs: refresh builder guides for v5.2.0 (undo/redo, version history, embedded AI, header downloads)

* docs: tighten SEO descriptions on v5.2.0 builder guides

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 18:06:45 +02:00
Amruth Pillai 27df724d2a feat(builder): show live PDF previews in template gallery, remove hover card 2026-07-04 18:05:45 +02:00
Amruth Pillai bc09430fdf chore(i18n): translate missing strings for am-ET, el-GR, km-KH, th-TH
97 strings translated for Amharic (am-ET), 97 for Greek (el-GR),
97 for Khmer (km-KH), and 98 for Thai (th-TH). All placeholders
preserved verbatim ({0}, {label}, <0>, __APP_VERSION__, etc.).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:25:18 +02:00
Amruth Pillai e936f93e3a chore: translate missing strings for kn-IN, ml-IN, or-IN, ta-IN, te-IN
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 17:07:34 +02:00
Amruth Pillai 3ba566506a chore: translate missing strings for bn-BD, hi-IN, mr-IN, ne-NP
Fill 96 empty msgstr entries per locale in the Indic/Devanagari batch:
Bengali (bn-BD), Hindi (hi-IN), Marathi (mr-IN), and Nepali (ne-NP).
2026-07-04 16:41:13 +02:00
Amruth Pillai a7c599b724 chore: translate missing strings for ar-SA, fa-IR, he-IL
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:30:27 +02:00
Amruth Pillai dbb0b179c3 chore: translate missing strings for ja-JP, ko-KR, zh-CN, zh-TW
Fill in 96 empty msgstr entries per locale covering new UI strings
(AI assistant, version history, dashboard, account menu, connection
status, editor controls, and more).

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:24:57 +02:00
Amruth Pillai fc634a202d chore: translate missing strings for id-ID, ms-MY, tr-TR, vi-VN 2026-07-04 16:20:42 +02:00
Amruth Pillai 7fab23870f chore: translate missing strings for az-AZ, bg-BG, ru-RU, sr-SP, uk-UA, uz-UZ
Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 16:08:17 +02:00
Amruth Pillai 20a8a3df9d chore: translate missing strings for pl-PL, pt-BR, pt-PT, ro-RO, sk-SK, sl-SI, sq-AL, sv-SE
Fill in all empty msgstr entries (96 per file, 92 for ro-RO) covering
new UI strings: AI assistant, version history, undo/redo, dashboard,
connection status, export data, and related builder strings.

Claude-Session: https://claude.ai/code/session_012jucCw5SQBpWMoZYwVEbeC
2026-07-04 15:54:15 +02:00
Amruth Pillai e38e37383d chore: translate missing strings for cs-CZ, da-DK, fi-FI, hu-HU, lt-LT, lv-LV, nl-NL, no-NO 2026-07-04 15:38:38 +02:00
Amruth Pillai d45116b2ba chore: translate missing strings for af-ZA, ca-ES, de-DE, en-GB, es-ES, fr-FR, it-IT 2026-07-04 15:24:14 +02:00
Amruth Pillai 6ad4f13914 docs: update changelog to not use images 2026-07-04 14:59:47 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 2f5d321051 docs: trim changelog description to meet SEO length target (#3207)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 12:58:39 +00:00
Amruth Pillai 57e9c8c487 v5.2.0: undo/redo, version history, embedded AI assistant, mobile builder & more (#3205) 2026-07-04 14:57:25 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> 09bc6ec521 docs: add pg pool error handler fix to weekly changelog (#3204)
Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:30:15 +00:00
helder-mattosandClaude Opus 4.8 50885176e0 fix(db): attach an error handler to the pg pool (#3172)
A Postgres connection can drop at any time — e.g. a serverless Postgres such as
Neon terminating the connection (error code 57P01). node-postgres surfaces this as
an 'error' event; without a listener node re-throws it as an unhandled 'error' and
crashes the process. Idle clients emit on the pool, but a client that is connecting
or checked out emits on the client itself, so we listen on both the pool and each
client. The pool then discards the dead client and opens a fresh one on the next
query, so the server survives transient/idle disconnects.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 09:28:18 +02:00
mintlify[bot]andmintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com> cbeecf6596 Draft changelog: weekly update for post-v5.1.9 changes (#3203)
* docs: add weekly changelog entry for post-v5.1.9 changes

* docs: improve changelog title and description for SEO

---------

Co-authored-by: mintlify[bot] <109931778+mintlify[bot]@users.noreply.github.com>
2026-07-04 07:22:49 +00:00
Shanu S ee970f2961 fix: wrap list item content in flex View so bullets respect margin (#3202)
* fix(pdf): wrap list item content in flex View so bullets respect margin

* fix(pdf): add minWidth 0 to bullet content so long text wraps
2026-07-04 09:20:01 +02:00
Amruth Pillai 578a983209 feat: polish micro-interactions with consistent motion system across the app
- add strong easing tokens (--ease-out-strong, --ease-in-out-strong, --ease-drawer)
- restore menu open/close animations (dropdown, context menu, combobox) using
  interruptible transitions via Base UI starting/ending styles
- dialogs: 200ms enter / 150ms exit; command palette opts out (keyboard-initiated)
- tooltips: 400ms initial delay with instant adjacent hovers via provider grouping
- buttons: scale press feedback, specific transition properties instead of transition-all
- tabs: sliding active-tab indicator via Base UI Tabs.Indicator
- sheet: iOS drawer curve with asymmetric enter/exit timing
- animate form validation messages and auth page entrance
- remove dead radix-idiom accordion classes in builder sidebars
2026-07-03 21:48:32 +02:00
autofix-ci[bot] 617135466d [autofix.ci] apply automated fixes 2026-07-03 19:22:57 +00:00
Diego Vega Centeno fa4c8adf78 fix: add conditional flex:1 for nested list content to fix layout (#3198) 2026-07-03 21:22:03 +02:00
Amruth Pillai 5b8ab33888 fix: rethrow non-ENOENT errors when loading .env 2026-07-03 20:55:41 +02:00
Amruth Pillai 0ba44865c7 test: add e2e specs for dashboard, sections, templates, sharing and settings workflows
- dashboard-lifecycle: rename, duplicate, delete via card context menu
- section-editing: add experience item, verify persistence across reloads
- template-switch: switch template in gallery, verify persisted selection
- sharing-password: password-protect public link, unlock as anonymous visitor
- lock-resume: lock blocks update/delete, unlock restores them
- settings-profile: profile name change persists
2026-07-03 20:04:36 +02:00
Amruth Pillai a4999c04af refactor: remove dead code, unused exports and redundant dependencies
- drop dotenv (Node 24 process.loadEnvFile) and dompurify (only used by dead code)
- delete unused ui components/hooks (card, progress, checkbox, use-confirm, use-prompt)
- delete dead sanitizeHtml/sanitizeCss, url-security helpers, patch-resume tool,
  schema/page, createResumePatches, patch-proposal preview builder, fonts fallback helpers
- inline single-caller wrappers (flags service, auth getSession, pdf renderer passthrough)
- deduplicate template color helpers into shared/color-helpers
- unexport 50+ internal-only symbols, remove dead export-map entries
- replace hand-rolled unique()/useIsMobile with Set spread and usehooks-ts
2026-07-03 20:04:36 +02:00
Amruth Pillai 2a80e6a1df chore: update dependencies 2026-07-03 20:04:36 +02:00
SimoandClaude Sonnet 4.6 4c8cc5c016 fix: remove overflow hidden from safeTextStyle (#3186)
overflow: hidden on Text elements in @react-pdf/renderer v4.x clips
content at its initial computed height, hiding any text after a line
break. minWidth/maxWidth/flexShrink already handle horizontal
containment so nothing else breaks.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:59:00 +02:00
Amruth Pillai d3735ebe27 chore: update dependencies 2026-06-29 09:09:00 +02:00
github-actions[bot]andCrowdin Bot 8eab8fdaa0 Sync Translations from Crowdin (#3183)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-29 09:01:42 +02:00
autofix-ci[bot] fbb9938af6 [autofix.ci] apply automated fixes 2026-06-29 01:26:48 +00:00
Andrea Accardo 5080fddf51 bugfix: fix list break with marker (#3177) (#3178)
* bugfix: fix list break with marker (#3177)

Signed-off-by: aaccardo <hackardo@gmail.com>

* refactor: fix code smell

Signed-off-by: aaccardo <hackardo@gmail.com>

---------

Signed-off-by: aaccardo <hackardo@gmail.com>
2026-06-29 03:25:55 +02:00
Amruth Pillaicoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Cursor Agentautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
dfd2c77bc9 Add Playwright E2E test setup (#3169)
* docs: design e2e test setup

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* docs: plan e2e test implementation

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add playwright e2e scripts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: configure playwright

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add core e2e fixtures and specs

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* ci: run e2e tests on pull requests

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* [autofix.ci] apply automated fixes

* test: stabilize e2e suite

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: ignore playwright artifacts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* Update .github/workflows/e2e.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test: address e2e review feedback

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-06-20 07:39:06 +02:00
Amruth PillaiandCursor Agent 56c90947e4 fix: ensure Atlas Cloud sponsor logo links to website (#3170)
Prevent the sponsor logo images from intercepting clicks so the
anchor reliably opens atlascloud.ai in a new tab instead of the SVG
asset.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-06-20 06:03:46 +02:00
github-actions[bot]andCrowdin Bot ae2a1dac12 Sync Translations from Crowdin (#3167)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2026-06-18 18:59:00 +02:00
439 changed files with 55751 additions and 25031 deletions
+7
View File
@@ -19,6 +19,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Check for merge conflict markers
run: |
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
echo "::error::Merge conflict markers found in tracked files"
exit 1
fi
- name: Install pnpm
uses: pnpm/action-setup@v6
+88
View File
@@ -0,0 +1,88 @@
name: E2E Tests
on:
pull_request:
push:
branches: ["main"]
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
APP_URL: http://localhost:3000
PORT: "3000"
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_API_RATE_LIMIT: "true"
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
jobs:
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Browser
run: pnpm exec playwright install --with-deps chromium
- name: Generate Test Secrets
run: |
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
- name: Prepare Storage
run: mkdir -p "$LOCAL_STORAGE_PATH"
- name: Run Database Migrations
run: pnpm db:migrate
- name: Build
run: pnpm build
- name: Run E2E Tests
run: pnpm test:e2e:ci
- name: Upload Playwright Report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
playwright-report
test-results
if-no-files-found: ignore
retention-days: 7
+3
View File
@@ -36,6 +36,8 @@ logs
# Testing
coverage
reports
playwright-report
test-results
# Cache
tmp
@@ -49,6 +51,7 @@ temp
.cursor
.codegraph
.superpowers
.migration
# Local Storage Data
/data
+4 -1
View File
@@ -27,5 +27,8 @@
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
"typescript.experimental.useTsgo": true
"typescript.experimental.useTsgo": true,
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+26 -28
View File
@@ -17,17 +17,17 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.85",
"@ai-sdk/google": "^3.0.83",
"@ai-sdk/openai": "^3.0.72",
"@ai-sdk/openai-compatible": "^2.0.51",
"@aws-sdk/client-s3": "^3.1070.0",
"@better-auth/api-key": "^1.6.19",
"@better-auth/drizzle-adapter": "^1.6.19",
"@better-auth/infra": "^0.2.14",
"@better-auth/oauth-provider": "^1.6.19",
"@better-auth/passkey": "^1.6.19",
"@hono/node-server": "^2.0.5",
"@ai-sdk/anthropic": "^4.0.8",
"@ai-sdk/google": "^4.0.8",
"@ai-sdk/openai": "^4.0.7",
"@ai-sdk/openai-compatible": "^3.0.5",
"@aws-sdk/client-s3": "^3.1079.0",
"@better-auth/api-key": "^1.6.23",
"@better-auth/drizzle-adapter": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@hono/node-server": "^2.0.8",
"@modelcontextprotocol/sdk": "^1.29.0",
"@orpc/client": "^1.14.6",
"@orpc/experimental-ratelimit": "^1.14.6",
@@ -46,42 +46,40 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^6.0.207",
"ai": "^7.0.15",
"bcrypt": "^6.0.0",
"better-auth": "1.6.19",
"better-auth": "1.6.23",
"cjk-regex": "^3.4.0",
"deepmerge-ts": "^7.1.5",
"dompurify": "^3.4.10",
"dotenv": "^17.4.2",
"drizzle-orm": "1.0.0-rc.3",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.47.1",
"es-toolkit": "^1.49.0",
"fast-json-patch": "^3.1.1",
"hono": "^4.12.25",
"jsonrepair": "^3.14.0",
"node-html-parser": "^7.1.0",
"nodemailer": "^9.0.0",
"hono": "^4.12.27",
"jsonrepair": "^3.15.0",
"node-html-parser": "^8.0.4",
"nodemailer": "^9.0.3",
"ollama-ai-provider-v2": "^3.6.0",
"pg": "^8.21.0",
"pg": "^8.22.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.7",
"react-email": "^6.6.3",
"react-email": "^6.6.6",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sharp": "^0.35.1",
"sharp": "^0.35.3",
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.0",
"uuid": "^14.0.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.9.3",
"@types/node": "^26.1.0",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@typescript/native-preview": "7.0.0-dev.20260617.2",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"tsdown": "^0.22.3",
"tsx": "^4.22.4",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vitest": "^4.1.9"
}
-4
View File
@@ -19,7 +19,6 @@ const mocks = vi.hoisted(() => ({
handleLlms: vi.fn(),
serveWebDistStatic: vi.fn(),
handleWebApp: vi.fn(),
handleWebAppHead: vi.fn(),
}));
vi.mock("./auth", () => ({
@@ -60,7 +59,6 @@ vi.mock("../static/seo", () => ({
vi.mock("../static/web", () => ({
serveWebDistStatic: mocks.serveWebDistStatic,
handleWebApp: mocks.handleWebApp,
handleWebAppHead: mocks.handleWebAppHead,
}));
vi.mock("../mcp/handler", () => ({
@@ -91,7 +89,6 @@ beforeEach(() => {
mocks.handleLlms.mockReturnValue(new Response("llms"));
mocks.serveWebDistStatic.mockResolvedValue(undefined);
mocks.handleWebApp.mockResolvedValue(new Response("web"));
mocks.handleWebAppHead.mockReturnValue(new Response(null));
});
describe("createApp", () => {
@@ -138,6 +135,5 @@ describe("createApp", () => {
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
expect(mocks.handleWebAppHead).not.toHaveBeenCalled();
});
});
+2 -3
View File
@@ -12,7 +12,7 @@ import { handleRpc } from "../rpc/handler";
import { handleSchemaJson } from "../static/schema";
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
import { handleUpload } from "../static/uploads";
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
import { handleWebApp, serveWebDistStatic } from "../static/web";
import { handleAuth, handleOAuth } from "./auth";
import { handleHealth } from "./health";
import { handleResumePdfDownload } from "./resume-pdf";
@@ -47,8 +47,7 @@ export function createApp() {
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
app.use("/*", serveWebDistStatic);
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
return app;
}
+8 -39
View File
@@ -1,4 +1,5 @@
import { sql } from "drizzle-orm";
import { withTimeout } from "es-toolkit";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { db } from "@reactive-resume/db/client";
@@ -11,30 +12,12 @@ type CheckResult = {
[key: string]: unknown;
};
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return "Unknown error";
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
let timeoutId: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
const startedAt = performance.now();
try {
const data = await withTimeout(check(), HEALTHCHECK_TIMEOUT_MS);
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
const latencyMs = Math.round(performance.now() - startedAt);
const result = data as { status?: string };
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
@@ -42,34 +25,20 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
} catch (error) {
return {
status: "unhealthy",
error: getErrorMessage(error),
error: error instanceof Error ? error.message : "Unknown error",
latencyMs: Math.round(performance.now() - startedAt),
};
}
}
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
async function checkDatabase() {
try {
await db.execute(sql`SELECT 1`);
return { status: "healthy" };
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
};
}
await db.execute(sql`SELECT 1`);
return { status: "healthy" };
}
async function checkStorage() {
try {
const storageService = getStorageService();
return await storageService.healthcheck();
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
};
}
return getStorageService().healthcheck();
}
export async function handleHealth() {
+19 -65
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { basename, extname, normalize } from "node:path";
import { getStorageService } from "@reactive-resume/api/features/storage";
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
import { env } from "@reactive-resume/env/server";
export async function handleUpload(request: Request) {
@@ -18,41 +18,32 @@ export async function handleUpload(request: Request) {
const filename = filePath.split("/").pop() ?? filePath;
const ext = extname(filename).toLowerCase();
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
const contentType = storedFile.contentType ?? inferContentType(filename);
const etag = createEtag(storedFile);
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
const shouldForceDownload = [".pdf"].includes(ext);
const headers = buildResponseHeaders({
filename,
storedFile,
contentType,
etag,
shouldForceDownload,
});
const buffer = toArrayBuffer(storedFile.data);
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
return new Response(buffer, { headers });
}
function inferContentTypeFromExtension(ext: string): string {
switch (ext) {
case ".webp":
return "image/webp";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".gif":
return "image/gif";
case ".pdf":
return "application/pdf";
default:
return "application/octet-stream";
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
headers.set("Access-Control-Allow-Origin", env.APP_URL);
return new Response(toArrayBuffer(storedFile.data), { headers });
}
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
@@ -101,43 +92,6 @@ function makeNotModifiedResponse(etag: string): Response {
});
}
type BuildResponseHeaderArgs = {
filename: string;
storedFile: { size: number };
contentType: string;
etag: string;
shouldForceDownload: boolean;
};
function buildResponseHeaders({
filename,
storedFile,
contentType,
etag,
shouldForceDownload,
}: BuildResponseHeaderArgs): Headers {
const headers = new Headers();
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
headers.set("Content-Length", storedFile.size.toString());
if (shouldForceDownload) {
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
}
headers.set("Cache-Control", "public, max-age=31536000, immutable");
headers.set("ETag", etag);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("X-Robots-Tag", "noindex, nofollow");
headers.set("Cross-Origin-Resource-Policy", "same-site");
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
headers.set("X-Frame-Options", "DENY");
headers.set("X-Download-Options", "noopen");
headers.set("Access-Control-Allow-Origin", env.APP_URL);
return headers;
}
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
? (data.buffer as ArrayBuffer)
+5 -3
View File
@@ -15,7 +15,7 @@ vi.mock("@hono/node-server/serve-static", () => ({
serveStatic: vi.fn(() => vi.fn()),
}));
const { handleWebApp, handleWebAppHead } = await import("./web");
const { handleWebApp } = await import("./web");
describe("web app fallback classification", () => {
beforeEach(() => {
@@ -90,8 +90,10 @@ describe("web app fallback classification", () => {
});
it("mirrors fallback status and headers for HEAD without a body", async () => {
const knownResponse = handleWebAppHead(new Request("https://example.com/dashboard"));
const unknownResponse = handleWebAppHead(new Request("https://example.com/unknown/extra/path"));
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
const unknownResponse = await handleWebApp(
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
);
expect(knownResponse.status).toBe(200);
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
+9 -12
View File
@@ -74,23 +74,20 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
});
}
// ponytail: GET and HEAD share the same routing logic; method determines body presence
export async function handleWebApp(request: Request) {
const isHead = request.method === "HEAD";
const pathname = new URL(request.url).pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response("Not Found", { status: 404 });
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
return new Response(isHead ? null : "Not Found", { status: 404 });
}
const headers = getFallbackResponseHeaders(pathname);
if (!headers) return notFoundResponse({ noindex: true });
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
if (isHead) return new Response(null, { status: 200, headers });
const html = await fs.readFile(indexHtmlPath, "utf-8");
return new Response(html, { headers });
}
export function handleWebAppHead(request: Request) {
const pathname = new URL(request.url).pathname;
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response(null, { status: 404 });
const headers = getFallbackResponseHeaders(pathname);
if (!headers) return notFoundResponse({ head: true, noindex: true });
return new Response(null, { status: 200, headers });
}
+36
View File
@@ -19,7 +19,43 @@
<title>Reactive Resume</title>
</head>
<body>
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
<div id="app"></div>
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
<div id="initial-loader">
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
<div class="initial-loader__spinner"></div>
<span class="initial-loader__sr-only">Loading</span>
</div>
<style>
@keyframes app-spin { to { transform: rotate(360deg) } }
#initial-loader {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
background: #09090b;
}
#app:not(:empty) ~ #initial-loader { display: none; }
.initial-loader__spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(250, 250, 250, 0.2);
border-top-color: #fafafa;
border-radius: 9999px;
animation: app-spin 0.7s linear infinite;
}
.initial-loader__sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
</style>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+488 -196
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+485 -193
View File
File diff suppressed because it is too large Load Diff
+483 -192
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+488 -196
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+491 -199
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+488 -196
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+488 -196
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+489 -197
View File
File diff suppressed because it is too large Load Diff
+475 -184
View File
File diff suppressed because it is too large Load Diff
+33 -32
View File
@@ -16,12 +16,12 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^3.0.209",
"@base-ui/react": "^1.5.0",
"@better-auth/api-key": "^1.6.19",
"@better-auth/infra": "^0.2.14",
"@better-auth/oauth-provider": "^1.6.19",
"@better-auth/passkey": "^1.6.19",
"@ai-sdk/react": "^4.0.16",
"@base-ui/react": "^1.6.0",
"@better-auth/api-key": "^1.6.23",
"@better-auth/infra": "^0.3.4",
"@better-auth/oauth-provider": "^1.6.23",
"@better-auth/passkey": "^1.6.23",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -42,40 +42,41 @@
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/ui": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tailwindcss/vite": "^4.3.1",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-form": "^1.33.0",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.170.16",
"@tiptap/extension-color": "^3.27.0",
"@tiptap/extension-highlight": "^3.27.0",
"@tiptap/extension-table": "^3.27.0",
"@tiptap/extension-text-align": "^3.27.0",
"@tiptap/extension-text-style": "^3.27.0",
"@tiptap/pm": "^3.27.0",
"@tiptap/react": "^3.27.0",
"@tiptap/starter-kit": "^3.27.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@tiptap/extension-color": "^3.27.1",
"@tiptap/extension-highlight": "^3.27.1",
"@tiptap/extension-text-align": "^3.27.1",
"@tiptap/extension-text-style": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.3",
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^6.0.207",
"better-auth": "1.6.19",
"ai": "^7.0.15",
"better-auth": "1.6.23",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.3",
"es-toolkit": "^1.47.1",
"drizzle-orm": "1.0.0-rc.4",
"es-toolkit": "^1.49.0",
"fuse.js": "^7.4.2",
"immer": "^11.1.8",
"immer": "^11.1.11",
"js-cookie": "^3.0.8",
"motion": "^12.40.0",
"pdfjs-dist": "6.0.227",
"pg": "^8.21.0",
"motion": "^12.42.2",
"pdfjs-dist": "6.1.200",
"pg": "^8.22.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-easy-crop": "^6.1.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.11.2",
"react-resizable-panels": "^4.12.1",
"react-window": "^2.2.7",
"react-zoom-pan-pinch": "^4.0.3",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"ts-pattern": "^5.9.0",
"usehooks-ts": "^3.1.1",
@@ -83,24 +84,24 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@babel/core": "^8.0.0",
"@babel/core": "^8.0.1",
"@lingui/babel-plugin-lingui-macro": "^6.4.0",
"@lingui/cli": "^6.4.0",
"@lingui/format-po": "^6.4.0",
"@lingui/vite-plugin": "^6.4.0",
"@reactive-resume/config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.168.18",
"@tanstack/router-plugin": "^1.168.19",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260617.2",
"@vitejs/plugin-react": "^6.0.2",
"@typescript/native-preview": "7.0.0-dev.20260704.1",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^6.0.3",
"rimraf": "^6.1.3",
"vite": "^8.0.16"
"typescript": "^6.0.3",
"vite": "^8.1.3"
}
}
@@ -12,28 +12,22 @@ describe("CountUp", () => {
expect(span.getAttribute("aria-atomic")).toBe("true");
});
it("seeds textContent to the 'from' value when counting up", () => {
const { container } = render(<CountUp from={0} to={100} />);
it("seeds textContent to 0 on initial render", () => {
const { container } = render(<CountUp to={100} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("0");
});
it("seeds textContent to the 'to' value when direction is down", () => {
const { container } = render(<CountUp from={0} to={100} direction="down" />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("100");
});
it("formats with the separator when one is supplied", () => {
const { container } = render(<CountUp from={1234} to={2345} separator="," />);
const { container } = render(<CountUp to={1234} separator="," />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1,234");
expect(span.textContent).toBe("0");
});
it("preserves decimal places when from / to are fractional", () => {
const { container } = render(<CountUp from={1.25} to={3.75} />);
it("preserves decimal places when to is fractional", () => {
const { container } = render(<CountUp to={3.75} />);
const span = container.querySelector("span") as HTMLSpanElement;
expect(span.textContent).toBe("1.25");
expect(span.textContent).toBe("0.00");
});
it("strips aria-live and aria-atomic when aria-hidden is set", () => {
+11 -55
View File
@@ -3,116 +3,72 @@ import { useCallback, useEffect, useEffectEvent, useRef } from "react";
type CountUpProps = {
to: number;
from?: number;
direction?: "up" | "down";
delay?: number;
duration?: number;
className?: string;
startWhen?: boolean;
separator?: string;
onStart?: () => void;
onEnd?: () => void;
"aria-hidden"?: boolean | "true" | "false";
"aria-live"?: "off" | "polite" | "assertive";
"aria-atomic"?: boolean | "true" | "false";
};
// ponytail: from/direction/delay/startWhen/onStart/onEnd removed — no production caller passes them
export function CountUp({
to,
from = 0,
direction = "up",
delay = 0,
duration = 2,
className = "",
startWhen = true,
separator = "",
onStart,
onEnd,
"aria-hidden": ariaHidden,
"aria-live": ariaLive = "polite",
"aria-atomic": ariaAtomic = "true",
}: CountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
const motionValue = useMotionValue(direction === "down" ? to : from);
const motionValue = useMotionValue(0);
const damping = 20 + 40 * (1 / duration);
const stiffness = 100 * (1 / duration);
const springValue = useSpring(motionValue, {
damping,
stiffness,
});
const springValue = useSpring(motionValue, { damping, stiffness });
const isInView = useInView(ref, { once: true, margin: "0px" });
const getDecimalPlaces = (num: number): number => {
const str = num.toString();
if (str.includes(".")) {
const decimals = str.split(".")[1];
if (Number.parseInt(decimals, 10) !== 0) return decimals.length;
}
return 0;
};
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
const maxDecimals = getDecimalPlaces(to);
const formatValue = useCallback(
(latest: number) => {
const hasDecimals = maxDecimals > 0;
const options: Intl.NumberFormatOptions = {
useGrouping: !!separator,
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
maximumFractionDigits: hasDecimals ? maxDecimals : 0,
minimumFractionDigits: maxDecimals,
maximumFractionDigits: maxDecimals,
};
const formattedNumber = Intl.NumberFormat("en-US", options).format(latest);
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
},
[maxDecimals, separator],
);
const formatCurrentValue = useEffectEvent((latest: number) => formatValue(latest));
const notifyStart = useEffectEvent(() => onStart?.());
const notifyEnd = useEffectEvent(() => onEnd?.());
useEffect(() => {
if (ref.current) {
ref.current.textContent = formatCurrentValue(direction === "down" ? to : from);
}
}, [from, to, direction]);
if (ref.current) ref.current.textContent = formatCurrentValue(0);
}, []);
useEffect(() => {
if (isInView && startWhen) {
notifyStart();
const timeoutId = setTimeout(() => {
motionValue.set(direction === "down" ? from : to);
}, delay * 1000);
const durationTimeoutId = setTimeout(
() => {
notifyEnd();
},
delay * 1000 + duration * 1000,
);
return () => {
clearTimeout(timeoutId);
clearTimeout(durationTimeoutId);
};
}
}, [isInView, startWhen, motionValue, direction, from, to, delay, duration]);
if (isInView) motionValue.set(to);
}, [isInView, motionValue, to]);
useEffect(() => {
const unsubscribe = springValue.on("change", (latest: number) => {
if (ref.current) {
ref.current.textContent = formatCurrentValue(latest);
}
if (ref.current) ref.current.textContent = formatCurrentValue(latest);
});
return () => unsubscribe();
}, [springValue]);
@@ -60,7 +60,9 @@ function IconCellComponent({ columnIndex, rowIndex, style, icons, onChange }: Ic
title={icon}
style={style}
tabIndex={-1}
onClick={() => onChange(icon)}
onClick={() => {
if (icon) onChange(icon);
}}
className="flex size-full items-center justify-center hover:bg-accent"
>
{icon ? <i className={cn("ph text-base", `ph-${icon}`)} /> : <ProhibitIcon />}
@@ -100,7 +102,7 @@ export function IconPicker({ value, onChange, popoverProps, ...props }: IconPick
<Popover {...popoverProps}>
<PopoverTrigger
render={
<Button size="icon" variant="outline" {...props}>
<Button size="icon" variant="outline" aria-label={t`Pick an icon`} {...props}>
<i className={cn("ph size-4 text-base", `ph-${value}`)} />
</Button>
}
+25 -105
View File
@@ -5,10 +5,7 @@ import { Trans } from "@lingui/react/macro";
import {
ArrowsInSimpleIcon,
ArrowsOutSimpleIcon,
CodeBlockIcon,
CodeSimpleIcon,
ColumnsPlusLeftIcon,
ColumnsPlusRightIcon,
HighlighterCircleIcon,
KeyReturnIcon,
LinkBreakIcon,
@@ -17,10 +14,6 @@ import {
ListNumbersIcon,
MinusIcon,
ParagraphIcon,
PlusIcon,
RowsPlusBottomIcon,
RowsPlusTopIcon,
TableIcon,
TextAlignCenterIcon,
TextAlignJustifyIcon,
TextAlignLeftIcon,
@@ -37,11 +30,9 @@ import {
TextOutdentIcon,
TextStrikethroughIcon,
TextUnderlineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import { TableKit } from "@tiptap/extension-table";
import TextAlign from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
import { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
@@ -56,7 +47,6 @@ import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
@@ -76,9 +66,7 @@ const extensions = [
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
codeBlock: {
enableTabIndentation: true,
},
codeBlock: false,
link: {
openOnClick: false,
enableClickSelection: true,
@@ -98,7 +86,6 @@ const extensions = [
},
}),
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
TableKit.configure(),
];
type Props = UseEditorOptions & {
@@ -347,28 +334,8 @@ function useEditorToolbarState(editor: Editor) {
canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
// Code Block
isCodeBlock: ctx.editor.isActive("codeBlock") ?? false,
canCodeBlock: ctx.editor.can().chain().toggleCodeBlock().run() ?? false,
toggleCodeBlock: () => ctx.editor.chain().focus().toggleCodeBlock().run(),
// Table
insertTable: () => ctx.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
canInsertTable: ctx.editor.can().chain().insertTable().run() ?? false,
addColumnBefore: () => ctx.editor.chain().focus().addColumnBefore().run(),
canAddColumnBefore: ctx.editor.can().chain().addColumnBefore().run() ?? false,
addColumnAfter: () => ctx.editor.chain().focus().addColumnAfter().run(),
canAddColumnAfter: ctx.editor.can().chain().addColumnAfter().run() ?? false,
addRowBefore: () => ctx.editor.chain().focus().addRowBefore().run(),
canAddRowBefore: ctx.editor.can().chain().addRowBefore().run() ?? false,
addRowAfter: () => ctx.editor.chain().focus().addRowAfter().run(),
canAddRowAfter: ctx.editor.can().chain().addRowAfter().run() ?? false,
deleteColumn: () => ctx.editor.chain().focus().deleteColumn().run(),
canDeleteColumn: ctx.editor.can().chain().deleteColumn().run() ?? false,
deleteRow: () => ctx.editor.chain().focus().deleteRow().run(),
canDeleteRow: ctx.editor.can().chain().deleteRow().run() ?? false,
deleteTable: () => ctx.editor.chain().focus().deleteTable().run(),
canDeleteTable: ctx.editor.can().chain().deleteTable().run() ?? false,
// Character Count
characterCount: ctx.editor.getText().length,
// Hard Break
setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
@@ -582,7 +549,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
aria-label={t`Paragraph and heading style`}
className="rounded-none"
>
{match(state)
.with({ isParagraph: true }, () => <ParagraphIcon className="size-3.5" />)
.with({ isHeading1: true }, () => <TextHOneIcon className="size-3.5" />)
@@ -655,7 +628,13 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button size={isFullscreen ? "lg" : "sm"} tabIndex={-1} variant="ghost" className="rounded-none">
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
aria-label={t`Text alignment`}
className="rounded-none"
>
{match(state)
.with({ isLeftAlign: true }, () => <TextAlignLeftIcon className="size-3.5" />)
.with({ isCenterAlign: true }, () => <TextAlignCenterIcon className="size-3.5" />)
@@ -731,6 +710,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Decrease indent`}
disabled={!state.canLiftListItem}
onClick={state.liftListItem}
>
@@ -742,6 +722,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Increase indent`}
disabled={!state.canSinkListItem}
onClick={state.sinkListItem}
>
@@ -756,6 +737,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Remove link`}
onClick={state.unsetLink}
>
<LinkBreakIcon className="size-3.5" />
@@ -766,6 +748,7 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Add link`}
onClick={state.setLink}
>
<LinkIcon className="size-3.5" />
@@ -784,73 +767,6 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
<CodeSimpleIcon className="size-3.5" />
</Toggle>
<Toggle
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
className="rounded-none"
title={t`Code Block`}
pressed={state.isCodeBlock}
disabled={!state.canCodeBlock}
onPressedChange={state.toggleCodeBlock}
>
<CodeBlockIcon className="size-3.5" />
</Toggle>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
variant="ghost"
className="rounded-none"
title={t`Table`}
>
<TableIcon className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent>
<DropdownMenuItem disabled={!state.canInsertTable} onClick={state.insertTable}>
<PlusIcon />
<Trans>Insert Table</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddColumnBefore} onClick={state.addColumnBefore}>
<ColumnsPlusLeftIcon />
<Trans>Add Column Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddColumnAfter} onClick={state.addColumnAfter}>
<ColumnsPlusRightIcon />
<Trans>Add Column After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canAddRowBefore} onClick={state.addRowBefore}>
<RowsPlusTopIcon />
<Trans>Add Row Before</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canAddRowAfter} onClick={state.addRowAfter}>
<RowsPlusBottomIcon />
<Trans>Add Row After</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={!state.canDeleteColumn} onClick={state.deleteColumn}>
<TrashSimpleIcon />
<Trans>Delete Column</Trans>
</DropdownMenuItem>
<DropdownMenuItem disabled={!state.canDeleteRow} onClick={state.deleteRow}>
<TrashSimpleIcon />
<Trans>Delete Row</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!state.canDeleteTable} onClick={state.deleteTable}>
<TrashSimpleIcon />
<Trans>Delete Table</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size={isFullscreen ? "lg" : "sm"}
tabIndex={-1}
@@ -872,6 +788,10 @@ function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
>
<MinusIcon className="size-3.5" />
</Button>
<span className="ml-auto px-2 text-muted-foreground text-xs tabular-nums" aria-live="polite">
<Trans comment="Character count readout for the rich-text editor">{state.characterCount} characters</Trans>
</span>
</div>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import type { Website } from "@reactive-resume/schema/resume/data";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { TagIcon } from "@phosphor-icons/react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { Input } from "@reactive-resume/ui/components/input";
import {
InputGroup,
@@ -50,7 +50,7 @@ export function URLInput<TValue extends Website>({ value, onChange, hideLabelBut
[onChange, value],
);
const urlValue = useMemo(() => stripPrefix(value.url), [value.url]);
const urlValue = stripPrefix(value.url);
return (
<InputGroup>
@@ -5,7 +5,18 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { ErrorScreen } from "./error-screen";
// `Link` from tanstack/react-router requires a Router context. Stub it out with
// a plain anchor so we can render the screen in isolation.
vi.mock("@tanstack/react-router", () => ({
Link: ({ children, to, ...rest }: React.PropsWithChildren<{ to: string }>) => (
<a href={typeof to === "string" ? to : "#"} {...rest}>
{children}
</a>
),
}));
const { ErrorScreen } = await import("./error-screen");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -25,21 +36,27 @@ const renderError = (overrides: { error?: Error; reset?: () => void } = {}) => {
};
describe("ErrorScreen", () => {
it("shows the error message provided", () => {
renderError({ error: new Error("Network is down") });
expect(screen.getByText("Network is down")).toBeInTheDocument();
});
it("shows the user-facing error heading", () => {
it("shows a distinct, non-blaming error heading", () => {
renderError();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("calls reset when the Refresh button is clicked", () => {
it("does not surface the raw error message to the user", () => {
renderError({ error: new Error("Network is down") });
expect(screen.queryByText("Network is down")).not.toBeInTheDocument();
});
it("calls reset when the Try again button is clicked", () => {
const reset = vi.fn();
renderError({ reset });
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
fireEvent.click(screen.getByRole("button", { name: /try again/i }));
expect(reset).toHaveBeenCalledTimes(1);
});
it("offers an absolute dashboard escape", () => {
renderError();
const link = screen.getByRole("link", { name: /go to dashboard/i });
expect(link.getAttribute("href")).toBe("/dashboard");
});
});
@@ -1,11 +1,12 @@
import type { ErrorComponentProps } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { ArrowClockwiseIcon, WarningIcon } from "@phosphor-icons/react";
import { ArrowClockwiseIcon, HouseIcon, WarningIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { Button, buttonVariants } from "@reactive-resume/ui/components/button";
export function ErrorScreen({ error, reset }: ErrorComponentProps) {
export function ErrorScreen({ reset }: ErrorComponentProps) {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
<BrandIcon variant="logo" className="size-12" />
@@ -13,15 +14,24 @@ export function ErrorScreen({ error, reset }: ErrorComponentProps) {
<Alert>
<WarningIcon />
<AlertTitle>
<Trans>An error occurred while loading the page.</Trans>
<Trans>Something went wrong</Trans>
</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
<AlertDescription>
<Trans>An unexpected error stopped this page from loading. You can try again or head back.</Trans>
</AlertDescription>
</Alert>
<Button onClick={reset}>
<ArrowClockwiseIcon />
<Trans>Refresh</Trans>
</Button>
<div className="flex items-center gap-x-2">
<Button onClick={reset}>
<ArrowClockwiseIcon />
<Trans>Try again</Trans>
</Button>
<Link to="/dashboard" className={buttonVariants({ variant: "secondary" })}>
<HouseIcon />
<Trans>Go to dashboard</Trans>
</Link>
</div>
</div>
);
}
@@ -1,32 +1,29 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { describe, expect, it } from "vitest";
import { LoadingScreen } from "./loading-screen";
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
describe("LoadingScreen", () => {
it("renders a spinner and the loading text", () => {
render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
it("renders the Reactive Resume icon and spinner", () => {
render(<LoadingScreen />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
const icons = screen.getAllByAltText("Reactive Resume");
expect(icons).toHaveLength(2);
expect(icons.map((icon) => icon.getAttribute("src"))).toEqual(["/icon/dark.svg", "/icon/light.svg"]);
expect(screen.getByLabelText("Loading")).toBeInTheDocument();
});
it("uses the same icon asset as the initial HTML loader", () => {
const html = readFileSync("index.html", "utf8");
expect(html).toContain('src="/icon/dark.svg"');
expect(html).not.toContain('src="/logo/dark.svg"');
});
it("fills the viewport (fixed inset-0)", () => {
const { container } = render(
<I18nProvider i18n={i18n}>
<LoadingScreen />
</I18nProvider>,
);
const { container } = render(<LoadingScreen />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper.className).toContain("fixed");
@@ -1,13 +1,11 @@
import { Trans } from "@lingui/react/macro";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Spinner } from "@reactive-resume/ui/components/spinner";
export function LoadingScreen() {
return (
<div className="fixed inset-0 z-50 flex h-svh w-svw items-center justify-center gap-x-3 bg-background">
<div className="fixed inset-0 z-50 flex h-svh w-svw flex-col items-center justify-center gap-y-6 bg-background">
<BrandIcon variant="icon" className="size-12" />
<Spinner className="size-6" />
<p className="text-muted-foreground">
<Trans>Loading…</Trans>
</p>
</div>
);
}
@@ -21,27 +21,33 @@ beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
const renderScreen = (routeId = "/missing") =>
const renderScreen = () =>
render(
<I18nProvider i18n={i18n}>
<NotFoundScreen isNotFound routeId={routeId as never} />
<NotFoundScreen />
</I18nProvider>,
);
describe("NotFoundScreen", () => {
it("renders the documented error heading", () => {
it("renders a distinct not-found heading", () => {
renderScreen();
expect(screen.getByText("An error occurred while loading the page.")).toBeInTheDocument();
expect(screen.getByText("We couldn't find that page")).toBeInTheDocument();
});
it("displays the routeId that triggered the not-found", () => {
renderScreen("/dashboard/missing-page");
expect(screen.getByText("/dashboard/missing-page")).toBeInTheDocument();
it("does not surface the raw routeId", () => {
renderScreen();
expect(screen.queryByText(/\/dashboard\/missing-page/)).not.toBeInTheDocument();
});
it("renders a Go Back link", () => {
it("offers an absolute dashboard escape", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go back/i });
expect(link.getAttribute("href")).toBe("..");
const link = screen.getByRole("link", { name: /go to dashboard/i });
expect(link.getAttribute("href")).toBe("/dashboard");
});
it("offers an absolute home escape", () => {
renderScreen();
const link = screen.getByRole("link", { name: /go home/i });
expect(link.getAttribute("href")).toBe("/");
});
});
@@ -1,12 +1,11 @@
import type { NotFoundRouteProps } from "@tanstack/react-router";
import { Trans } from "@lingui/react/macro";
import { ArrowLeftIcon, WarningIcon } from "@phosphor-icons/react";
import { HouseIcon, MagnifyingGlassIcon, WarningIcon } from "@phosphor-icons/react";
import { Link } from "@tanstack/react-router";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { buttonVariants } from "@reactive-resume/ui/components/button";
export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
export function NotFoundScreen() {
return (
<div className="mx-auto flex h-svh max-w-md flex-col items-center justify-center gap-y-4">
<BrandIcon variant="logo" className="size-12" />
@@ -14,15 +13,24 @@ export function NotFoundScreen({ routeId }: NotFoundRouteProps) {
<Alert>
<WarningIcon />
<AlertTitle>
<Trans>An error occurred while loading the page.</Trans>
<Trans>We couldn't find that page</Trans>
</AlertTitle>
<AlertDescription>{routeId}</AlertDescription>
<AlertDescription>
<Trans>The page you're looking for may have been moved or no longer exists.</Trans>
</AlertDescription>
</Alert>
<Link to=".." className={buttonVariants()}>
<ArrowLeftIcon />
<Trans>Go Back</Trans>
</Link>
<div className="flex items-center gap-x-2">
<Link to="/dashboard" className={buttonVariants()}>
<MagnifyingGlassIcon />
<Trans>Go to dashboard</Trans>
</Link>
<Link to="/" className={buttonVariants({ variant: "secondary" })}>
<HouseIcon />
<Trans>Go home</Trans>
</Link>
</div>
</div>
);
}
+6 -2
View File
@@ -25,6 +25,9 @@ import { useControlledState } from "@/hooks/use-controlled-state";
type ComboboxOption<TValue extends string | number = string> = {
value: TValue;
label: React.ReactNode;
// Plain-text label used for the collapsed trigger and filtering when `label` is a ReactNode.
// Without it, a JSX label falls back to String(value) (e.g. a raw enum or locale code) in the trigger.
textValue?: string;
group?: string | ComboboxOptionGroup;
keywords?: string[];
disabled?: boolean;
@@ -177,7 +180,8 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
});
const itemToStringLabel = React.useCallback(
(item: ComboboxOption<TValue>) => (typeof item.label === "string" ? item.label : String(item.value)),
(item: ComboboxOption<TValue>) =>
item.textValue ?? (typeof item.label === "string" ? item.label : String(item.value)),
[],
);
@@ -188,7 +192,7 @@ function Combobox<TValue extends string | number = string>(props: ComboboxProps<
const filter = React.useCallback(
(item: ComboboxOption<TValue>, query: string) => {
const labelStr = typeof item.label === "string" ? item.label : String(item.value);
const labelStr = typeof item.label === "string" ? item.label : (item.textValue ?? String(item.value));
if (contains(labelStr, query)) return true;
return item.keywords?.some((kw) => contains(kw, query)) ?? false;
},
@@ -36,7 +36,8 @@ describe("Copyright", () => {
it("includes the app version string", () => {
renderCopyright();
expect(screen.getByText(/v9\.9\.9/)).toBeInTheDocument();
// The version is wrapped in <bdi> for RTL isolation, so it is its own text node.
expect(screen.getByText("9.9.9")).toBeInTheDocument();
});
it("merges custom className into the wrapper", () => {
+1 -1
View File
@@ -42,7 +42,7 @@ export function Copyright({ className, ...props }: Props) {
<p className="mt-4">
<Trans comment="App version label in footer; includes semantic version variable">
Reactive Resume v{__APP_VERSION__}
Reactive Resume v<bdi>{__APP_VERSION__}</bdi>
</Trans>
</p>
</div>
@@ -14,9 +14,9 @@ type ToastOptions = {
unstyled: boolean;
};
const useCookieMock = vi.hoisted(() => ({
setDismissed: vi.fn(),
const cookieMock = vi.hoisted(() => ({
value: null as string | null,
set: vi.fn(),
}));
const toastMock = vi.hoisted(() => ({
@@ -26,8 +26,11 @@ const toastMock = vi.hoisted(() => ({
},
}));
vi.mock("@reactive-resume/ui/hooks/use-cookie", () => ({
useCookie: vi.fn(() => [useCookieMock.value, useCookieMock.setDismissed, vi.fn()] as const),
vi.mock("js-cookie", () => ({
default: {
get: vi.fn(() => cookieMock.value ?? undefined),
set: cookieMock.set,
},
}));
vi.mock("sonner", () => ({
@@ -51,8 +54,8 @@ describe("DonationToast", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-11T12:00:00.000Z"));
i18n.loadAndActivate({ locale: "en-US", messages: {} });
useCookieMock.value = null;
useCookieMock.setDismissed.mockClear();
cookieMock.value = null;
cookieMock.set.mockClear();
toastMock.toast.custom.mockClear();
toastMock.toast.dismiss.mockClear();
vi.spyOn(window, "open").mockReturnValue(null);
@@ -89,7 +92,7 @@ describe("DonationToast", () => {
});
it("does not show the toast after it has been dismissed", () => {
useCookieMock.value = "true";
cookieMock.value = "true";
render(<DonationToast />);
@@ -110,7 +113,10 @@ describe("DonationToast", () => {
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", {
path: "/",
secure: true,
sameSite: "lax",
expires: new Date("2026-06-10T12:05:00.000Z"),
});
expect(toastMock.toast.dismiss).toHaveBeenCalledWith("donation-toast");
@@ -126,7 +132,10 @@ describe("DonationToast", () => {
fireEvent.click(screen.getByRole("button", { name: "Donate" }));
expect(useCookieMock.setDismissed).toHaveBeenCalledWith("true", {
expect(cookieMock.set).toHaveBeenCalledWith("donation-toast-dismissed", "true", {
path: "/",
secure: true,
sameSite: "lax",
expires: new Date("2026-06-10T12:05:00.000Z"),
});
expect(window.open).toHaveBeenCalledWith(
+10 -3
View File
@@ -1,10 +1,10 @@
import { Trans } from "@lingui/react/macro";
import { HandHeartIcon } from "@phosphor-icons/react";
import { useCallback } from "react";
import Cookies from "js-cookie";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { useTimeout } from "usehooks-ts";
import { Button } from "@reactive-resume/ui/components/button";
import { useCookie } from "@reactive-resume/ui/hooks/use-cookie";
const TOAST_ID = "donation-toast";
const SHOW_TOAST_DELAY_MS = 5 * 60 * 1000; // 5 minutes
@@ -14,7 +14,14 @@ const DISMISSED_COOKIE_EXPIRES_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const getDismissedCookieExpiresAt = () => new Date(Date.now() + DISMISSED_COOKIE_EXPIRES_MS);
export function DonationToast() {
const [dismissed, setDismissed] = useCookie(DISMISSED_COOKIE_NAME);
// ponytail: inlined from @reactive-resume/ui/hooks/use-cookie — only consumer, one read + one set-with-expiry
const [dismissed, setDismissedState] = useState<string | null>(() => Cookies.get(DISMISSED_COOKIE_NAME) ?? null);
const setDismissed = useCallback((value: string, options?: { expires?: Date }) => {
// Attributes match the former useCookie DEFAULT_COOKIE_ATTRIBUTES; options (expiry) override.
Cookies.set(DISMISSED_COOKIE_NAME, value, { path: "/", secure: true, sameSite: "lax", ...options });
setDismissedState(value);
}, []);
const showToast = useCallback(() => {
if (dismissed === "true") return;
+4 -4
View File
@@ -1,6 +1,6 @@
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
import type { AnyDialogRendererEntry } from "../renderer-registry";
import { CreateApiKeyDialog } from "./create";
export const apiKeyDialogRendererRegistry = defineDialogRendererRegistry("api-key", [
defineDialogRenderer("api-key.create", () => <CreateApiKeyDialog />),
]);
export const apiKeyDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "api-key.create", render: () => <CreateApiKeyDialog /> },
];
+6 -6
View File
@@ -1,10 +1,10 @@
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
import type { AnyDialogRendererEntry } from "../renderer-registry";
import { ChangePasswordDialog } from "./change-password";
import { DisableTwoFactorDialog } from "./disable-two-factor";
import { EnableTwoFactorDialog } from "./enable-two-factor";
export const authDialogRendererRegistry = defineDialogRendererRegistry("auth", [
defineDialogRenderer("auth.change-password", () => <ChangePasswordDialog />),
defineDialogRenderer("auth.two-factor.enable", () => <EnableTwoFactorDialog />),
defineDialogRenderer("auth.two-factor.disable", () => <DisableTwoFactorDialog />),
]);
export const authDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "auth.change-password", render: () => <ChangePasswordDialog /> },
{ type: "auth.two-factor.enable", render: () => <EnableTwoFactorDialog /> },
{ type: "auth.two-factor.disable", render: () => <DisableTwoFactorDialog /> },
];
+3 -17
View File
@@ -1,26 +1,12 @@
import type { ReactNode } from "react";
import type { DialogSchema, DialogType } from "./schemas";
export interface DialogRendererEntry<T extends DialogType = DialogType> {
// ponytail: defineDialogRenderer/defineDialogRendererRegistry were identity fns; inlined
type DialogRendererEntry<T extends DialogType = DialogType> = {
type: T;
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode;
}
};
export type AnyDialogRendererEntry = {
[T in DialogType]: DialogRendererEntry<T>;
}[DialogType];
export interface DialogRendererRegistry {
domain: string;
renderers: readonly AnyDialogRendererEntry[];
}
export const defineDialogRenderer = <T extends DialogType>(
type: T,
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode,
): DialogRendererEntry<T> => ({ type, render });
export const defineDialogRendererRegistry = (
domain: string,
renderers: readonly AnyDialogRendererEntry[],
): DialogRendererRegistry => ({ domain, renderers });
+5 -13
View File
@@ -1,25 +1,17 @@
import type { DialogSchema } from "./schemas";
import { apiKeyDialogRendererRegistry } from "./api-key/registry";
import { authDialogRendererRegistry } from "./auth/registry";
import { resumeDialogRendererRegistry } from "./resume/registry";
const dialogRendererRegistries = [
authDialogRendererRegistry,
apiKeyDialogRendererRegistry,
resumeDialogRendererRegistry,
] as const;
import { apiKeyDialogRenderers } from "./api-key/registry";
import { authDialogRenderers } from "./auth/registry";
import { resumeDialogRenderers } from "./resume/registry";
const dialogRendererByType = new Map(
dialogRendererRegistries.flatMap((registry) =>
registry.renderers.map((renderer) => [renderer.type, renderer] as const),
[...authDialogRenderers, ...apiKeyDialogRenderers, ...resumeDialogRenderers].map(
(renderer) => [renderer.type, renderer] as const,
),
);
export const renderDialog = (dialog: DialogSchema | null) => {
if (!dialog) return null;
const renderer = dialogRendererByType.get(dialog.type);
if (renderer) return renderer.render(dialog as never);
return null;
};
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { detectJsonImportType } from "./import";
describe("detectJsonImportType", () => {
it("detects JSON Resume by a top-level basics without Reactive Resume sections/metadata", () => {
expect(detectJsonImportType({ basics: { name: "A" }, work: [] })).toBe("json-resume-json");
});
it("detects the current Reactive Resume schema by metadata.page", () => {
expect(detectJsonImportType({ basics: {}, sections: {}, metadata: { page: { locale: "en-US" } } })).toBe(
"reactive-resume-json",
);
});
it("detects the legacy v4 schema by metadata without a page key", () => {
expect(detectJsonImportType({ basics: {}, sections: {}, metadata: { template: "azurill" } })).toBe(
"reactive-resume-v4-json",
);
});
it("returns an empty string for unrecognized shapes", () => {
expect(detectJsonImportType({ foo: "bar" })).toBe("");
expect(detectJsonImportType(null)).toBe("");
expect(detectJsonImportType("nope")).toBe("");
});
});
+167 -100
View File
@@ -4,14 +4,14 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { DownloadSimpleIcon, FileIcon, UploadSimpleIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useMutation } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { toast } from "sonner";
import z from "zod";
import { JSONResumeImporter } from "@reactive-resume/import/json-resume";
import { ReactiveResumeJSONImporter } from "@reactive-resume/import/reactive-resume-json";
import { ReactiveResumeV4JSONImporter } from "@reactive-resume/import/reactive-resume-v4-json";
import { parseJSONResume } from "@reactive-resume/import/json-resume";
import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
import { Badge } from "@reactive-resume/ui/components/badge";
import { Button } from "@reactive-resume/ui/components/button";
import {
@@ -24,8 +24,8 @@ import {
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { Combobox } from "@/components/ui/combobox";
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { getOrpcErrorMessage } from "@/libs/error-message";
import { client, orpc } from "@/libs/orpc/client";
@@ -88,6 +88,56 @@ function fileToBase64(file: File): Promise<string> {
});
}
// #7: sniff the source format from magic bytes + JSON shape rather than trusting the extension/MIME
// (multiple resume interchange formats share the .json extension). Returns "" when unrecognized.
async function detectImportType(file: File): Promise<ImportType> {
const name = file.name.toLowerCase();
const mime = file.type;
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
const isPdf = header[0] === 0x25 && header[1] === 0x50 && header[2] === 0x44 && header[3] === 0x46; // "%PDF"
const isZip = header[0] === 0x50 && header[1] === 0x4b && header[2] === 0x03 && header[3] === 0x04; // "PK\x03\x04"
if (isPdf || mime === "application/pdf" || name.endsWith(".pdf")) return "pdf";
if (
isZip ||
mime === "application/msword" ||
mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ||
name.endsWith(".docx") ||
name.endsWith(".doc")
) {
return "docx";
}
if (mime === "application/json" || name.endsWith(".json")) {
try {
return detectJsonImportType(JSON.parse(await file.text()));
} catch {
return "";
}
}
return "";
}
export function detectJsonImportType(parsed: unknown): ImportType {
if (!parsed || typeof parsed !== "object") return "";
const data = parsed as Record<string, unknown>;
// JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`.
if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json";
// Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key,
// the legacy v4 schema does not. Best-effort guess — the user can override the type below.
if ("sections" in data || "metadata" in data) {
const metadata = data.metadata as Record<string, unknown> | undefined;
if (metadata && !("page" in metadata)) return "reactive-resume-v4-json";
return "reactive-resume-json";
}
return "";
}
export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
@@ -96,8 +146,7 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
const [isImporting, setIsImporting] = useState<boolean>(false);
const { mutateAsync: importResume } = useMutation(orpc.resume.import.mutationOptions());
const { data: aiProviders, isLoading: isLoadingAiProviders } = useQuery(orpc.aiProviders.list.queryOptions());
const hasAIProvider = aiProviders?.some((provider) => provider.enabled && provider.testStatus === "success") ?? false;
const { hasUsableProvider, isLoading: isLoadingAiProviders } = useHasUsableAiProvider();
const form = useAppForm({
defaultValues: {
@@ -118,27 +167,21 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
let data: ResumeData | undefined;
if (value.type === "json-resume-json") {
const json = await value.file.text();
const importer = new JSONResumeImporter();
data = importer.parse(json);
data = parseJSONResume(await value.file.text());
}
if (value.type === "reactive-resume-json") {
const json = await value.file.text();
const importer = new ReactiveResumeJSONImporter();
data = importer.parse(json);
data = parseReactiveResumeJSON(await value.file.text());
}
if (value.type === "reactive-resume-v4-json") {
const json = await value.file.text();
const importer = new ReactiveResumeV4JSONImporter();
data = importer.parse(json);
data = parseReactiveResumeV4JSON(await value.file.text());
}
if (value.type === "pdf") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
if (!hasUsableProvider)
throw new Error(t`This feature requires a connected AI provider. Please set one up in the settings.`);
const base64 = await fileToBase64(value.file);
@@ -149,8 +192,8 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
if (value.type === "docx") {
if (isLoadingAiProviders) throw new Error(t`Loading AI providers. Please try again in a moment.`);
if (!hasAIProvider)
throw new Error(t`This feature requires a tested AI provider. Please add one in the settings.`);
if (!hasUsableProvider)
throw new Error(t`This feature requires a connected AI provider. Please set one up in the settings.`);
const base64 = await fileToBase64(value.file);
@@ -205,19 +248,24 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
});
const type = useStore(form.store, (s) => s.values.type);
const file = useStore(form.store, (s) => s.values.file);
const aiRequired = type === "pdf" || type === "docx";
const onSelectFile = () => {
if (!inputRef.current) return;
inputRef.current.click();
};
const onUploadFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
form.setFieldValue("file", file);
const onUploadFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (!selected) return;
form.setFieldValue("file", selected);
// #7: pre-select the source format from the file's content; the user can still override below.
form.setFieldValue("type", await detectImportType(selected));
};
useFormBlocker(form);
// #6: only warn about unsaved changes once a file has actually been chosen — not on a bare type selection.
useFormBlocker(form, { shouldBlock: () => Boolean(file) });
return (
<DialogContent>
@@ -242,83 +290,12 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
void form.handleSubmit();
}}
>
<form.Field name="type">
<form.Field name="file">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Type</Trans>
<Trans>File</Trans>
</FormLabel>
<FormControl
render={
<Combobox
showClear={false}
value={field.state.value}
onValueChange={(value) => {
const nextType = value as ImportType;
if (nextType !== field.state.value) form.setFieldValue("file", undefined);
field.handleChange(nextType);
}}
options={[
{
value: "reactive-resume-json",
label: t({
comment: "Import source option for current Reactive Resume JSON format",
message: "Reactive Resume (JSON)",
}),
},
{
value: "reactive-resume-v4-json",
label: t({
comment: "Import source option for legacy Reactive Resume v4 JSON format",
message: "Reactive Resume v4 (JSON)",
}),
},
{
value: "json-resume-json",
label: t({
comment: "Import source option for standard JSON Resume format",
message: "JSON Resume",
}),
},
{
value: "pdf",
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "PDF",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "Microsoft Word",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
}
/>
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
<form.Field key={type} name="file">
{(field) => (
<FormItem
className={cn(!type && "hidden")}
hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}
>
<FormControl>
<Input type="file" className="hidden" ref={inputRef} onChange={onUploadFile} />
@@ -345,8 +322,98 @@ export function ImportResumeDialog(_: DialogProps<"resume.import">) {
)}
</form.Field>
{file && (
<form.Field name="type">
{(field) => (
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormLabel>
<Trans>Type</Trans>
</FormLabel>
<FormControl
render={
<Combobox
showClear={false}
value={field.state.value}
onValueChange={(value) => field.handleChange(value as ImportType)}
options={[
{
value: "reactive-resume-json",
label: t({
comment: "Import source option for current Reactive Resume JSON format",
message: "Reactive Resume (JSON)",
}),
},
{
value: "reactive-resume-v4-json",
label: t({
comment: "Import source option for legacy Reactive Resume v4 JSON format",
message: "Reactive Resume v4 (JSON)",
}),
},
{
value: "json-resume-json",
label: t({
comment: "Import source option for standard JSON Resume format",
message: "JSON Resume",
}),
},
{
value: "pdf",
textValue: t({ comment: "File format label in import source selector", message: "PDF" }),
label: (
<div className="flex items-center gap-x-2">
{t({ comment: "File format label in import source selector", message: "PDF" })}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
{
value: "docx",
textValue: t({
comment: "File format label in import source selector",
message: "Microsoft Word",
}),
label: (
<div className="flex items-center gap-x-2">
{t({
comment: "File format label in import source selector",
message: "Microsoft Word",
})}{" "}
<Badge>{t`AI`}</Badge>
</div>
),
},
]}
/>
}
/>
{!field.state.value && (
<p className="text-muted-foreground text-xs">
<Trans>We couldn't detect the format automatically — please choose it above.</Trans>
</p>
)}
<FormMessage errors={field.state.meta.errors} />
</FormItem>
)}
</form.Field>
)}
{aiRequired && !isLoadingAiProviders && !hasUsableProvider && (
<div className="flex flex-col gap-3 rounded-md border border-dashed p-3 text-sm lg:flex-row lg:items-center lg:justify-between">
<span className="text-muted-foreground">
<Trans>Importing from PDF or Word requires a connected AI provider.</Trans>
</span>
<Button
size="sm"
variant="secondary"
nativeButton={false}
render={<Link to="/dashboard/settings/integrations">{t`Set up a provider`}</Link>}
/>
</div>
)}
<DialogFooter>
<Button type="submit" disabled={!type || isImporting}>
<Button type="submit" disabled={!type || !file || isImporting || (aiRequired && !hasUsableProvider)}>
{isImporting ? <Spinner /> : null}
{isImporting ? t`Importing…` : t`Import`}
</Button>
+13 -4
View File
@@ -6,7 +6,7 @@ import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeI
import { useStore } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { useNavigate, useParams } from "@tanstack/react-router";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
@@ -59,7 +59,10 @@ const defaultValues: FormValues = {
};
export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const navigate = useNavigate();
const closeDialog = useDialogStore((state) => state.closeDialog);
// Skip the unsaved-changes guard when we close as a result of a successful create.
const didCreateRef = useRef(false);
const { mutate: createResume, isPending } = useMutation(orpc.resume.create.mutationOptions());
@@ -75,9 +78,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const toastId = toast.loading(t`Creating your resume...`);
createResume(value, {
onSuccess: () => {
onSuccess: (id) => {
didCreateRef.current = true;
toast.success(t`Your resume has been created successfully.`, { id: toastId });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
@@ -92,7 +97,9 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
form.setFieldValue("slug", slugify(name));
}, [form, name]);
useFormBlocker(form);
useFormBlocker(form, {
shouldBlock: () => !didCreateRef.current && form.state.isDirty && !form.state.isSubmitting,
});
const onCreateSampleResume = () => {
const values = form.state.values;
@@ -108,9 +115,11 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) {
const toastId = toast.loading(t`Creating your resume...`);
createResume(data, {
onSuccess: () => {
onSuccess: (id) => {
didCreateRef.current = true;
toast.success(t`Your resume has been created successfully.`, { id: toastId });
closeDialog();
void navigate({ to: "/builder/$resumeId", params: { resumeId: id } });
},
onError: (error) => {
toast.error(getResumeErrorMessage(error), { id: toastId });
+44 -42
View File
@@ -1,4 +1,4 @@
import { defineDialogRenderer, defineDialogRendererRegistry } from "../renderer-registry";
import type { AnyDialogRendererEntry } from "../renderer-registry";
import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from ".";
import { ImportResumeDialog } from "./import";
import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award";
@@ -18,44 +18,46 @@ import { CreateSummaryItemDialog, UpdateSummaryItemDialog } from "./sections/sum
import { CreateVolunteerDialog, UpdateVolunteerDialog } from "./sections/volunteer";
import { TemplateGalleryDialog } from "./template/gallery";
export const resumeDialogRendererRegistry = defineDialogRendererRegistry("resume", [
defineDialogRenderer("resume.create", () => <CreateResumeDialog />),
defineDialogRenderer("resume.update", ({ data }) => <UpdateResumeDialog data={data} />),
defineDialogRenderer("resume.duplicate", ({ data }) => <DuplicateResumeDialog data={data} />),
defineDialogRenderer("resume.import", () => <ImportResumeDialog />),
defineDialogRenderer("resume.template.gallery", () => <TemplateGalleryDialog />),
defineDialogRenderer("resume.sections.profiles.create", ({ data }) => <CreateProfileDialog data={data} />),
defineDialogRenderer("resume.sections.profiles.update", ({ data }) => <UpdateProfileDialog data={data} />),
defineDialogRenderer("resume.sections.experience.create", ({ data }) => <CreateExperienceDialog data={data} />),
defineDialogRenderer("resume.sections.experience.update", ({ data }) => <UpdateExperienceDialog data={data} />),
defineDialogRenderer("resume.sections.education.create", ({ data }) => <CreateEducationDialog data={data} />),
defineDialogRenderer("resume.sections.education.update", ({ data }) => <UpdateEducationDialog data={data} />),
defineDialogRenderer("resume.sections.skills.create", ({ data }) => <CreateSkillDialog data={data} />),
defineDialogRenderer("resume.sections.skills.update", ({ data }) => <UpdateSkillDialog data={data} />),
defineDialogRenderer("resume.sections.projects.create", ({ data }) => <CreateProjectDialog data={data} />),
defineDialogRenderer("resume.sections.projects.update", ({ data }) => <UpdateProjectDialog data={data} />),
defineDialogRenderer("resume.sections.certifications.create", ({ data }) => (
<CreateCertificationDialog data={data} />
)),
defineDialogRenderer("resume.sections.certifications.update", ({ data }) => (
<UpdateCertificationDialog data={data} />
)),
defineDialogRenderer("resume.sections.languages.create", ({ data }) => <CreateLanguageDialog data={data} />),
defineDialogRenderer("resume.sections.languages.update", ({ data }) => <UpdateLanguageDialog data={data} />),
defineDialogRenderer("resume.sections.publications.create", ({ data }) => <CreatePublicationDialog data={data} />),
defineDialogRenderer("resume.sections.publications.update", ({ data }) => <UpdatePublicationDialog data={data} />),
defineDialogRenderer("resume.sections.awards.create", ({ data }) => <CreateAwardDialog data={data} />),
defineDialogRenderer("resume.sections.awards.update", ({ data }) => <UpdateAwardDialog data={data} />),
defineDialogRenderer("resume.sections.interests.create", ({ data }) => <CreateInterestDialog data={data} />),
defineDialogRenderer("resume.sections.interests.update", ({ data }) => <UpdateInterestDialog data={data} />),
defineDialogRenderer("resume.sections.volunteer.create", ({ data }) => <CreateVolunteerDialog data={data} />),
defineDialogRenderer("resume.sections.volunteer.update", ({ data }) => <UpdateVolunteerDialog data={data} />),
defineDialogRenderer("resume.sections.references.create", ({ data }) => <CreateReferenceDialog data={data} />),
defineDialogRenderer("resume.sections.references.update", ({ data }) => <UpdateReferenceDialog data={data} />),
defineDialogRenderer("resume.sections.summary.create", ({ data }) => <CreateSummaryItemDialog data={data} />),
defineDialogRenderer("resume.sections.summary.update", ({ data }) => <UpdateSummaryItemDialog data={data} />),
defineDialogRenderer("resume.sections.cover-letter.create", ({ data }) => <CreateCoverLetterDialog data={data} />),
defineDialogRenderer("resume.sections.cover-letter.update", ({ data }) => <UpdateCoverLetterDialog data={data} />),
defineDialogRenderer("resume.sections.custom.create", ({ data }) => <CreateCustomSectionDialog data={data} />),
defineDialogRenderer("resume.sections.custom.update", ({ data }) => <UpdateCustomSectionDialog data={data} />),
]);
export const resumeDialogRenderers: readonly AnyDialogRendererEntry[] = [
{ type: "resume.create", render: () => <CreateResumeDialog /> },
{ type: "resume.update", render: ({ data }) => <UpdateResumeDialog data={data} /> },
{ type: "resume.duplicate", render: ({ data }) => <DuplicateResumeDialog data={data} /> },
{ type: "resume.import", render: () => <ImportResumeDialog /> },
{ type: "resume.template.gallery", render: () => <TemplateGalleryDialog /> },
{ type: "resume.sections.profiles.create", render: ({ data }) => <CreateProfileDialog data={data} /> },
{ type: "resume.sections.profiles.update", render: ({ data }) => <UpdateProfileDialog data={data} /> },
{ type: "resume.sections.experience.create", render: ({ data }) => <CreateExperienceDialog data={data} /> },
{ type: "resume.sections.experience.update", render: ({ data }) => <UpdateExperienceDialog data={data} /> },
{ type: "resume.sections.education.create", render: ({ data }) => <CreateEducationDialog data={data} /> },
{ type: "resume.sections.education.update", render: ({ data }) => <UpdateEducationDialog data={data} /> },
{ type: "resume.sections.skills.create", render: ({ data }) => <CreateSkillDialog data={data} /> },
{ type: "resume.sections.skills.update", render: ({ data }) => <UpdateSkillDialog data={data} /> },
{ type: "resume.sections.projects.create", render: ({ data }) => <CreateProjectDialog data={data} /> },
{ type: "resume.sections.projects.update", render: ({ data }) => <UpdateProjectDialog data={data} /> },
{
type: "resume.sections.certifications.create",
render: ({ data }) => <CreateCertificationDialog data={data} />,
},
{
type: "resume.sections.certifications.update",
render: ({ data }) => <UpdateCertificationDialog data={data} />,
},
{ type: "resume.sections.languages.create", render: ({ data }) => <CreateLanguageDialog data={data} /> },
{ type: "resume.sections.languages.update", render: ({ data }) => <UpdateLanguageDialog data={data} /> },
{ type: "resume.sections.publications.create", render: ({ data }) => <CreatePublicationDialog data={data} /> },
{ type: "resume.sections.publications.update", render: ({ data }) => <UpdatePublicationDialog data={data} /> },
{ type: "resume.sections.awards.create", render: ({ data }) => <CreateAwardDialog data={data} /> },
{ type: "resume.sections.awards.update", render: ({ data }) => <UpdateAwardDialog data={data} /> },
{ type: "resume.sections.interests.create", render: ({ data }) => <CreateInterestDialog data={data} /> },
{ type: "resume.sections.interests.update", render: ({ data }) => <UpdateInterestDialog data={data} /> },
{ type: "resume.sections.volunteer.create", render: ({ data }) => <CreateVolunteerDialog data={data} /> },
{ type: "resume.sections.volunteer.update", render: ({ data }) => <UpdateVolunteerDialog data={data} /> },
{ type: "resume.sections.references.create", render: ({ data }) => <CreateReferenceDialog data={data} /> },
{ type: "resume.sections.references.update", render: ({ data }) => <UpdateReferenceDialog data={data} /> },
{ type: "resume.sections.summary.create", render: ({ data }) => <CreateSummaryItemDialog data={data} /> },
{ type: "resume.sections.summary.update", render: ({ data }) => <UpdateSummaryItemDialog data={data} /> },
{ type: "resume.sections.cover-letter.create", render: ({ data }) => <CreateCoverLetterDialog data={data} /> },
{ type: "resume.sections.cover-letter.update", render: ({ data }) => <UpdateCoverLetterDialog data={data} /> },
{ type: "resume.sections.custom.create", render: ({ data }) => <CreateCustomSectionDialog data={data} /> },
{ type: "resume.sections.custom.update", render: ({ data }) => <UpdateCustomSectionDialog data={data} /> },
];
+21 -68
View File
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { awardItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
@@ -23,6 +15,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = awardItemSchema;
@@ -57,36 +50,16 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new award</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<AwardForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new award</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<AwardForm form={form} />
</SectionItemDialog>
);
}
@@ -109,36 +82,16 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing award</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<AwardForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing award</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<AwardForm form={form} />
</SectionItemDialog>
);
}
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { certificationItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
@@ -22,6 +14,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = certificationItemSchema;
@@ -56,36 +49,16 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new certification</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CertificationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new certification</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<CertificationForm form={form} />
</SectionItemDialog>
);
}
@@ -108,36 +81,16 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing certification</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CertificationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing certification</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<CertificationForm form={form} />
</SectionItemDialog>
);
}
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { coverLetterItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { RichInput } from "@/components/input/rich-input";
import { useDialogStore } from "@/dialogs/store";
@@ -19,6 +11,7 @@ import { useUpdateResumeData } from "@/features/resume/builder/draft";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = coverLetterItemSchema;
@@ -53,36 +46,17 @@ export function CreateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new cover letter</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CoverLetterForm form={form} />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new cover letter</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
singleColumn
>
<CoverLetterForm form={form} />
</SectionItemDialog>
);
}
@@ -110,36 +84,17 @@ export function UpdateCoverLetterDialog({ data }: DialogProps<"resume.sections.c
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing cover letter</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<CoverLetterForm form={form} />
<DialogFooter>
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing cover letter</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
singleColumn
>
<CoverLetterForm form={form} />
</SectionItemDialog>
);
}
@@ -39,6 +39,8 @@ const defaultValues: FormValues = {
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [],
};
@@ -81,6 +83,8 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
icon: data?.icon ?? "",
columns: data?.columns ?? 1,
hidden: data?.hidden ?? false,
keepTogether: data?.keepTogether ?? false,
startOnNewPage: data?.startOnNewPage ?? false,
items: data?.items ?? [],
},
validators: { onSubmit: formSchema },
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { educationItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Switch } from "@reactive-resume/ui/components/switch";
import { RichInput } from "@/components/input/rich-input";
@@ -22,6 +14,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = educationItemSchema;
@@ -59,36 +52,16 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new education</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<EducationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new education</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<EducationForm form={form} />
</SectionItemDialog>
);
}
@@ -111,36 +84,16 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing education</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<EducationForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing education</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<EducationForm form={form} />
</SectionItemDialog>
);
}
@@ -7,13 +7,6 @@ import { useStore } from "@tanstack/react-form";
import { AnimatePresence, Reorder, useDragControls } from "motion/react";
import { experienceItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { Switch } from "@reactive-resume/ui/components/switch";
@@ -26,6 +19,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = experienceItemSchema;
@@ -62,36 +56,16 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ExperienceForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new experience</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<ExperienceForm form={form} />
</SectionItemDialog>
);
}
@@ -114,36 +88,16 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing experience</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<ExperienceForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing experience</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<ExperienceForm form={form} />
</SectionItemDialog>
);
}
@@ -4,14 +4,6 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { interestItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
@@ -25,6 +17,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = interestItemSchema;
@@ -58,36 +51,16 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new interest</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<InterestForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new interest</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<InterestForm form={form} />
</SectionItemDialog>
);
}
@@ -110,36 +83,16 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing interest</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<InterestForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing interest</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<InterestForm form={form} />
</SectionItemDialog>
);
}
@@ -5,14 +5,6 @@ import { Trans } from "@lingui/react/macro";
import { PencilSimpleLineIcon, PlusIcon } from "@phosphor-icons/react";
import { useStore } from "@tanstack/react-form";
import { languageItemSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import {
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@reactive-resume/ui/components/dialog";
import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form";
import { Slider } from "@reactive-resume/ui/components/slider";
import { useDialogStore } from "@/dialogs/store";
@@ -21,6 +13,7 @@ import { useFormBlocker } from "@/hooks/use-form-blocker";
import { makeSectionItem } from "@/libs/resume/make-section-item";
import { createSectionItem, updateSectionItem } from "@/libs/resume/section-actions";
import { useAppForm, withForm } from "@/libs/tanstack-form";
import { SectionItemDialog } from "./section-item-dialog";
const formSchema = languageItemSchema;
@@ -53,36 +46,16 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PlusIcon />
<Trans>Create a new language</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<LanguageForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Create</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Create a new language</Trans>}
icon={<PlusIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Create</Trans>}
>
<LanguageForm form={form} />
</SectionItemDialog>
);
}
@@ -105,36 +78,16 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const isSubmitting = useStore(form.store, (state) => state.isSubmitting);
return (
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-x-2">
<PencilSimpleLineIcon />
<Trans>Update an existing language</Trans>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<form
className="grid gap-4 sm:grid-cols-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
void form.handleSubmit();
}}
>
<LanguageForm form={form} />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={requestClose}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Trans>Save Changes</Trans>
</Button>
</DialogFooter>
</form>
</DialogContent>
<SectionItemDialog
title={<Trans>Update an existing language</Trans>}
icon={<PencilSimpleLineIcon />}
onSubmit={() => void form.handleSubmit()}
onCancel={requestClose}
isSubmitting={isSubmitting}
submitLabel={<Trans>Save Changes</Trans>}
>
<LanguageForm form={form} />
</SectionItemDialog>
);
}

Some files were not shown because too many files have changed in this diff Show More