Compare commits

..
45 Commits
Author SHA1 Message Date
Amruth Pillai 582a6fb429 fix(web): preserve dialogs opened during close animations
Opening another dialog during the previous dialog's 300 ms close animation could clear the new dialog and its close handler. This caused the post-merge dashboard lifecycle test to lose the Duplicate Resume dialog after renaming a resume.

- Scope delayed cleanup to the original dialog and require it to remain closed.
- Add regression coverage for both open and closing replacement dialogs; both cases failed before the fix and pass afterward.
- Include the fix in the v5.3.1 release notes.

Validation: `pnpm check`, `pnpm typecheck`, `pnpm test`, and the focused dialog-store suite (12 passing tests).
2026-09-17 12:19:06 +02:00
Amruth Pillai 24d9e5fb5c chore: release v5.3.1
Prepare v5.3.1 with dashboard search and thumbnail improvements, PDF layout fixes, cover-letter integrations, and self-hosting updates.

- Bump the root version and add release notes with contributor credits, cover-letter REST migration instructions, and the new GHCR image path.
- Align the dashboard authentication plugin with Better Auth's fetch dependency to restore auth-client type inference.
- Regenerate the OpenAPI specification so published validation limits match runtime schemas.

Validation: `pnpm lingui:extract` (no missing translations), `pnpm check`, `pnpm typecheck`, and `pnpm test`.
2026-09-17 12:02:03 +02:00
Emanuele Tonello 2a2d08a8d2 fix(web): keep resume search local (#3510) 2026-09-17 00:14:42 +02:00
Emanuele TonelloandAmruth Pillai b42eb6ec06 fix: stop application search session refetches (#3507)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:13:33 +02:00
Emanuele TonelloandAmruth Pillai fbf1f8fbac docs(api): describe cover letter endpoints (#3509)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:13:21 +02:00
Emanuele TonelloandAmruth Pillai 232f48578b fix(web): cache dashboard resume thumbnails (#3506)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-17 00:12:05 +02:00
Emanuele TonelloandAmruth Pillai e6a6bf0e6a feat(mcp): add independent cover-letter tools (#3508)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-16 22:08:23 +02:00
Amruth Pillai 96c7142fbc chore: update dependencies 2026-09-16 18:36:50 +02:00
PerryLinkandAmruth Pillai 3c5908819c docs(self-hosting): add Kubernetes self-hosting guide (#3515)
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-16 18:03:44 +02:00
Lystran 3e129c9d9d fix(web): keep AI provider names untranslated across locales (#3516) 2026-09-16 17:40:43 +02:00
Amruth Pillai fd3494ccac docs: confirm repository migration and current-version redeployment 2026-09-12 11:29:04 +02:00
Amruth Pillai f89acb4368 chore: migrate repository links to reactive-resume/reactive-resume 2026-09-12 11:18:32 +02:00
Amruth Pillai 08e61ded7b Add powered by Blacksmith section to README
Added a powered by Blacksmith image and link to README.
2026-09-11 12:54:59 +02:00
Amruth Pillai f1d5c6bab4 ci: use reachable Ubuntu mirror for Playwright dependencies 2026-09-11 11:40:03 +02:00
Amruth Pillai e3717251cb docs: switch to verified public GHCR images 2026-09-11 11:21:49 +02:00
Amruth Pillai 30b21fa1e3 ci: verify anonymous container pulls before deployment 2026-09-11 11:17:10 +02:00
Amruth Pillai d77cb93494 fix: complete repository links and container publishing migration 2026-09-11 11:09:55 +02:00
Amruth Pillai ce996349fa Merge branch 'codex/repository-migration' 2026-09-11 10:53:08 +02:00
Amruth Pillai a62ee22f20 ci: use 32-vCPU Blacksmith runners 2026-09-11 09:51:52 +02:00
Amruth Pillai 0a4608bf9d ci: trigger 2026-09-11 03:26:42 +02:00
Amruth Pillai 9550910f17 revert: remove repository migration changes from main 2026-09-11 03:23:27 +02:00
Amruth Pillai 4076b1a523 ci: use Blacksmith runners and native multi-architecture Docker builds 2026-09-11 03:16:11 +02:00
Amruth Pillai 9699dbf2d8 ci: publish nightly images for amd64 and arm64 2026-09-11 03:02:26 +02:00
Amruth Pillai 31d6ee6251 chore: prepare repository migration and Docker Build Cloud publishing 2026-09-11 02:55:52 +02:00
Amruth Pillai d9fdf7a30a docs: announce planned repository and GHCR migration 2026-09-11 01:46:59 +02:00
Amruth Pillai 81341a107f fix(mcp): align tool annotations and descriptions with behavior 2026-09-11 00:31:58 +02:00
Amruth Pillai 3fc0896a34 fix(auth): honor client-requested token_endpoint_auth_method during DCR
Dynamic client registration unconditionally rewrote token_endpoint_auth_method
to "none" for every unauthenticated request, downgrading clients that asked for
client_secret_basic or client_secret_post to public clients. Those clients were
issued no client_secret but still authenticated at the token endpoint with the
method they registered, so the exchange failed with 401 invalid_client.
Connecting Composio to the MCP server hit this on every attempt.

Default to "none" only when the client omits the field, which keeps PKCE-only
MCP clients working while confidential clients receive a usable secret.
2026-09-10 12:47:52 +02:00
Amruth PillaiandClaude Fable 5.1 7aaed8e30b chore: pin Node.js runtime and make root TS strict mode explicit (#3501)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-09-10 11:15:38 +02:00
Amruth Pillai 730f795073 fix(pdf): pin @napi-rs/canvas to 1.0.8
1.0.9 rejects the Path2D objects pdfjs-dist 6.3.289 passes to fill/clip,
failing every raster test with "Value is none of these types `String`, `Path`".
2026-09-10 00:07:23 +02:00
Amruth Pillai dc8f9787a4 chore: update dependencies 2026-09-09 23:57:37 +02:00
Amruth Pillai 742526af53 chore: drop duplicated overrides and patchedDependencies from root package.json
pnpm reads audit overrides and patch mappings from pnpm-workspace.yaml, which
already carries both. The top-level package.json copies were npm-shaped fields
that pnpm never consults, and they had already drifted: the workspace file maps
'@react-pdf/textkit' unversioned while the package.json copy pinned 7.0.1.

pnpm install --frozen-lockfile still passes with pnpm-lock.yaml unchanged, and
all four patches remain applied at their recorded hashes, which is what shows
the removed block was inert.
2026-09-09 13:04:19 +02:00
Amruth Pillai 812d396120 test(pdf): compare raster baselines only on their authoring platform
Adding packages/pdf to CI turned it red on ubuntu-latest for two reasons,
neither of which is a real regression.

Rasterized pixels depend on the host font rasterizer. The chikorita, ditto
and all-template baselines differ on Linux in rasterSha256 alone: every page
count, item count and text coordinate is byte-identical to the macOS-authored
baseline. Compare the portable geometry on every host and the pixels only on
the platform the PNGs were generated on, so the characterization keeps
protecting layout without asserting another machine's antialiasing.

The picture-fit override case rasterizes twice and timed out at Vitest's 5s
default on a CI runner, with the date suite landing at 3.5-4.7s. Give the
package a 30s timeout rather than leaving every rendering test a runner
slowdown away from failing.

Verified on linux/amd64 in Docker: both files pass, 18/18.
2026-09-09 12:46:41 +02:00
Amruth Pillai 1106562169 test(pdf): refresh Lapras date-layout baseline and run all packages in CI
The Lapras section marginTop added in c0c658c0 shifted every date marker in
that template down the page, but the date-layout characterization baseline
was not regenerated, so packages/pdf has been failing on main since. The
delta is geometry-only: same page count (2), same text item count (87), same
markers, x and width unchanged; 17 markers move on y and the raster hashes
follow.

Nothing caught it because the workflow ran test:ci for only server and
tooling, leaving 17 packages uncovered. Replace that filter list with the
full workspace run so a package cannot silently lose coverage again, and
move it after the migration and storage steps that the api suite needs.

Serial execution is deliberate. Running the packages in parallel oversubscribes
the runner and starves the PDF rasterization and API rate-limit suites past
their timeouts; 19/19 pass consistently at --concurrency=1.
2026-09-09 12:33:04 +02:00
Amruth Pillai 607eafd3e8 chore(deps): bump ai-sdk, aws-sdk, react-email and tooling dependencies
Patch and minor bumps across the AI provider SDKs (@ai-sdk/*, ai),
@aws-sdk/client-s3, react-email/@react-email/ui, knip and jszip, with
pnpm-lock.yaml regenerated to match.

Also records the audit overrides and patched dependencies in the root
package.json alongside the existing pnpm-workspace.yaml entries.
2026-09-09 12:16:20 +02:00
Amruth PillaiandClaude Opus 5 ffe889b832 test: remove flaky slow-save navigation e2e test
The "stops waiting for a slow save while preserving late acknowledgements
and queued edits" test races Playwright's fake clock against real debounce
and network timing, and has failed intermittently on main and in PRs since
it landed. Six prior stabilization attempts, including bumping its timeout
to 60s, did not hold; the latest run on main still exceeded that budget.

The same behavior is covered deterministically with fake timers in
apps/web/src/features/resume/builder/draft.test.ts ("ends a stalled
navigation wait without aborting or discarding the pending save", plus
the queued-edit and pending-snapshot cases), so removing the e2e test
loses no coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018z9CKmSSEuS2UFMoqhHWtQ
2026-09-09 12:09:54 +02:00
Santhi Prakashcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>Amruth Pillai
51ac77295e fix(pdf): preserve list indentation on continuation pages (#3495) (#3497)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Amruth Pillai <im.amruth@gmail.com>
2026-09-09 11:55:48 +02:00
Diego Vega Centeno c0c658c00c fix(pdf): add marginTop to style of "section" in "Lapras" template (#3498) 2026-09-09 11:35:23 +02:00
Amruth Pillai 6416da28a4 feat(homepage): rebuild landing page and fix untranslated homepage strings in 16 locales (#3496) 2026-09-08 18:01:59 +02:00
Amruth Pillai 614a1ff9df test: wait for recovery state persistence 2026-09-07 23:06:19 +02:00
Amruth Pillai 4f60856706 refactor: remove unused UI and runtime scaffolding 2026-09-07 22:40:50 +02:00
Amruth Pillai ad91a0838c docs: remove build with ona 2026-09-07 10:00:10 +02:00
Amruth Pillai 15d6443b4f chore: update translations 2026-09-06 21:38:58 +02:00
Amruth Pillai 1f8c46b4f1 test: bump timeout for slow-save navigation e2e test
Default 30s budget was too tight for this multi-step test (resume
creation, warm-up save, fake-clock save juggling, re-navigation),
causing a CI timeout that surfaced as a generic closed-context error
rather than a real assertion failure.
2026-09-06 21:38:27 +02:00
Amruth Pillai de9a6dcfad fix: add missing twitter:url meta tag across social meta sites
Also fill in twitter:title/twitter:description on the ATS checker page,
matching its existing og: tags.
2026-09-06 21:27:40 +02:00
Amruth Pillai e19f706efd docs: expand v5.3.0 changelog
Expand the v5.3.0 changelog with shipped features, issue-linked fixes, OAuth consent details, and contributor credits.
2026-09-06 10:06:25 -07:00
261 changed files with 61916 additions and 32175 deletions
+2 -2
View File
@@ -20,8 +20,8 @@
"url": "https://rxresu.me"
},
"repositoryUrl": {
"url": "https://github.com/amruthpillai/reactive-resume",
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
"url": "https://github.com/reactive-resume/reactive-resume",
"wellKnown": "https://github.com/reactive-resume/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
},
"licenses": ["spdx:MIT"],
"tags": ["data", "design", "productivity", "resume-builder"]
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions and support
url: https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a
url: https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a
about: Get help with setup, configuration, and using Reactive Resume.
- name: Security vulnerability
url: https://github.com/amruthpillai/reactive-resume/security/advisories/new
url: https://github.com/reactive-resume/reactive-resume/security/advisories/new
about: Report security vulnerabilities privately.
+4
View File
@@ -0,0 +1,4 @@
self-hosted-runner:
labels:
- blacksmith-32vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404-arm
+2 -2
View File
@@ -13,7 +13,7 @@ env:
jobs:
autofix:
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
steps:
- name: Checkout Repository
@@ -32,7 +32,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "lts/*"
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install Dependencies
+1 -1
View File
@@ -10,7 +10,7 @@ concurrency:
jobs:
crowdin-sync:
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
permissions:
contents: write
+71 -30
View File
@@ -2,6 +2,11 @@ name: Build Docker Image
on:
workflow_dispatch:
inputs:
release:
description: Publish release aliases and redeploy production (false runs a cache-only build, then publishes a canary)
type: boolean
default: false
push:
branches:
- main
@@ -13,30 +18,39 @@ concurrency:
cancel-in-progress: true
env:
IMAGE: ${{ github.repository }}
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKER_IMAGE: docker.io/amruthpillai/reactive-resume
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
mode:
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
outputs:
nightly: ${{ steps.mode.outputs.nightly }}
release: ${{ steps.mode.outputs.release }}
matrix: ${{ steps.mode.outputs.matrix }}
canary: ${{ steps.mode.outputs.canary }}
steps:
- name: Determine publishing mode
id: mode
env:
EVENT_NAME: ${{ github.event_name }}
GIT_REF: ${{ github.ref }}
RELEASE: ${{ inputs.release }}
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
if [[ "$EVENT_NAME" == "push" && "$GIT_REF" == "refs/heads/main" ]]; then
echo "nightly=true" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
echo "canary=false" >> "$GITHUB_OUTPUT"
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$RELEASE" != "true" ]]; then
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=false" >> "$GITHUB_OUTPUT"
echo "canary=true" >> "$GITHUB_OUTPUT"
else
echo "nightly=false" >> "$GITHUB_OUTPUT"
echo "release=true" >> "$GITHUB_OUTPUT"
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
echo "canary=false" >> "$GITHUB_OUTPUT"
fi
build:
@@ -44,7 +58,14 @@ jobs:
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
matrix:
include:
- platform: linux/amd64
runner: blacksmith-32vcpu-ubuntu-2404
arch: amd64
- platform: linux/arm64
runner: blacksmith-32vcpu-ubuntu-2404-arm
arch: arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
@@ -59,12 +80,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Get version from package.json
id: version
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Blacksmith Docker Builder
uses: useblacksmith/setup-docker-builder@v2
with:
cache-key: Dockerfile-${{ matrix.arch }}
- name: Login to Docker Hub
uses: docker/login-action@v4
@@ -84,14 +103,22 @@ jobs:
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
${{ env.GHCR_IMAGE }}
${{ env.DOCKER_IMAGE }}
tags: |
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
- name: Cache-only smoke build
if: ${{ needs.mode.outputs.canary == 'true' }}
uses: useblacksmith/build-push-action@v2
with:
context: .
platforms: ${{ matrix.platform }}
outputs: type=cacheonly
- name: Build and Push by Digest
id: build
uses: docker/build-push-action@v7
uses: useblacksmith/build-push-action@v2
with:
context: .
sbom: true
@@ -101,8 +128,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha,scope=${{ env.IMAGE }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ env.IMAGE }}-${{ matrix.arch }}
- name: Export digest
run: |
@@ -123,7 +148,7 @@ jobs:
- mode
- build
timeout-minutes: 30
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
permissions:
contents: read
@@ -180,10 +205,11 @@ jobs:
uses: docker/metadata-action@v6
with:
images: |
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
${{ env.GHCR_IMAGE }}
${{ env.DOCKER_IMAGE }}
tags: |
type=sha,prefix=sha-
type=raw,value=canary-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ needs.mode.outputs.canary == 'true' }}
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
@@ -199,6 +225,8 @@ jobs:
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
FINAL_TAG="nightly"
elif [[ "${{ needs.mode.outputs.canary }}" == "true" ]]; then
FINAL_TAG="canary-${{ github.run_id }}-${{ github.run_attempt }}"
else
FINAL_TAG="v${{ steps.version.outputs.version }}"
fi
@@ -211,14 +239,13 @@ jobs:
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
$(printf '${{ env.GHCR_IMAGE }}@sha256:%s ' *)
# Get the digest of the multi-arch manifest
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
GHCR_DIGEST=$(docker buildx imagetools inspect ${{ env.GHCR_IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect ${{ env.DOCKER_IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
@@ -229,15 +256,29 @@ jobs:
- name: Sign images with Cosign
run: |
# Sign GHCR image
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
cosign sign --yes ${{ env.GHCR_IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
# Sign Docker Hub image
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
cosign sign --yes ${{ env.DOCKER_IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
docker buildx imagetools inspect ${{ env.GHCR_IMAGE }}:${{ steps.manifest.outputs.final_tag }}
docker buildx imagetools inspect ${{ env.DOCKER_IMAGE }}:${{ steps.manifest.outputs.final_tag }}
- name: Verify anonymous pulls on both architectures
run: |
set -euo pipefail
registry_config=$(mktemp -d)
trap 'rm -rf "$registry_config"' EXIT
# Prevent Docker from discovering a system credential helper.
printf '%s\n' '{"auths":{"ghcr.io":{},"https://index.docker.io/v1/":{}}}' > "$registry_config/config.json"
for image in "$GHCR_IMAGE" "$DOCKER_IMAGE"; do
for platform in linux/amd64 linux/arm64; do
docker --config "$registry_config" pull --quiet --platform "$platform" \
"$image:${{ steps.manifest.outputs.final_tag }}"
done
done
- name: Redeploy Stack
if: ${{ needs.mode.outputs.release == 'true' }}
+16 -6
View File
@@ -20,7 +20,7 @@ env:
jobs:
e2e:
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
services:
@@ -50,17 +50,20 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: "24"
node-version-file: ".nvmrc"
cache: "pnpm"
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Server and Tooling Tests
run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling
- name: Install Playwright Browser
run: pnpm exec playwright install --with-deps chromium
timeout-minutes: 10
run: |
# The runner's Ubuntu archive/security mirrors time out during dependency installation.
printf '%s\n' 'https://mirrors.edge.kernel.org/ubuntu/' | sudo tee /etc/apt/blacksmith-ubuntu-mirrors.txt > /dev/null
sudo find /etc/apt -type f \( -name '*.list' -o -name '*.sources' \) \
-exec sed -i -E 's#https?://(archive|us\.archive|security)\.ubuntu\.com/ubuntu#https://mirrors.edge.kernel.org/ubuntu#g' {} +
pnpm exec playwright install --with-deps chromium
- name: Generate Test Secrets
run: |
@@ -73,6 +76,13 @@ jobs:
- name: Run Database Migrations
run: pnpm db:migrate
# Runs every workspace package, not a hand-maintained filter list, so a package
# cannot silently lose coverage by being left out. Serial execution: the PDF
# rasterization and API rate-limit suites time out when several packages' Vitest
# thread pools oversubscribe the runner at once.
- name: Run Unit Tests
run: pnpm exec turbo run test:ci --concurrency=1
- name: Build
run: pnpm build
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs:
label:
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
steps:
- name: Checkout Repository
+1 -1
View File
@@ -10,7 +10,7 @@ permissions:
jobs:
stale:
runs-on: ubuntu-latest
runs-on: blacksmith-32vcpu-ubuntu-2404
steps:
- name: Close Inactive Issues Awaiting Information
+1
View File
@@ -0,0 +1 @@
24
+1 -1
View File
@@ -1,7 +1,7 @@
# Plan 27 Phase A diagnostic evidence
Date: 2026-09-06
Issue: [#3377](https://github.com/amruthpillai/reactive-resume/issues/3377)
Issue: [#3377](https://github.com/reactive-resume/reactive-resume/issues/3377)<br>
Revision: `2a4a1583b` (`origin/main` at run start)
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
+2 -2
View File
@@ -31,7 +31,7 @@ Boundaries: code/commits/PRs written normal.
## Agent skills
- Issues and specs: GitHub Issues for `amruthpillai/reactive-resume`. See `docs/agents/issue-tracker.md`.
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
- Domain docs use a multi-context layout. See `docs/agents/domain.md`.
## Overview
@@ -40,7 +40,7 @@ Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/w
Internal packages are source-consumed through `package.json` export maps pointing at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
Prerequisites: **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`), **pnpm 11.21.0** ([install guide](https://pnpm.io/installation)), and **Docker** for PostgreSQL (`sudo dockerd &` if the daemon isn't running).
Prerequisites: **Node.js 24** (pinned in `.nvmrc`; matches Dockerfile `ARG NODE_VERSION=24`), **pnpm 12.3.4** (pinned by `packageManager` in the root `package.json`; pnpm self-manages to it, so any recent pnpm can bootstrap — the Dockerfile's `ARG PNPM_VERSION` only picks the base image) ([install guide](https://pnpm.io/installation)), and **Docker** for PostgreSQL (`sudo dockerd &` if the daemon isn't running).
## Ownership map
+2 -1
View File
@@ -1,5 +1,6 @@
# syntax=docker/dockerfile:1.7
# Base image only; pnpm self-manages to the `packageManager` version in package.json.
ARG PNPM_VERSION=11.21.0
ARG NODE_VERSION=24
@@ -47,7 +48,7 @@ LABEL org.opencontainers.image.description="A free and open-source resume builde
LABEL org.opencontainers.image.vendor="Amruth Pillai"
LABEL org.opencontainers.image.url="https://rxresu.me"
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
LABEL org.opencontainers.image.source="https://github.com/reactive-resume/reactive-resume"
ENV NODE_ENV="production" \
PORT=3000 \
+4
View File
@@ -21,6 +21,10 @@ the catalog already uses one consistently):
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
AI provider names are brand names and stay in English: OpenAI, Anthropic Claude, Google
Gemini, Vercel AI Gateway, OpenRouter, Mistral AI, Cohere, xAI Grok, Groq, DeepSeek, Together.ai,
Fireworks, Cerebras, Perplexity, Ollama Cloud.
Template names are proper nouns and are never translated: Azurill, Bronzor, Chikorita, Ditgar,
Ditto, Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, Rhyhorn, Scizor.
+21 -13
View File
@@ -1,3 +1,9 @@
> [!IMPORTANT]
> **Repository moved:** Reactive Resume now lives at **[`reactive-resume/reactive-resume`](https://github.com/reactive-resume/reactive-resume)** on GitHub.
> **Docker Hub stays at `amruthpillai/reactive-resume`.** GHCR builds now publish to `ghcr.io/reactive-resume/reactive-resume`.
> Verified image tags: `latest`, `v5`, `v5.3`, and `v5.3.0` (AMD64 and ARM64). The current version was rebuilt and production redeployed for this rename; no new GitHub release or version bump was made. See [migration details](https://github.com/reactive-resume/reactive-resume/issues/3503).
> GitHub Sponsors and Open Collective funding links remain unchanged.
<div align="center">
<a href="https://rxresu.me">
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
@@ -14,9 +20,9 @@
</p>
<p>
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/github/package-json/v/reactive-resume/reactive-resume?style=flat-square" alt="Reactive Resume Version">
<img src="https://img.shields.io/github/stars/reactive-resume/reactive-resume?style=flat-square" alt="GitHub Stars">
<img src="https://img.shields.io/github/license/reactive-resume/reactive-resume?style=flat-square" alt="License" />
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
@@ -143,7 +149,7 @@ The quickest way to run Reactive Resume locally:
```bash
# Clone the repository
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
git clone --depth=1 https://github.com/reactive-resume/reactive-resume.git reactive-resume
cd reactive-resume
# Start all services
@@ -153,8 +159,6 @@ docker compose up -d
open http://localhost:3000
```
[![Build with Ona](https://ona.com/build-with-ona.svg)](https://app.ona.com/#https://github.com/amruthpillai/reactive-resume)
For detailed setup instructions, environment configuration, and self-hosting guides, see the [documentation](https://docs.rxresu.me).
## Tech Stack
@@ -199,7 +203,7 @@ Pull the latest image from Docker Hub or GitHub Container Registry:
docker pull amruthpillai/reactive-resume:latest
# GitHub Container Registry
docker pull ghcr.io/amruthpillai/reactive-resume:latest
docker pull ghcr.io/reactive-resume/reactive-resume:latest
```
See the [self-hosting guide](https://docs.rxresu.me/self-hosting/docker) for complete instructions.
@@ -221,17 +225,21 @@ Other ways to support:
- Star this repository
- Report reproducible bugs and suggest actionable features
- Help other users in [GitHub Discussions](https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a)
- Help other users in [GitHub Discussions](https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a)
- Improve documentation
- Help with translations
<a href="https://blacksmith.sh/">
<img width="368" height="126" alt="powered-by-blacksmith" src="https://github.com/user-attachments/assets/3e95d11b-4579-4082-8d0c-6b574f925625" />
</a>
## Star History
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
<a href="https://www.star-history.com/?repos=reactive-resume%2Freactive-resume&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
</picture>
</a>
@@ -247,7 +255,7 @@ Every contribution helps, whether it is a typo fix or a new feature.
See the [development setup guide](https://docs.rxresu.me/contributing/development) for how to run the project locally.
Maintainers review the [`status: needs triage` queue](https://github.com/amruthpillai/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
Maintainers review the [`status: needs triage` queue](https://github.com/reactive-resume/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
`status: needs info`.
+37 -37
View File
@@ -18,34 +18,34 @@
"#react-pdf-renderer": "@react-pdf/renderer"
},
"dependencies": {
"@ai-sdk/anthropic": "^4.0.49",
"@ai-sdk/cerebras": "^3.0.44",
"@ai-sdk/cohere": "^4.0.37",
"@ai-sdk/deepseek": "^3.0.39",
"@ai-sdk/fireworks": "^3.0.47",
"@ai-sdk/google": "^4.0.64",
"@ai-sdk/groq": "^4.0.37",
"@ai-sdk/mistral": "^4.0.39",
"@ai-sdk/openai": "^4.0.60",
"@ai-sdk/openai-compatible": "^3.0.44",
"@ai-sdk/perplexity": "^4.0.39",
"@ai-sdk/togetherai": "^3.0.45",
"@ai-sdk/xai": "^4.0.54",
"@aws-sdk/client-s3": "^3.1127.0",
"@better-auth/api-key": "^1.7.3",
"@better-auth/drizzle-adapter": "^1.7.3",
"@better-auth/infra": "^0.4.5",
"@better-auth/oauth-provider": "^1.7.3",
"@better-auth/passkey": "^1.7.3",
"@ai-sdk/anthropic": "^4.0.54",
"@ai-sdk/cerebras": "^3.0.49",
"@ai-sdk/cohere": "^4.0.42",
"@ai-sdk/deepseek": "^3.0.45",
"@ai-sdk/fireworks": "^3.0.52",
"@ai-sdk/google": "^4.0.72",
"@ai-sdk/groq": "^4.0.42",
"@ai-sdk/mistral": "^4.0.44",
"@ai-sdk/openai": "^4.0.67",
"@ai-sdk/openai-compatible": "^3.0.49",
"@ai-sdk/perplexity": "^4.0.44",
"@ai-sdk/togetherai": "^3.0.50",
"@ai-sdk/xai": "^5.0.0",
"@aws-sdk/client-s3": "^3.1133.0",
"@better-auth/api-key": "^1.7.5",
"@better-auth/drizzle-adapter": "^1.7.5",
"@better-auth/infra": "^0.4.9",
"@better-auth/oauth-provider": "^1.7.5",
"@better-auth/passkey": "^1.7.5",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.1.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@orpc/client": "^1.15.0",
"@orpc/experimental-ratelimit": "^1.15.0",
"@orpc/json-schema": "^1.15.0",
"@orpc/openapi": "^1.15.0",
"@orpc/server": "^1.15.0",
"@orpc/zod": "^1.15.0",
"@orpc/client": "^1.15.1",
"@orpc/experimental-ratelimit": "^1.15.1",
"@orpc/json-schema": "^1.15.1",
"@orpc/openapi": "^1.15.1",
"@orpc/server": "^1.15.1",
"@orpc/zod": "^1.15.1",
"@react-pdf/hyphenate": "0.1.0",
"@react-pdf/renderer": "^4.9.0",
"@reactive-resume/api": "workspace:*",
@@ -58,10 +58,10 @@
"@sindresorhus/slugify": "^3.0.1",
"@t3-oss/env-core": "^0.13.11",
"@uiw/color-convert": "^2.10.3",
"ai": "^7.0.93",
"ai": "^7.0.102",
"bcrypt": "^6.0.0",
"better-auth": "1.7.3",
"cjk-regex": "^3.4.0",
"better-auth": "1.7.5",
"cjk-regex": "^3.5.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^8.0.2",
"drizzle-orm": "1.0.0-rc.4",
@@ -69,15 +69,15 @@
"es-toolkit": "^1.52.0",
"fast-json-patch": "^3.1.1",
"fast-png": "^8.0.0",
"hono": "^4.13.7",
"hono": "^4.13.8",
"jsonrepair": "^3.15.0",
"node-html-parser": "^9.0.3",
"nodemailer": "^10.0.0",
"node-html-parser": "^9.0.4",
"nodemailer": "^10.0.10",
"ollama-ai-provider-v2": "^4.0.1",
"pg": "^8.23.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.8",
"react-email": "^6.9.3",
"react": "^19.3.0",
"react-email": "^6.9.5",
"react-pdf-html": "^2.1.5",
"resumable-stream": "^2.2.12",
"sanitize-html": "^2.17.7",
@@ -86,17 +86,17 @@
"ts-pattern": "^5.9.0",
"unique-names-generator": "^4.7.1",
"uuid": "^14.0.2",
"zod": "^4.5.4"
"zod": "^4.6.5"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^26.4.1",
"@types/node": "^26.6.1",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@types/react": "^19.3.0",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"tsdown": "^0.23.0",
"tsx": "^4.23.13",
"typescript": "^7.0.2",
"vitest": "^5.0.0"
"vitest": "^5.0.1"
}
}
+36 -2
View File
@@ -53,13 +53,28 @@ describe("handleAuth", () => {
},
);
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
it("registers third-party https callbacks so remote MCP clients can complete DCR", async () => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
body: JSON.stringify({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] }),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(200);
expect(mocks.handler).toHaveBeenCalledOnce();
});
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
const { handleAuth } = await import("./auth");
const response = await handleAuth(
new Request("http://localhost:3001/api/auth/oauth2/register", {
method: "POST",
body: JSON.stringify({ redirect_uris: ["https://192.168.1.10/callback"] }),
headers: { "content-type": "application/json" },
}),
);
@@ -106,6 +121,25 @@ describe("handleAuth", () => {
},
);
it.each(["client_secret_basic", "client_secret_post"])(
"keeps an explicitly registered %s so the client receives a client secret",
async (method) => {
const { handleAuth } = await import("./auth");
await handleAuth(
new Request("http://localhost:3000/api/auth/oauth2/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
redirect_uris: ["https://example.com/callback"],
token_endpoint_auth_method: method,
}),
}),
);
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
await expect(forwarded.json()).resolves.toMatchObject({ token_endpoint_auth_method: method });
},
);
it.each([
{ redirect_uris: ["https://example.com/callback"] },
{ redirect_uris: ["http://localhost.evil.example/callback"] },
+5 -1
View File
@@ -82,8 +82,12 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
if (allLoopback) body.application_type = "native";
}
// MCP clients that authenticate with PKCE alone omit the method, and Better Auth
// would otherwise register them as `client_secret_basic`. Honor an explicit choice:
// forcing it to "none" issues no client secret, so the client's own Basic/post
// credentials are rejected at the token endpoint with 401 invalid_client.
if (!request.headers.get("authorization")) {
body.token_endpoint_auth_method = "none";
body.token_endpoint_auth_method ??= "none";
}
return new Request(url.toString(), {
@@ -156,6 +156,89 @@ describe.skipIf(!databaseURL)("MCP OAuth flow with PostgreSQL", () => {
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
expect((await handleAuth(tokenRequest())).status).toBe(400);
}, 30_000);
it("exchanges a code for a confidential client that registered client_secret_basic", async () => {
if (!databaseURL) return;
process.env.DATABASE_URL = databaseURL;
process.env.APP_URL = "http://localhost:33920";
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
const { handleAuth, handleOAuth } = await import("./auth");
const origin = process.env.APP_URL;
const redirectURI = "http://127.0.0.1:33921/callback";
const request = (path: string, body: object, cookie = "") =>
new Request(`${origin}/api/auth/${path}`, {
method: "POST",
headers: { "content-type": "application/json", origin, cookie },
body: JSON.stringify(body),
});
const registration = await handleAuth(
request("oauth2/register", {
client_name: "Confidential MCP client",
redirect_uris: [redirectURI],
token_endpoint_auth_method: "client_secret_basic",
}),
);
expect(registration.status, await registration.clone().text()).toBe(201);
const client = await registration.json();
// Downgrading this to a public client leaves the client without a secret, and its
// Basic-authenticated token exchange then fails with 401 invalid_client.
expect(client.token_endpoint_auth_method).toBe("client_secret_basic");
expect(client.client_secret).toBeTruthy();
const verifier = randomBytes(32).toString("base64url");
const query = new URLSearchParams({
client_id: client.client_id,
redirect_uri: redirectURI,
response_type: "code",
scope: "openid profile offline_access",
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
code_challenge_method: "S256",
resource: `${origin}/mcp`,
state: "opaque-state",
});
const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`));
const login = await handleOAuth(new Request(new URL(authorize.headers.get("location") ?? "", origin)));
const callbackURL = new URL(login.headers.get("location") ?? "", origin).searchParams.get("callbackURL");
const unique = randomBytes(6).toString("hex");
const signup = await handleAuth(
request("sign-up/email", {
name: "Confidential Test",
email: `confidential-${unique}@example.com`,
username: `confidential-${unique}`,
password: "password123",
}),
);
expect(signup.status, await signup.clone().text()).toBe(200);
const cookie = signup.headers
.getSetCookie()
.map((value) => value.split(";", 1)[0])
.join("; ");
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
const oauth_query = new URL(callback.headers.get("location") ?? "", origin).search.slice(1);
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
expect(accepted.status, await accepted.clone().text()).toBe(200);
const code = new URL((await accepted.json()).url).searchParams.get("code");
const tokenResponse = await handleAuth(
new Request(`${origin}/api/auth/oauth2/token`, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
authorization: `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code ?? "",
redirect_uri: redirectURI,
code_verifier: verifier,
resource: `${origin}/mcp`,
}),
}),
);
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
await expect(tokenResponse.json()).resolves.toMatchObject({ token_type: "Bearer" });
}, 30_000);
it.each(["login", "max-age", "create"])(
"requires fresh authentication for %s without looping",
async (mode) => {
+51
View File
@@ -1,3 +1,4 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
@@ -16,6 +17,11 @@ type GeneratedSpecView = {
Record<
string,
{
tags?: string[];
operationId?: string;
summary?: string;
description?: string;
responses?: Record<string, { description?: string }>;
requestBody?: {
content?: Record<string, { schema?: unknown }>;
};
@@ -72,6 +78,51 @@ function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
}
describe("generateOpenApiSpec", () => {
it("documents all cover-letter procedures with REST metadata", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const expected = [
["get", "/cover-letters", "listCoverLetters", "List cover letters", "200"],
["get", "/cover-letters/{id}", "getCoverLetter", "Get cover letter by ID", "200"],
["post", "/cover-letters", "createCoverLetter", "Create a cover letter", "200"],
["put", "/cover-letters/{id}", "updateCoverLetter", "Update a cover letter", "200"],
["post", "/cover-letters/{id}/refresh-style", "refreshCoverLetterStyle", "Refresh cover letter style", "200"],
["post", "/cover-letters/{id}/duplicate", "duplicateCoverLetter", "Duplicate a cover letter", "200"],
["delete", "/cover-letters/{id}", "deleteCoverLetter", "Delete a cover letter", "200"],
["post", "/cover-letters/from-resume", "copyEmbeddedCoverLetter", "Copy an embedded cover letter", "200"],
["get", "/cover-letters/{id}/export", "exportCoverLetter", "Export a cover letter", "200"],
["post", "/cover-letters/import", "importCoverLetter", "Import a cover letter", "200"],
] as const;
for (const [method, path, operationId, summary, successStatus] of expected) {
const operation = spec.paths?.[path]?.[method];
expect(operation).toMatchObject({
tags: ["Cover Letters"],
operationId,
summary,
description: expect.any(String),
responses: { [successStatus]: { description: expect.any(String) } },
});
}
});
it("keeps published cover-letter operations in sync with the runtime spec", async () => {
const published = JSON.parse(
await readFile(new URL("../../../../docs/spec.json", import.meta.url), "utf8"),
) as GeneratedSpecView;
const runtime = await generateSpec();
const coverLetterPaths = (spec: GeneratedSpecView) =>
Object.fromEntries(
Object.entries(spec.paths ?? {}).filter(
([path]) => path.startsWith("/cover-letters") || path.startsWith("/coverLetters/"),
),
);
const publishedPaths = coverLetterPaths(published);
const runtimePaths = coverLetterPaths(runtime as GeneratedSpecView);
expect(Object.keys(publishedPaths).sort()).toEqual(Object.keys(runtimePaths).sort());
expect(publishedPaths).toEqual(runtimePaths);
});
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
+1 -1
View File
@@ -84,7 +84,7 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
title: "Reactive Resume",
version,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
license: { name: "MIT", url: "https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${appUrl}/api/openapi` }],
+2 -2
View File
@@ -127,13 +127,13 @@ function createRootSeoMarkup(canonicalUrl: string) {
price: "0",
priceCurrency: "USD",
},
codeRepository: "https://github.com/amruthpillai/reactive-resume",
codeRepository: "https://github.com/reactive-resume/reactive-resume",
},
{
"@type": "Project",
name: "Reactive Resume",
url: canonicalUrl,
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
sameAs: ["https://github.com/reactive-resume/reactive-resume"],
},
{
"@type": "FAQPage",
+749 -307
View File
File diff suppressed because it is too large Load Diff
+750 -308
View File
File diff suppressed because it is too large Load Diff
+748 -306
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+780 -338
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+750 -308
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+748 -306
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+745 -303
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+762 -320
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+768 -326
View File
File diff suppressed because it is too large Load Diff
+748 -306
View File
File diff suppressed because it is too large Load Diff
+772 -330
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+752 -310
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+787 -345
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+776 -334
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+748 -306
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+762 -320
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+748 -306
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+794 -352
View File
File diff suppressed because it is too large Load Diff
+757 -315
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+746 -304
View File
File diff suppressed because it is too large Load Diff
+753 -311
View File
File diff suppressed because it is too large Load Diff
+749 -307
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+747 -305
View File
File diff suppressed because it is too large Load Diff
+725 -283
View File
File diff suppressed because it is too large Load Diff
+37 -37
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"build": "rimraf dist && vite build",
"build": "vite build",
"dev": "vite dev",
"serve": "vite preview",
"start": "vite preview",
@@ -16,28 +16,29 @@
"lingui:extract": "lingui extract --clean --overwrite"
},
"dependencies": {
"@ai-sdk/react": "^4.0.96",
"@ai-sdk/react": "^4.0.105",
"@base-ui/react": "^1.8.0",
"@better-auth/api-key": "^1.7.3",
"@better-auth/infra": "^0.4.5",
"@better-auth/oauth-provider": "^1.7.3",
"@better-auth/passkey": "^1.7.3",
"@better-auth/api-key": "^1.7.5",
"@better-auth/infra": "^0.4.9",
"@better-auth/oauth-provider": "^1.7.5",
"@better-auth/passkey": "^1.7.5",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.11.0",
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.4",
"@codemirror/view": "^6.43.11",
"@codemirror/state": "^6.7.5",
"@codemirror/view": "^6.43.12",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lingui/core": "^6.6.0",
"@lingui/react": "^6.6.0",
"@orpc/client": "^1.15.0",
"@orpc/server": "^1.15.0",
"@orpc/tanstack-query": "^1.15.0",
"@fontsource-variable/manrope": "^5.3.0",
"@lingui/core": "^6.7.0",
"@lingui/react": "^6.7.0",
"@orpc/client": "^1.15.1",
"@orpc/server": "^1.15.1",
"@orpc/tanstack-query": "^1.15.1",
"@phosphor-icons/react": "^2.1.10",
"@react-pdf/renderer": "^4.9.0",
"@reactive-resume/ai": "workspace:*",
@@ -54,11 +55,11 @@
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-form": "^1.33.5",
"@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-router": "^1.170.32",
"@tanstack/react-query": "^5.103.1",
"@tanstack/react-router": "^1.170.36",
"@tiptap/extension-color": "^3.31.3",
"@tiptap/extension-highlight": "^3.31.3",
"@tiptap/extension-heading": "^3.31.3",
"@tiptap/extension-highlight": "^3.31.3",
"@tiptap/extension-paragraph": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-text-align": "^3.31.3",
@@ -69,8 +70,8 @@
"@types/js-cookie": "^3.0.6",
"@uiw/color-convert": "^2.10.3",
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^7.0.93",
"better-auth": "1.7.3",
"ai": "^7.0.102",
"better-auth": "1.7.5",
"buffer": "^6.0.3",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.4",
@@ -78,13 +79,13 @@
"fuse.js": "^7.5.0",
"immer": "^11.1.18",
"js-cookie": "^3.0.8",
"motion": "^13.2.0",
"motion": "^13.4.0",
"pdfjs-dist": "6.3.289",
"pg": "^8.23.0",
"prettier": "^3.9.6",
"prettier": "^3.9.7",
"qrcode.react": "^4.2.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-easy-crop": "^6.2.3",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.4",
@@ -93,33 +94,32 @@
"remark-gfm": "^4.0.1",
"ts-pattern": "^5.9.0",
"usehooks-ts": "^3.1.1",
"zod": "^4.5.4",
"zod": "^4.6.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@babel/core": "^8.0.1",
"@lingui/babel-plugin-lingui-macro": "^6.6.0",
"@lingui/cli": "^6.6.0",
"@lingui/format-po": "^6.6.0",
"@lingui/vite-plugin": "^6.6.0",
"@babel/core": "^8.0.5",
"@lingui/babel-plugin-lingui-macro": "^6.7.0",
"@lingui/cli": "^6.7.0",
"@lingui/format-po": "^6.7.0",
"@lingui/vite-plugin": "^6.7.0",
"@reactive-resume/config": "workspace:*",
"@rolldown/plugin-babel": "^0.2.3",
"@rolldown/plugin-babel": "^0.2.4",
"@shadcn/helpers": "^0.2.0",
"@tanstack/devtools-vite": "^0.8.5",
"@tanstack/react-devtools": "^0.10.12",
"@tanstack/react-query-devtools": "^5.102.8",
"@tanstack/react-router-devtools": "^1.167.1",
"@tanstack/router-plugin": "^1.168.35",
"@tanstack/react-query-devtools": "^5.103.1",
"@tanstack/react-router-devtools": "^1.167.2",
"@tanstack/router-plugin": "^1.168.38",
"@types/babel__core": "^7.20.5",
"@types/pg": "^8.23.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@typescript/native-preview": "7.0.0-dev.20260707.2",
"@vitejs/plugin-react": "^6.1.1",
"babel-plugin-macros": "^3.1.0",
"babel-plugin-react-compiler": "^1.0.0",
"rimraf": "^6.1.3",
"typescript": "^7.0.2",
"vite": "^8.2.2"
"vite": "^8.3.0"
}
}
+2 -2
View File
@@ -20,8 +20,8 @@
"url": "https://rxresu.me"
},
"repositoryUrl": {
"url": "https://github.com/amruthpillai/reactive-resume",
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
"url": "https://github.com/reactive-resume/reactive-resume",
"wellKnown": "https://github.com/reactive-resume/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
},
"licenses": ["spdx:MIT"],
"tags": ["data", "design", "productivity", "resume-builder"]
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

@@ -42,7 +42,7 @@ describe("GithubStarsButton", () => {
it("renders an anchor pointing at the project repo with rel=noopener noreferrer and target=_blank", () => {
renderButton();
const link = screen.getByRole("button") as HTMLAnchorElement;
expect(link.href).toBe("https://github.com/amruthpillai/reactive-resume");
expect(link.href).toBe("https://github.com/reactive-resume/reactive-resume");
expect(link.target).toBe("_blank");
expect(link.rel).toBe("noopener noreferrer");
});
@@ -20,7 +20,7 @@ export function GithubStarsButton() {
render={
<a
target="_blank"
href="https://github.com/amruthpillai/reactive-resume"
href="https://github.com/reactive-resume/reactive-resume"
aria-label={ariaLabel}
rel="noopener noreferrer"
>
@@ -24,7 +24,7 @@ describe("Copyright", () => {
it("renders the MIT license link", () => {
renderCopyright();
const link = screen.getByRole("link", { name: "MIT" });
expect(link.getAttribute("href")).toBe("https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE");
expect(link.getAttribute("href")).toBe("https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE");
expect(link.getAttribute("rel")).toBe("noopener noreferrer");
});
+1 -1
View File
@@ -10,7 +10,7 @@ export function Copyright({ className, ...props }: Props) {
<Trans>
Licensed under{" "}
<a
href="https://github.com/AmruthPillai/Reactive-Resume/blob/main/LICENSE"
href="https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-2"
-3
View File
@@ -1,3 +0,0 @@
import z from "zod";
export const apiKeyDialogSchemas = [z.object({ type: z.literal("api-key.create"), data: z.undefined() })] as const;
@@ -19,6 +19,7 @@ import {
} 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 { OTPField } from "@reactive-resume/ui/components/otp-field";
import { toast } from "@reactive-resume/ui/components/toast";
import { useFormBlocker } from "@/hooks/use-form-blocker";
import { authClient } from "@/libs/auth/client";
@@ -287,14 +288,13 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">)
<FormItem hasError={field.state.meta.isTouched && field.state.meta.errors.length > 0}>
<FormControl
render={
<Input
type="number"
maxLength={6}
className="max-w-xs"
<OTPField
length={6}
autoSubmit
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
onValueChange={field.handleChange}
/>
}
/>
-7
View File
@@ -1,7 +0,0 @@
import z from "zod";
export const authDialogSchemas = [
z.object({ type: z.literal("auth.change-password"), data: z.undefined() }),
z.object({ type: z.literal("auth.two-factor.enable"), data: z.undefined() }),
z.object({ type: z.literal("auth.two-factor.disable"), data: z.undefined() }),
] as const;
@@ -189,7 +189,7 @@ describe("ImportResumeDialog — detected files without MIME metadata", () => {
});
describe("ImportResumeDialog — Set up a provider", () => {
// https://github.com/amruthpillai/reactive-resume/issues/3307
// https://github.com/reactive-resume/reactive-resume/issues/3307
it("confirms before leaving instead of navigating behind the dialog", async () => {
renderDialog();
const link = await selectWordFile();
-152
View File
@@ -1,152 +0,0 @@
import z from "zod";
import {
awardItemSchema,
certificationItemSchema,
coverLetterItemSchema,
customSectionSchema,
educationItemSchema,
experienceItemSchema,
interestItemSchema,
languageItemSchema,
profileItemSchema,
projectItemSchema,
publicationItemSchema,
referenceItemSchema,
skillItemSchema,
summaryItemSchema,
volunteerItemSchema,
} from "@reactive-resume/schema/resume/data";
export const resumeDialogSchemas = [
z.object({ type: z.literal("resume.create"), data: z.undefined() }),
z.object({
type: z.literal("resume.update"),
data: z.object({ id: z.string(), name: z.string(), slug: z.string(), tags: z.array(z.string()) }),
}),
z.object({ type: z.literal("resume.import"), data: z.undefined() }),
z.object({
type: z.literal("resume.duplicate"),
data: z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
tags: z.array(z.string()),
shouldRedirect: z.boolean().optional(),
}),
}),
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
z.object({
type: z.literal("resume.sections.profiles.create"),
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.profiles.update"),
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.experience.create"),
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.experience.update"),
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.education.create"),
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.education.update"),
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.projects.create"),
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.projects.update"),
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.skills.create"),
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.skills.update"),
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.languages.create"),
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.languages.update"),
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.awards.create"),
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.awards.update"),
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.certifications.create"),
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.certifications.update"),
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.publications.create"),
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.publications.update"),
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.interests.create"),
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.interests.update"),
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.volunteer.create"),
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.volunteer.update"),
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.references.create"),
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.references.update"),
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.summary.create"),
data: z.object({ item: summaryItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.summary.update"),
data: z.object({ item: summaryItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.cover-letter.create"),
data: z.object({ item: coverLetterItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.cover-letter.update"),
data: z.object({ item: coverLetterItemSchema, customSectionId: z.string().optional() }),
}),
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
] as const;
+51 -12
View File
@@ -1,19 +1,58 @@
import z from "zod";
import { apiKeyDialogSchemas } from "./api-key/schema";
import { authDialogSchemas } from "./auth/schema";
import { resumeDialogSchemas } from "./resume/schema";
import type { CoverLetterItem, CustomSection, SectionItem, SummaryItem } from "@reactive-resume/schema/resume/data";
export const dialogSchemaRegistries = [
{ domain: "auth", schemas: authDialogSchemas },
{ domain: "api-key", schemas: apiKeyDialogSchemas },
{ domain: "resume", schemas: resumeDialogSchemas },
] as const;
type EmptyDialog<T extends string> = { [K in T]: { type: K; data?: undefined } }[T];
const dialogSchemaEntries = [...authDialogSchemas, ...apiKeyDialogSchemas, ...resumeDialogSchemas] as const;
type SectionItems = {
profiles: SectionItem<"profiles">;
experience: SectionItem<"experience">;
education: SectionItem<"education">;
projects: SectionItem<"projects">;
skills: SectionItem<"skills">;
languages: SectionItem<"languages">;
interests: SectionItem<"interests">;
awards: SectionItem<"awards">;
certifications: SectionItem<"certifications">;
publications: SectionItem<"publications">;
volunteer: SectionItem<"volunteer">;
references: SectionItem<"references">;
summary: SummaryItem;
"cover-letter": CoverLetterItem;
};
export const dialogTypeSchema = z.discriminatedUnion("type", dialogSchemaEntries);
type SectionDialog = {
[T in keyof SectionItems]:
| {
type: `resume.sections.${T}.create`;
data?: { item?: SectionItems[T]; customSectionId?: string };
}
| {
type: `resume.sections.${T}.update`;
data: { item: SectionItems[T]; customSectionId?: string };
};
}[keyof SectionItems];
export type DialogSchema =
| EmptyDialog<
| "auth.change-password"
| "auth.two-factor.enable"
| "auth.two-factor.disable"
| "api-key.create"
| "resume.create"
| "resume.import"
| "resume.template.gallery"
>
| {
type: "resume.update";
data: { id: string; name: string; slug: string; tags: string[] };
}
| {
type: "resume.duplicate";
data: { id: string; name: string; slug: string; tags: string[]; shouldRedirect?: boolean };
}
| SectionDialog
| { type: "resume.sections.custom.create"; data?: CustomSection }
| { type: "resume.sections.custom.update"; data: CustomSection };
export type DialogSchema = z.infer<typeof dialogTypeSchema>;
export type DialogType = DialogSchema["type"];
export type DialogData<T extends DialogType> = Extract<DialogSchema, { type: T }>["data"];
+23 -8
View File
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dialogSchemaRegistries, dialogTypeSchema } from "./schemas";
import { useDialogStore } from "./store";
describe("useDialogStore", () => {
@@ -14,13 +13,6 @@ describe("useDialogStore", () => {
});
describe("openDialog", () => {
it("uses schema entries from domain registries", () => {
expect(dialogSchemaRegistries.map((registry) => registry.domain)).toEqual(["auth", "api-key", "resume"]);
expect(dialogTypeSchema.options).toHaveLength(
dialogSchemaRegistries.reduce((total, registry) => total + registry.schemas.length, 0),
);
});
it("opens a dialog and sets activeDialog", () => {
useDialogStore.getState().openDialog("api-key.create", undefined);
@@ -71,6 +63,29 @@ describe("useDialogStore", () => {
vi.advanceTimersByTime(300);
expect(useDialogStore.getState().activeDialog).toBeNull();
});
it.each([false, true])("preserves a replacement dialog and its own closing delay (closing=%s)", (closing) => {
useDialogStore.getState().openDialog("api-key.create", undefined);
useDialogStore.getState().closeDialog();
vi.advanceTimersByTime(100);
useDialogStore.getState().openDialog("resume.create", undefined);
const onBeforeClose = () => false;
useDialogStore.getState().setOnBeforeClose(onBeforeClose);
if (closing) useDialogStore.getState().closeDialog();
vi.advanceTimersByTime(200);
expect(useDialogStore.getState()).toMatchObject({
open: !closing,
activeDialog: { type: "resume.create" },
onBeforeClose,
});
if (!closing) useDialogStore.getState().closeDialog();
vi.advanceTimersByTime(300);
expect(useDialogStore.getState().activeDialog).toBeNull();
expect(useDialogStore.getState().onBeforeClose).toBeNull();
});
});
describe("onOpenChange", () => {
+3
View File
@@ -40,8 +40,11 @@ export const useDialogStore = create<DialogStore>((set) => ({
onBeforeClose: null,
}),
closeDialog: () => {
const closingDialog = useDialogStore.getState().activeDialog;
set({ open: false });
setTimeout(() => {
const state = useDialogStore.getState();
if (state.open || state.activeDialog !== closingDialog) return;
set({ activeDialog: null, onBeforeClose: null });
}, 300);
},
@@ -6,6 +6,7 @@ import z from "zod";
import { Button } from "@reactive-resume/ui/components/button";
import { FormControl, FormItem, FormMessage } from "@reactive-resume/ui/components/form";
import { Input } from "@reactive-resume/ui/components/input";
import { OTPField } from "@reactive-resume/ui/components/otp-field";
import { toast } from "@reactive-resume/ui/components/toast";
import { authClient } from "@/libs/auth/client";
import { useAppForm } from "@/libs/tanstack-form";
@@ -98,15 +99,26 @@ function TwoFactorVerificationPage({ backupCode = false }: TwoFactorVerification
>
<FormControl
render={
<Input
type={backupCode ? "text" : "number"}
maxLength={backupCode ? 10 : 6}
className="max-w-xs"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
backupCode ? (
<Input
type="text"
maxLength={10}
className="max-w-xs"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(event) => field.handleChange(event.target.value)}
/>
) : (
<OTPField
length={6}
autoSubmit
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onValueChange={field.handleChange}
/>
)
}
/>
<FormMessage errors={field.state.meta.errors} />
@@ -0,0 +1,6 @@
/* Keyframes cannot be expressed as a utility; referenced via animate-[home-ats-spin_...] in ats-playground.tsx. */
@keyframes home-ats-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,118 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import AtsPlayground from "./ats-playground";
const { parse } = vi.hoisted(() => ({ parse: vi.fn() }));
vi.mock("@/features/ats-checker/run-ats-check", () => ({ runAtsCheck: parse }));
i18n.loadAndActivate({ locale: "en", messages: {} });
function Playground() {
return (
<I18nProvider i18n={i18n}>
<AtsPlayground />
</I18nProvider>
);
}
beforeEach(() => {
parse.mockReset();
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation(async () => new Response(new Blob(["%PDF sample"]))),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("reads the bundled PDF only after activation and displays the parser's actual text", async () => {
parse.mockResolvedValue({ fullText: "Jordan Lee\nWork experience\nBuilt accessible products." });
const view = render(<Playground />);
expect(fetch).not.toHaveBeenCalled();
fireEvent.click(view.getByRole("button", { name: "Read this resume" }));
await waitFor(() => expect(view.getByLabelText("Extracted text")).toHaveTextContent("Built accessible products."));
expect(fetch).toHaveBeenCalledWith(
"/templates/pdf/rhyhorn.pdf",
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(parse.mock.calls[0][0].name).toBe("rhyhorn.pdf");
});
it("allows retry after a failed read and explains PDFs with no text", async () => {
parse.mockRejectedValueOnce(Object.assign(new Error(), { name: "PdfPasswordRequiredError" }));
parse.mockResolvedValueOnce({ fullText: " " });
const view = render(<Playground />);
fireEvent.click(view.getByRole("button", { name: "Read this resume" }));
await waitFor(() => expect(view.getByRole("alert")).toHaveTextContent("password protected"));
fireEvent.click(view.getByRole("button", { name: "Try again" }));
await waitFor(() => expect(view.getByLabelText("Extracted text")).toHaveTextContent("No readable text"));
});
it("recovers from sample fetch failure and discards a read cancelled before parsing", async () => {
vi.mocked(fetch).mockResolvedValueOnce(new Response(null, { status: 503 }));
let finishFetch: (response: Response) => void = () => {};
vi.mocked(fetch).mockImplementationOnce(
() =>
new Promise((resolve) => {
finishFetch = resolve;
}),
);
const view = render(<Playground />);
fireEvent.click(view.getByRole("button", { name: "Read this resume" }));
await waitFor(() => expect(view.getByRole("alert")).toHaveTextContent("try again"));
fireEvent.click(view.getByRole("button", { name: "Try again" }));
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
await act(async () => finishFetch(new Response(new Blob(["%PDF sample"]))));
expect(parse).not.toHaveBeenCalled();
expect(view.getByRole("button", { name: "Read this resume" })).toBeEnabled();
expect(view.getByLabelText("Extracted text")).toHaveTextContent("Read the sample");
});
it("rejects other file types and shows the selected file instead of the sample image", async () => {
parse.mockResolvedValue({ fullText: "My uploaded resume" });
const view = render(<Playground />);
fireEvent.change(view.getByLabelText("Try your PDF"), {
target: { files: [new File(["text"], "notes.txt", { type: "text/plain" })] },
});
expect(view.getByRole("alert")).toHaveTextContent("Choose a PDF");
expect(parse).not.toHaveBeenCalled();
fireEvent.change(view.getByLabelText("Try your PDF"), { target: { files: [new File(["%PDF"], "my-resume.PDF")] } });
await waitFor(() => expect(view.getByLabelText("Extracted text")).toHaveTextContent("My uploaded resume"));
expect(view.getByText("my-resume.PDF")).toBeInTheDocument();
expect(view.queryByAltText("Rhyhorn sample resume")).not.toBeInTheDocument();
expect(fetch).not.toHaveBeenCalled();
});
it("ignores stale parser results after a newer file or reset and aborts on unmount", async () => {
let finishOld: (value: { fullText: string }) => void = () => {};
parse.mockImplementationOnce(
() =>
new Promise((resolve) => {
finishOld = resolve;
}),
);
parse.mockResolvedValueOnce({ fullText: "Current resume" });
const view = render(<Playground />);
fireEvent.click(view.getByRole("button", { name: "Read this resume" }));
await waitFor(() => expect(parse).toHaveBeenCalledTimes(1));
const oldSignal = parse.mock.calls[0][1].signal;
fireEvent.change(view.getByLabelText("Try your PDF"), { target: { files: [new File(["%PDF"], "current.pdf")] } });
await waitFor(() => expect(view.getByLabelText("Extracted text")).toHaveTextContent("Current resume"));
await act(async () => finishOld({ fullText: "Stale resume" }));
expect(oldSignal.aborted).toBe(true);
expect(view.getByLabelText("Extracted text")).not.toHaveTextContent("Stale resume");
fireEvent.click(view.getByRole("button", { name: "Reset" }));
expect(view.getByLabelText("Extracted text")).not.toHaveTextContent("Current resume");
parse.mockImplementationOnce(() => new Promise(() => {}));
fireEvent.click(view.getByRole("button", { name: "Read this resume" }));
await waitFor(() => expect(parse).toHaveBeenCalledTimes(3));
const signal = parse.mock.calls[2][1].signal;
view.unmount();
expect(signal.aborted).toBe(true);
});
@@ -0,0 +1,198 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, ArrowUpRightIcon, FilePdfIcon, SpinnerGapIcon } from "@phosphor-icons/react";
import { useEffect, useId, useRef, useState } from "react";
import "./ats-playground.css";
const focusRing = "focus-visible:outline-2 focus-visible:outline-(--home-accent) focus-visible:outline-offset-4";
const message = "max-w-[300px] text-[14px] leading-[1.8]";
export default function AtsPlayground() {
const inputId = useId();
const controllerRef = useRef<AbortController | null>(null);
const [file, setFile] = useState<File | null>(null);
const [phase, setPhase] = useState<"idle" | "reading" | "done" | "error">("idle");
const [text, setText] = useState("");
const [error, setError] = useState("");
useEffect(() => () => controllerRef.current?.abort(), []);
const reset = () => {
controllerRef.current?.abort();
setFile(null);
setText("");
setError("");
setPhase("idle");
};
const read = async (selected: File | null) => {
controllerRef.current?.abort();
const controller = new AbortController();
controllerRef.current = controller;
setFile(selected);
setText("");
setError("");
if (selected && selected.type !== "application/pdf" && !/\.pdf$/i.test(selected.name)) {
setError(t`Choose a PDF file to read its text.`);
setPhase("error");
return;
}
setPhase("reading");
try {
let source = selected;
if (!source) {
const response = await fetch("/templates/pdf/rhyhorn.pdf", { signal: controller.signal });
if (!response.ok) throw new Error("Sample PDF unavailable");
source = new File([await response.blob()], "rhyhorn.pdf", { type: "application/pdf" });
}
if (controller.signal.aborted) return;
const { runAtsCheck } = await import("@/features/ats-checker/run-ats-check");
if (controller.signal.aborted) return;
const result = await runAtsCheck(source, { signal: controller.signal });
if (controller.signal.aborted) return;
setText(result.fullText);
setPhase("done");
} catch (caught) {
if (controller.signal.aborted) return;
const pdfErrors: Record<string, string> = {
PdfPasswordRequiredError: t`This PDF is password protected. Save an unprotected copy and try again.`,
PdfTooLargeError: t`This PDF is too large. Choose a file smaller than 25 MB.`,
PdfUnreadableError: t`This file could not be read as a PDF. Export a new copy and try again.`,
};
setError(
(caught instanceof Error && pdfErrors[caught.name]) ||
t`Something went wrong while reading this file. Please try again.`,
);
setPhase("error");
}
};
const isReading = phase === "reading";
return (
<div className="grid grid-cols-[minmax(180px,0.8fr)_auto_minmax(260px,1.25fr)] items-center gap-[38px] text-(--home-ink) max-[600px]:grid-cols-1 max-[900px]:grid-cols-[minmax(150px,0.8fr)_minmax(260px,1.2fr)] max-[600px]:gap-6 max-[900px]:gap-[25px]">
<div className="min-w-0 text-center max-[900px]:col-[1]">
<div className="transform-[perspective(1000px)_rotateY(9deg)_rotateZ(-5deg)] mx-auto mt-3 mb-[27px] aspect-[210/297] max-w-[235px] overflow-hidden bg-[#f5f4ef] text-[#414145] shadow-[12px_20px_36px_#0006] max-[600px]:max-w-[165px] max-[900px]:max-w-[180px]">
{file ? (
<div className="flex h-full flex-col items-center justify-center gap-[18px] text-[13px]">
<FilePdfIcon size={58} weight="thin" aria-hidden="true" />
<span>
<Trans>Your selected file</Trans>
</span>
</div>
) : (
<img
className="size-full object-cover"
src="/templates/jpg/rhyhorn.jpg"
alt={t`Rhyhorn sample resume`}
width="510"
height="720"
loading="lazy"
/>
)}
</div>
<p className="wrap-anywhere text-[14px]">{file?.name ?? "rhyhorn.pdf"}</p>
<span className="mt-[5px] block text-(--home-muted) text-[12px]">
{file ? t`Stays in your browser` : t`A real sample PDF`}
</span>
</div>
<div className="flex flex-col items-center gap-[15px] max-[900px]:col-[1] max-[600px]:row-auto max-[900px]:row-[2] max-[600px]:flex-row max-[600px]:flex-wrap max-[600px]:justify-center max-[600px]:gap-2.5">
<button
className={`motion-safe:active:not-disabled:not-focus-visible:transform-[scale(0.97)] flex min-w-[140px] flex-col items-center gap-[14px] rounded border border-(--home-line) bg-(--home-panel) px-4 py-5 text-(--home-ink) text-[13px] transition-[transform_140ms_cubic-bezier(0.23,1,0.32,1)] hover:not-disabled:border-(--home-accent) disabled:cursor-progress max-[900px]:flex-row max-[900px]:gap-2.5 max-[900px]:py-[13px] ${focusRing}`}
type="button"
disabled={isReading}
onClick={() => void read(file)}
>
{isReading ? (
<SpinnerGapIcon
className="animate-[home-ats-spin_900ms_linear_infinite] text-(--home-accent)"
size={22}
aria-hidden="true"
/>
) : (
<ArrowRightIcon className="text-(--home-accent)" size={22} aria-hidden="true" />
)}
{isReading
? t`Reading PDF`
: phase === "error"
? t`Try again`
: phase === "done"
? t`Read again`
: t`Read this resume`}
</button>
<label
className="relative inline-flex min-h-[44px] cursor-pointer items-center p-2 text-[13px] underline underline-offset-[5px] focus-within:outline-(--home-accent) focus-within:outline-2 focus-within:outline-offset-4"
htmlFor={inputId}
>
<Trans>Try your PDF</Trans>
<input
className="absolute inset-0 w-full cursor-pointer opacity-0"
id={inputId}
type="file"
accept="application/pdf,.pdf"
onChange={(event) => {
const selected = event.target.files?.[0];
event.target.value = "";
if (selected) void read(selected);
}}
/>
</label>
{phase !== "idle" && (
<button
className={`min-h-9 px-3 py-1.5 text-(--home-muted) text-[12px] ${focusRing}`}
type="button"
onClick={reset}
>
{isReading ? t`Cancel` : t`Reset`}
</button>
)}
</div>
<div className="min-w-0 overflow-hidden rounded border border-(--home-line) bg-(--home-panel) max-[600px]:col-[1] max-[900px]:col-[2] max-[600px]:row-auto max-[900px]:row-[1/3]">
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-2 border-(--home-line) border-b px-[22px] py-[18px] text-[13px]">
<span>
<Trans>Extracted text</Trans>
</span>
<span className="text-(--home-muted) text-[11px]" role="status">
{isReading
? t`Reading locally`
: phase === "done"
? text.trim()
? t`Text extracted`
: t`No text found`
: t`PDF text layer`}
</span>
</div>
<section
className="h-80 overflow-auto overscroll-contain p-[23px] focus-visible:outline-(--home-accent) focus-visible:outline-2 focus-visible:outline-offset-[-3px] max-[600px]:h-[280px] max-[600px]:p-5"
aria-label={t`Extracted text`}
aria-busy={isReading}
// biome-ignore lint/a11y/noNoninteractiveTabindex: The extracted text overflows and must be keyboard scrollable.
tabIndex={0}
>
{phase === "error" ? (
<p className={`${message} text-[#e1bda8]`} role="alert">
{error}
</p>
) : phase === "done" && text.trim() ? (
<pre className="wrap-anywhere whitespace-pre-wrap font-[IBM_Plex_Mono,monospace] text-[12px] leading-[1.9]">
{text}
</pre>
) : (
<p className={`${message} text-(--home-muted)`}>
{isReading
? t`Reading this PDF in your browser. Your file is not uploaded.`
: phase === "done"
? t`No readable text found. This PDF may contain scanned images. Export a PDF with selectable text and try again.`
: t`Read the sample to see its text without the layout. Or try a PDF of your own.`}
</p>
)}
</section>
<a
className={`flex items-center justify-between gap-[15px] border-(--home-line) border-t px-[22px] py-[18px] text-(--home-ink) text-[12px] hover:underline hover:underline-offset-4 ${focusRing}`}
href="/ats-checker"
>
<Trans>Open the full ATS checker</Trans> <ArrowUpRightIcon size={17} aria-hidden="true" />
</a>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
// Shared layout/typography utilities used across the homepage components.
export const wrap =
"mx-auto w-[min(1184px,calc(100%_-_112px))] max-[1100px]:w-[calc(100%_-_72px)] max-[900px]:w-[calc(100%_-_48px)] max-[540px]:w-[calc(100%_-_40px)]";
export const section = "scroll-mt-[30px] pt-[100px] pb-[112px] max-[900px]:py-[76px] max-[540px]:py-[61px]";
export const sectionHeading =
"mb-[50px] flex items-end justify-between gap-10 max-[900px]:items-start max-[900px]:gap-[30px] max-[540px]:mb-8 max-[540px]:flex-col max-[540px]:gap-[21px]";
export const sectionTitle =
"font-[Manrope_Variable,sans-serif] font-semibold text-[clamp(36px,4.2vw,57px)] leading-[1.1] tracking-[-0.057em] max-[900px]:text-[42px] max-[540px]:text-[40px]";
export const sectionText =
"max-w-[355px] pb-[3px] text-(--home-muted) text-[16px] leading-[1.7] max-[900px]:max-w-[275px] max-[900px]:text-[14px] max-[540px]:max-w-[340px]";
export const textLink =
"inline-flex min-h-[44px] items-center gap-[9px] text-[14px] underline-offset-[5px] hover:underline";
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { act, render, waitFor } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CommunityStats } from "./community-stats";
const getTotals = vi.hoisted(() => vi.fn());
const cachedAt = Date.UTC(2026, 8, 7, 12);
vi.mock("@/libs/orpc/client", () => ({
orpc: {
statistics: {
getTotals: {
queryOptions: (options: object) => ({ queryKey: ["totals"], queryFn: getTotals, ...options }),
},
},
},
}));
vi.mock("motion/react", async (importOriginal) => ({
...(await importOriginal<typeof import("motion/react")>()),
useInView: () => true,
useReducedMotion: () => true,
}));
beforeEach(() => {
getTotals.mockReset().mockResolvedValue({ users: 0, resumes: 999, cachedAt });
i18n.loadAndActivate({ locale: "en-US", messages: {} });
});
function renderStats() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return {
client,
...render(
<QueryClientProvider client={client}>
<I18nProvider i18n={i18n}>
<CommunityStats />
</I18nProvider>
</QueryClientProvider>,
),
};
}
it("uses the server cache time and keeps the last counts and timestamp when a refresh fails", async () => {
const { client, getByText, container } = renderStats();
await waitFor(() => expect(getByText("999", { selector: ".sr-only" })).toBeInTheDocument());
expect(getByText("0", { selector: ".sr-only" })).toBeInTheDocument();
expect(container.querySelector("time")).toHaveAttribute("datetime", new Date(cachedAt).toISOString());
const refreshedAt = cachedAt + 6 * 60 * 60 * 1000;
getTotals.mockResolvedValue({ users: 0, resumes: 1_000, cachedAt: refreshedAt });
await act(() => client.invalidateQueries({ queryKey: ["totals"] }));
expect(getTotals).toHaveBeenCalledTimes(2);
await waitFor(() => expect(getByText("1,000", { selector: ".sr-only" })).toBeInTheDocument());
expect(container.querySelector("time")).toHaveAttribute("datetime", new Date(refreshedAt).toISOString());
getTotals.mockRejectedValue(new Error("Offline"));
await act(() => client.invalidateQueries({ queryKey: ["totals"] }));
expect(getByText("1,000", { selector: ".sr-only" })).toBeInTheDocument();
expect(container.querySelector("time")).toHaveAttribute("datetime", new Date(refreshedAt).toISOString());
client.clear();
});
it("does not substitute an invented total when the first request fails", async () => {
getTotals.mockRejectedValue(new Error("Offline"));
const { client, getAllByText, getByText, queryByText, container } = renderStats();
await waitFor(() => expect(getAllByText("Total unavailable")).toHaveLength(2));
expect(getByText("Some totals are unavailable right now.")).toBeVisible();
expect(queryByText("0", { selector: ".sr-only" })).toBeNull();
expect(container.querySelector("time")).toBeNull();
client.clear();
});
@@ -0,0 +1,144 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { useQuery } from "@tanstack/react-query";
import { AnimatePresence, m, useInView, useReducedMotion } from "motion/react";
import { useRef } from "react";
import { cn } from "@reactive-resume/utils/style";
import { orpc } from "@/libs/orpc/client";
import { wrap } from "./classes";
// The statistics service caches these totals for six hours.
const refreshInterval = 6 * 60 * 60 * 1000;
export function CommunityStats() {
const { i18n } = useLingui();
const ref = useRef<HTMLElement>(null);
const entered = useInView(ref, { once: true, amount: 0.2 });
const { data, isError } = useQuery(
orpc.statistics.getTotals.queryOptions({
enabled: entered,
staleTime: refreshInterval,
refetchInterval: refreshInterval,
}),
);
const labels = [t`Users`, t`Resumes`];
const ids = ["users", "resumes"] as const;
const totals = data;
const cachedAt = totals?.cachedAt;
const cachedDate = cachedAt == null ? null : new Date(cachedAt);
const formattedDate = cachedDate ? i18n.date(cachedDate, { dateStyle: "medium", timeStyle: "short" }) : "";
return (
<section
ref={ref}
className={cn(wrap, "pt-12 pb-[54px] max-[600px]:pt-[34px] max-[600px]:pb-10")}
id="community"
aria-labelledby="community-title"
>
<div className="mb-8 max-[600px]:mb-6">
<h2
id="community-title"
className="font-[Manrope_Variable,sans-serif] font-semibold text-[26px] leading-[1.3] tracking-[-0.04em] max-[600px]:text-[24px]"
>
<Trans>You’re in good company.</Trans>
</h2>
</div>
<dl className="grid grid-cols-2 border-(--home-line) border-y pt-[30px] pb-[26px] max-[600px]:grid-cols-1 max-[600px]:gap-6 max-[600px]:py-6">
{ids.map((id, index) => (
<div
className="@container min-w-0 not-first:border-(--home-line) not-first:border-l not-first:pl-[12%] max-[600px]:not-first:border-t max-[600px]:not-first:border-l-0 max-[600px]:not-first:pt-6 max-[600px]:not-first:pl-0"
key={id}
>
<dt className="flex items-center gap-3 text-(--home-muted) text-[13px]">
<Mark id={id} />
{labels[index]}
</dt>
<dd className="mt-[18px] font-[Manrope_Variable,sans-serif] font-extrabold text-[clamp(44px,6.4vw,92px)] leading-[1.1] tracking-[-0.09em] max-[600px]:mt-[11px] max-[600px]:text-[clamp(40px,14cqw,64px)]">
{totals ? (
<RollingTotal value={totals[id]} entered={entered} />
) : (
<span className="text-[#636367]">
<span aria-hidden="true">···</span>
<span className="sr-only">{isError ? t`Total unavailable` : t`Loading total`}</span>
</span>
)}
</dd>
</div>
))}
</dl>
<p className="mt-[14px] text-[#85858c] text-[11px]">
{cachedDate ? (
<Trans>
As of <time dateTime={cachedDate.toISOString()}>{formattedDate}</time>.
</Trans>
) : isError && !totals ? (
t`Some totals are unavailable right now.`
) : (
t`Totals are refreshed periodically.`
)}
</p>
</section>
);
}
type MarkProps = { id: "users" | "resumes" };
function Mark({ id }: MarkProps) {
const users = id === "users";
const sheet = cn(
"absolute left-[7px] border bg-(--home-bg)",
users ? "top-[7px] h-3 w-3 rounded-full" : "top-[3px] h-[19px] w-[14px] rounded-sm",
);
return (
<span className="relative block h-[25px] w-7" aria-hidden="true">
<i className={`${sheet} -translate-x-[5px] translate-y-[2px] -rotate-[13deg] border-[#68606f]`} />
<i className={cn(sheet, users ? "border-[#c7b5a5]" : "border-[#baafc4]")} />
<i className={`${sheet} translate-x-[5px] -translate-y-px rotate-12 border-[#9c8caa]`} />
</span>
);
}
type RollingTotalProps = { value: number; entered: boolean };
function RollingTotal({ value, entered }: RollingTotalProps) {
const { i18n } = useLingui();
const reducedMotion = useReducedMotion();
const formatted = i18n.number(value, { maximumFractionDigits: 0 });
const characters = Array.from(formatted);
return (
<>
<span className="sr-only">{formatted}</span>
<span className="inline-flex [direction:ltr] [unicode-bidi:isolate]" aria-hidden="true">
{characters.map((character, index) => (
<span
// The roll needs a vertical mask, but overflow-clip also cuts horizontally, and the negative
// tracking shrinks each glyph's box below its ink width. Padding widens the clip box; the
// matching negative margin keeps the tightened spacing.
className="relative -mx-[0.12em] inline-block h-[1.16em] shrink-0 overflow-clip px-[0.12em]"
key={characters.length - index}
>
<AnimatePresence mode="popLayout">
<m.span
className="block"
key={character}
initial={reducedMotion ? false : { y: "105%", opacity: 0 }}
animate={entered || reducedMotion ? { y: "0%", opacity: 1 } : { y: "105%", opacity: 0 }}
exit={{ y: "-105%", opacity: 0 }}
transition={
reducedMotion
? { duration: 0 }
: { type: "spring", stiffness: 190, damping: 27, delay: index * 0.025 }
}
>
{character}
</m.span>
</AnimatePresence>
</span>
))}
</span>
</>
);
}

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