mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-30 16:54:26 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
582a6fb429 | ||
|
|
24d9e5fb5c | ||
|
|
2a2d08a8d2 | ||
|
|
b42eb6ec06 | ||
|
|
fbf1f8fbac | ||
|
|
232f48578b | ||
|
|
e6a6bf0e6a | ||
|
|
96c7142fbc | ||
|
|
3c5908819c | ||
|
|
3e129c9d9d | ||
|
|
fd3494ccac | ||
|
|
f89acb4368 | ||
|
|
08e61ded7b | ||
|
|
f1d5c6bab4 | ||
|
|
e3717251cb | ||
|
|
30b21fa1e3 | ||
|
|
d77cb93494 | ||
|
|
ce996349fa | ||
|
|
a62ee22f20 | ||
|
|
0a4608bf9d | ||
|
|
9550910f17 | ||
|
|
4076b1a523 | ||
|
|
9699dbf2d8 | ||
|
|
31d6ee6251 | ||
|
|
d9fdf7a30a | ||
|
|
81341a107f | ||
|
|
3fc0896a34 | ||
|
|
7aaed8e30b | ||
|
|
730f795073 | ||
|
|
dc8f9787a4 | ||
|
|
742526af53 | ||
|
|
812d396120 | ||
|
|
1106562169 | ||
|
|
607eafd3e8 | ||
|
|
ffe889b832 | ||
|
|
51ac77295e | ||
|
|
c0c658c00c | ||
|
|
6416da28a4 | ||
|
|
614a1ff9df | ||
|
|
4f60856706 | ||
|
|
ad91a0838c | ||
|
|
15d6443b4f | ||
|
|
1f8c46b4f1 | ||
|
|
de9a6dcfad | ||
|
|
e19f706efd |
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404-arm
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
[](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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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` }],
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+750
-308
File diff suppressed because it is too large
Load Diff
+748
-306
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+780
-338
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+750
-308
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+748
-306
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+745
-303
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+762
-320
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+768
-326
File diff suppressed because it is too large
Load Diff
+748
-306
File diff suppressed because it is too large
Load Diff
+772
-330
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+752
-310
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+787
-345
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+776
-334
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+748
-306
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+762
-320
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+748
-306
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+794
-352
File diff suppressed because it is too large
Load Diff
+757
-315
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+746
-304
File diff suppressed because it is too large
Load Diff
+753
-311
File diff suppressed because it is too large
Load Diff
+749
-307
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+747
-305
File diff suppressed because it is too large
Load Diff
+725
-283
File diff suppressed because it is too large
Load Diff
+37
-37
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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"];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user