mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-30 08:44:31 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47abce351b | ||
|
|
8f6c65b7fd | ||
|
|
5a88ac12bf | ||
|
|
118004b3d3 | ||
|
|
6a8dd480cb | ||
|
|
5d8126d4b0 | ||
|
|
8566a9fd0f | ||
|
|
0d0bc61582 | ||
|
|
e3af637100 | ||
|
|
f752145c57 | ||
|
|
b87f200767 | ||
|
|
73ec8b2ffb | ||
|
|
107537999f | ||
|
|
e1bccbcc93 | ||
|
|
54e256be09 | ||
|
|
b26d510294 | ||
|
|
623ca5c675 | ||
|
|
76e00ae019 | ||
|
|
ff0ef12ecc | ||
|
|
1bcb2e2af6 | ||
|
|
796dc2ca41 | ||
|
|
80bf41254b | ||
|
|
bbc38d2f09 | ||
|
|
775f625f6e | ||
|
|
2203933fac | ||
|
|
907e32a731 | ||
|
|
d6919e340b | ||
|
|
e05ae42fbb | ||
|
|
e99f39d139 | ||
|
|
a4e7d6680d | ||
|
|
77ad14b359 | ||
|
|
9b3916d43e | ||
|
|
29f1aaf0da | ||
|
|
73cef02a4f | ||
|
|
2cb1897922 | ||
|
|
d0af9f4b4f | ||
|
|
08e9c80037 | ||
|
|
a42dbcd452 | ||
|
|
d3102565e4 | ||
|
|
c0387298c5 | ||
|
|
9e1e593dc4 | ||
|
|
849aad6497 | ||
|
|
847d69b621 | ||
|
|
6ff754d125 | ||
|
|
ff3d4b1337 | ||
|
|
2d2d5cd505 | ||
|
|
c6e8df0a00 | ||
|
|
7df9b1e4b5 | ||
|
|
0b4bac62a7 | ||
|
|
82cb6e7590 | ||
|
|
46dac6d3b1 | ||
|
|
b0b0d27c1f | ||
|
|
b12d1184bc | ||
|
|
d6e0d9ac61 | ||
|
|
c19b9746c8 | ||
|
|
4ed6177aee | ||
|
|
5900de101c | ||
|
|
61b3324941 | ||
|
|
0e858c5967 | ||
|
|
8c968e92f4 | ||
|
|
bea8ff1beb | ||
|
|
85e0b0a96d |
+6
-1
@@ -10,7 +10,7 @@ APP_URL="http://localhost:3000"
|
||||
PRINTER_APP_URL="http://host.docker.internal:3000"
|
||||
|
||||
# --- Printer ---
|
||||
PRINTER_ENDPOINT="ws://localhost:4000?token=1234567890"
|
||||
PRINTER_ENDPOINT="ws://localhost:4000?token=change-me"
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
@@ -40,6 +40,11 @@ OAUTH_CLIENT_ID=""
|
||||
OAUTH_CLIENT_SECRET=""
|
||||
OAUTH_DISCOVERY_URL=""
|
||||
OAUTH_AUTHORIZATION_URL=""
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
|
||||
|
||||
# AI provider base URL allowlist (optional, comma-separated hosts/origins)
|
||||
# Example: api.openai.com,https://gateway.ai.vercel.com
|
||||
AI_ALLOWED_BASE_URLS=""
|
||||
|
||||
# --- Email (optional) ---
|
||||
# If all keys are disabled, the app logs the email to be sent to the console instead.
|
||||
|
||||
@@ -16,16 +16,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- uses: voidzero-dev/setup-vp@v1
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
|
||||
- run: vp install
|
||||
- name: Install Dependencies
|
||||
run: vp install --frozen-lockfile
|
||||
|
||||
- run: vp test
|
||||
- run: vp check --fix
|
||||
- name: Run Tests
|
||||
run: vp test
|
||||
|
||||
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8
|
||||
- name: Run Lint and Format
|
||||
run: vp check --fix
|
||||
|
||||
- name: Autofix
|
||||
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Sync Translations from Crowdin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Sync Translations from Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
download_translations: true
|
||||
localization_branch_name: "l10n"
|
||||
commit_message: "[skip ci] chore(i18n): sync translations from crowdin"
|
||||
pull_request_title: "Sync Translations from Crowdin"
|
||||
pull_request_body: "This is an automated pull request to sync translations from [Crowdin](https://crowdin.com/project/reactive-resume)."
|
||||
pull_request_labels: "l10n"
|
||||
pull_request_reviewers: "amruthpillai"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
Vendored
+3
-6
@@ -1,12 +1,8 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": "explicit",
|
||||
"source.fixAll.oxc": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"files.associations": {
|
||||
@@ -40,5 +36,6 @@
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
|
||||
@@ -89,24 +89,24 @@ Copy `.env.example` to `.env` if not present. Key notes for local dev:
|
||||
|
||||
`vp` is the global CLI for Vite+. Do not use pnpm/npm/yarn directly — Vite+ wraps the underlying package manager.
|
||||
|
||||
| Task | Command |
|
||||
| -------------------------- | --------------------------------------------------------------- |
|
||||
| Install dependencies | `vp install` |
|
||||
| Dev server (port 3000) | `vp dev` |
|
||||
| Lint (Oxlint, type-aware) | `vp lint --type-aware` |
|
||||
| Format (Oxfmt) | `vp fmt` |
|
||||
| Check (lint + fmt + types) | `vp check` |
|
||||
| Typecheck | `pnpm typecheck` (uses tsgo) |
|
||||
| Run tests | `vp test` |
|
||||
| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) |
|
||||
| DB studio | `pnpm db:studio` |
|
||||
| i18n extraction | `pnpm lingui:extract` |
|
||||
| Add a dependency | `vp add <package>` |
|
||||
| Remove a dependency | `vp remove <package>` |
|
||||
| One-off binary | `vp dlx <package>` |
|
||||
| Build for production | `vp build` |
|
||||
| Preview production build | `vp preview` |
|
||||
| Start production server | `pnpm start` |
|
||||
| Task | Command |
|
||||
| ----------------------------- | --------------------------------------------------------------- |
|
||||
| Install dependencies | `vp install` |
|
||||
| Dev server (port 3000) | `vp dev` |
|
||||
| Lint (Oxlint, type-aware) | `vp lint --type-aware` |
|
||||
| Format (Oxfmt) | `vp fmt` |
|
||||
| Check (lint + format + types) | `vp check` |
|
||||
| Typecheck | `pnpm typecheck` (uses tsgo) |
|
||||
| Run tests | `vp test` |
|
||||
| DB migrations | `pnpm db:generate` / `pnpm db:migrate` (auto-runs on dev start) |
|
||||
| DB studio | `pnpm db:studio` |
|
||||
| i18n extraction | `pnpm lingui:extract` |
|
||||
| Add a dependency | `vp add <package>` |
|
||||
| Remove a dependency | `vp remove <package>` |
|
||||
| One-off binary | `vp dlx <package>` |
|
||||
| Build for production | `vp build` |
|
||||
| Preview production build | `vp preview` |
|
||||
| Start production server | `pnpm start` |
|
||||
|
||||
## Vite+ Pitfalls
|
||||
|
||||
@@ -128,4 +128,4 @@ Copy `.env.example` to `.env` if not present. Key notes for local dev:
|
||||
## Review Checklist for Agents
|
||||
|
||||
- [ ] Run `vp install` after pulling remote changes and before getting started.
|
||||
- [ ] Run `pnpm lint:fix`, `pnpm fmt:fix`, `pnpm typecheck` and `vp test` to validate changes.
|
||||
- [ ] Run `pnpm lint:fix`, `pnpm format:fix`, `pnpm typecheck` and `vp test` to validate changes.
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------- Development Layer ----------
|
||||
FROM node:24-slim AS development
|
||||
|
||||
ENV NODE_ENV="development"
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
@@ -126,6 +126,10 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
|
||||
<img src="public/templates/jpg/ditgar.jpg" alt="Ditgar" width="150" />
|
||||
<br /><sub><b>Ditgar</b></sub>
|
||||
</td>
|
||||
<td align="center">
|
||||
<img src="public/templates/jpg/meowth.jpg" alt="Meowth" width="150" />
|
||||
<br /><sub><b>Meowth</b></sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Reactive Resume provides security updates for version `5.0.x` and newer.
|
||||
Earlier major versions are no longer supported.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| >= 5.0 | :white_check_mark: |
|
||||
| 4.x | :x: |
|
||||
| < 4.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you believe you have found a security vulnerability, please report it
|
||||
privately instead of opening a public issue.
|
||||
|
||||
Preferred reporting channels:
|
||||
|
||||
- Use GitHub's private vulnerability reporting for this repository.
|
||||
- If that is unavailable, email `hello@amruthpillai.com` with the subject
|
||||
`Security Vulnerability: Reactive Resume`.
|
||||
|
||||
Please include as much detail as possible, including:
|
||||
|
||||
- A description of the vulnerability and its impact.
|
||||
- Steps to reproduce or a proof of concept.
|
||||
- Affected versions, configuration, or deployment details.
|
||||
- Any relevant logs, screenshots, or links.
|
||||
|
||||
You can expect an initial response within 7 days. If the report is accepted,
|
||||
we will coordinate remediation and disclosure timing with you before publishing
|
||||
details publicly.
|
||||
+65
-12
@@ -20,32 +20,34 @@ services:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres"]
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||
start_period: 10s
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "4000:3000"
|
||||
environment:
|
||||
- QUEUED=10
|
||||
- HEALTH=true
|
||||
- CONCURRENT=5
|
||||
- TOKEN=1234567890
|
||||
QUEUED: 10
|
||||
HEALTH: "true"
|
||||
CONCURRENT: 5
|
||||
TOKEN: ${BROWSERLESS_TOKEN:-change-me}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
|
||||
test: ["CMD-SHELL", 'curl -f "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN:-change-me}"']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -57,14 +59,16 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9222:9222"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
restart: unless-stopped
|
||||
command: server -s3 -filer -dir=/data -ip=0.0.0.0
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=seaweedfs
|
||||
- AWS_SECRET_ACCESS_KEY=seaweedfs
|
||||
AWS_ACCESS_KEY_ID: seaweedfs
|
||||
AWS_SECRET_ACCESS_KEY: seaweedfs
|
||||
volumes:
|
||||
- seaweedfs_data:/data
|
||||
ports:
|
||||
@@ -106,7 +110,56 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
web_app:
|
||||
profiles: ["app"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PRINTER_APP_URL: http://web_app:3000
|
||||
PRINTER_ENDPOINT: ws://browserless:3000?token=${BROWSERLESS_TOKEN:-change-me}
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
|
||||
SMTP_HOST: mailpit
|
||||
S3_ENDPOINT: http://seaweedfs:8333
|
||||
CHOKIDAR_INTERVAL: 100
|
||||
WATCHPACK_POLLING: "true"
|
||||
CHOKIDAR_USEPOLLING: "true"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- web_app_data:/app/data
|
||||
- web_app_pnpm_store:/pnpm/store
|
||||
- web_app_nitro_cache:/app/.nitro
|
||||
- web_app_tanstack_cache:/app/.tanstack
|
||||
- web_app_node_modules:/app/node_modules
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
browserless:
|
||||
condition: service_healthy
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
seaweedfs_create_bucket:
|
||||
condition: service_completed_successfully
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
start_period: 10s
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
mailpit_data:
|
||||
postgres_data:
|
||||
seaweedfs_data:
|
||||
web_app_data:
|
||||
web_app_pnpm_store:
|
||||
web_app_nitro_cache:
|
||||
web_app_node_modules:
|
||||
web_app_tanstack_cache:
|
||||
|
||||
+32
-8
@@ -4,6 +4,8 @@ services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- data_network
|
||||
environment:
|
||||
- POSTGRES_DB=postgres
|
||||
- POSTGRES_USER=postgres
|
||||
@@ -18,15 +20,17 @@ services:
|
||||
retries: 3
|
||||
|
||||
browserless:
|
||||
image: ghcr.io/browserless/chromium:latest
|
||||
image: ${BROWSERLESS_IMAGE:-ghcr.io/browserless/chromium:latest}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- printer_network
|
||||
environment:
|
||||
- QUEUED=10
|
||||
- HEALTH=true
|
||||
- CONCURRENT=5
|
||||
- TOKEN=1234567890
|
||||
- TOKEN=${BROWSERLESS_TOKEN:-change-me}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
|
||||
test: ["CMD-SHELL", 'curl -f "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN:-change-me}"']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -36,6 +40,8 @@ services:
|
||||
# chrome:
|
||||
# image: chromedp/headless-shell:latest
|
||||
# restart: unless-stopped
|
||||
# networks:
|
||||
# - printer_network
|
||||
# ports:
|
||||
# - "9222:9222"
|
||||
|
||||
@@ -43,6 +49,8 @@ services:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
restart: unless-stopped
|
||||
command: server -s3 -filer -dir=/data -ip=0.0.0.0
|
||||
networks:
|
||||
- storage_network
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=seaweedfs
|
||||
- AWS_SECRET_ACCESS_KEY=seaweedfs
|
||||
@@ -65,21 +73,32 @@ services:
|
||||
mc mb seaweedfs/reactive-resume;
|
||||
exit 0;
|
||||
"
|
||||
networks:
|
||||
- storage_network
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
|
||||
reactive_resume:
|
||||
image: amruthpillai/reactive-resume:latest
|
||||
# image: amruthpillai/reactive-resume:latest
|
||||
# image: ghcr.io/amruthpillai/reactive-resume:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
networks:
|
||||
- data_network
|
||||
- printer_network
|
||||
- storage_network
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# Server
|
||||
- TZ=Etc/UTC
|
||||
- NODE_ENV=production
|
||||
- APP_URL=http://localhost:3000
|
||||
- PRINTER_APP_URL=http://reactive_resume:3000
|
||||
- PRINTER_APP_URL=http://host.docker.internal:3000
|
||||
# Printer
|
||||
- PRINTER_ENDPOINT=ws://browserless:3000?token=1234567890
|
||||
- PRINTER_ENDPOINT=ws://browserless:3000?token=${BROWSERLESS_TOKEN:-change-me}
|
||||
# - PRINTER_ENDPOINT=http://chrome:9222 # Or, if you're using chromedp/headless-shell
|
||||
# Database
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
|
||||
@@ -93,8 +112,6 @@ services:
|
||||
- S3_FORCE_PATH_STYLE=true
|
||||
volumes:
|
||||
- reactive_resume_data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -102,6 +119,8 @@ services:
|
||||
condition: service_healthy
|
||||
seaweedfs_create_bucket:
|
||||
condition: service_completed_successfully
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
start_period: 10s
|
||||
@@ -113,3 +132,8 @@ volumes:
|
||||
postgres_data:
|
||||
seaweedfs_data:
|
||||
reactive_resume_data:
|
||||
|
||||
networks:
|
||||
data_network:
|
||||
printer_network:
|
||||
storage_network:
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
preserve_hierarchy: true
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
commit_message: "[ci skip]"
|
||||
pull_request_title: Sync Translations from Crowdin
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
commit_message: "[skip ci] chore(i18n): sync translations from crowdin"
|
||||
pull_request_title: "Sync Translations from Crowdin"
|
||||
pull_request_body: "This is an automated pull request to sync translations from [Crowdin](https://crowdin.com/project/reactive-resume)."
|
||||
pull_request_labels:
|
||||
- l10n
|
||||
pull_request_reviewers:
|
||||
|
||||
@@ -4,6 +4,65 @@ description: "List of all notable changes and updates to Reactive Resume"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.0.20" description="27th April 2026">
|
||||
## Features & Improvements
|
||||
- Added the new **Meowth** resume template (compact ATS-friendly layout with an inline three-column entry header), thanks to [@JamesGoslings](https://github.com/JamesGoslings). [#2923](https://github.com/amruthpillai/reactive-resume/pull/2923)
|
||||
- Added support for custom icon colors on section items, thanks to [@Ruzenie](https://github.com/Ruzenie). [#2928](https://github.com/amruthpillai/reactive-resume/pull/2928)
|
||||
- Improved account-related email UX with better templates for password reset and email verification. [b87f2007](https://github.com/amruthpillai/reactive-resume/commit/b87f2007)
|
||||
- Consolidated separate settings pages into a single **Integrations** page for a more streamlined configuration flow. [5d8126d4](https://github.com/amruthpillai/reactive-resume/commit/5d8126d4)
|
||||
|
||||
## Fixes
|
||||
- Added a targeted Chinese localization fix for the `Skills` section title (`zh-CN`). [#2931](https://github.com/amruthpillai/reactive-resume/pull/2931)
|
||||
- Allowed MCP localhost loopback only for local MCP clients, improving local integration behavior while keeping external access restrictions intact. [623ca5c6](https://github.com/amruthpillai/reactive-resume/commit/623ca5c6)
|
||||
|
||||
## Maintenance & Localization
|
||||
- Updated dependencies and lockfile, including cleanup of unused exports (Knip). [e1bccbcc](https://github.com/amruthpillai/reactive-resume/commit/e1bccbcc)
|
||||
- Synced translation catalogs from Crowdin. [#2922](https://github.com/amruthpillai/reactive-resume/pull/2922), [#2925](https://github.com/amruthpillai/reactive-resume/pull/2925), [#2926](https://github.com/amruthpillai/reactive-resume/pull/2926), [#2932](https://github.com/amruthpillai/reactive-resume/pull/2932), [#2934](https://github.com/amruthpillai/reactive-resume/pull/2934), [#2935](https://github.com/amruthpillai/reactive-resume/pull/2935)
|
||||
- Updated docs to list **Meowth** alongside existing templates. [#2929](https://github.com/amruthpillai/reactive-resume/pull/2929)
|
||||
|
||||
**Full Changelog**: [v5.0.19...v5.0.20](https://github.com/amruthpillai/reactive-resume/compare/v5.0.19...v5.0.20)
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.19" description="25th April 2026">
|
||||
## Highlights
|
||||
- Added **OpenRouter** as an AI provider, thanks to [@iagodemacedo](https://github.com/iagodemacedo). [#2906](https://github.com/amruthpillai/reactive-resume/pull/2906)
|
||||
- Added Chinese font options, thanks to [@Platinum1154](https://github.com/Platinum1154). [#2905](https://github.com/amruthpillai/reactive-resume/pull/2905)
|
||||
- Added text color support to the rich text editor, including DOCX export handling for colored text, thanks to [@Platinum1154](https://github.com/Platinum1154). [#2903](https://github.com/amruthpillai/reactive-resume/pull/2903)
|
||||
- Fixed MCP tool names for Claude Desktop compatibility. [#2885](https://github.com/amruthpillai/reactive-resume/pull/2885)
|
||||
- Improved Docker-based local development support and refreshed self-hosting setup guidance. [849aad64](https://github.com/amruthpillai/reactive-resume/commit/849aad64), [c0387298](https://github.com/amruthpillai/reactive-resume/commit/c0387298)
|
||||
|
||||
## Fixes & Security
|
||||
- Thank you to [@shaxbozaka](https://github.com/shaxbozaka) for running a security audit on Reactive Resume and helping make the app more secure.
|
||||
- Hardened auth, OAuth, AI URL validation, printer/export endpoints, resume access controls, Browserless configuration, and public route exposure. [a42dbcd4](https://github.com/amruthpillai/reactive-resume/commit/a42dbcd4)
|
||||
- Removed the public RPC surface for the printer-only resume fetch endpoint by moving it behind a server function, further shrinking the attack surface for `getByIdForPrinter`, thanks to [@shaxbozaka](https://github.com/shaxbozaka).
|
||||
- Corrected Computer Modern Sans italic font file mappings, thanks to [@russellbrenner](https://github.com/russellbrenner). [#2881](https://github.com/amruthpillai/reactive-resume/pull/2881)
|
||||
- Improved email handling and user lookup in OAuth configuration, thanks to [@VedantBhawsar](https://github.com/VedantBhawsar). [#2874](https://github.com/amruthpillai/reactive-resume/pull/2874)
|
||||
- Aligned role period text in experience items, thanks to [@JamesGoslings](https://github.com/JamesGoslings). [#2908](https://github.com/amruthpillai/reactive-resume/pull/2908)
|
||||
- Reduced resume preview wheel zoom sensitivity, thanks to [@GoldenSection0618](https://github.com/GoldenSection0618). [#2911](https://github.com/amruthpillai/reactive-resume/pull/2911)
|
||||
- Prevented orphaned section headings at page breaks, thanks to [@5queezer](https://github.com/5queezer). [#2851](https://github.com/amruthpillai/reactive-resume/pull/2851)
|
||||
- Added top margin handling for subsequent PDF pages, based on feedback from [@trigger-xyz](https://github.com/trigger-xyz). [bbc38d2f](https://github.com/amruthpillai/reactive-resume/commit/bbc38d2f)
|
||||
|
||||
## Maintenance & Localization
|
||||
- Updated dependencies and lockfile, including security-focused package pins and Vite+ version pins. [c6e8df0a](https://github.com/amruthpillai/reactive-resume/commit/c6e8df0a), [847d69b6](https://github.com/amruthpillai/reactive-resume/commit/847d69b6), [9b3916d4](https://github.com/amruthpillai/reactive-resume/commit/9b3916d4)
|
||||
- Added `SECURITY.md`. [9e1e593d](https://github.com/amruthpillai/reactive-resume/commit/9e1e593d)
|
||||
- Added focused tests for auth, printer/export, URL validation, form edge cases, rich text DOCX output, font handling, and CSS sanitization hardening. [d0af9f4b](https://github.com/amruthpillai/reactive-resume/commit/d0af9f4b)
|
||||
- Synced translation catalogs from Crowdin and moved syncing to GitHub Actions. [#2886](https://github.com/amruthpillai/reactive-resume/pull/2886), [#2918](https://github.com/amruthpillai/reactive-resume/pull/2918)
|
||||
|
||||
## New Contributors
|
||||
- [@russellbrenner](https://github.com/russellbrenner), [@VedantBhawsar](https://github.com/VedantBhawsar), [@JamesGoslings](https://github.com/JamesGoslings), [@GoldenSection0618](https://github.com/GoldenSection0618), [@iagodemacedo](https://github.com/iagodemacedo), and [@Platinum1154](https://github.com/Platinum1154)
|
||||
|
||||
**Full Changelog**: [v5.0.17...v5.0.19](https://github.com/amruthpillai/reactive-resume/compare/v5.0.17...v5.0.19)
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.18" description="14th April 2026">
|
||||
## Features & Improvements
|
||||
- Re-enabled **Passkeys** across authentication flows, including passkey sign-in on the login page and passkey management in account settings.
|
||||
|
||||
## Maintenance
|
||||
- Updated dependencies and lockfile.
|
||||
- Synced translation catalogs from Crowdin.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.17" description="9th April 2026">
|
||||
## Features & Improvements
|
||||
- Added **Resume Analysis** in the resume builder right sidebar: run an on-demand AI review for an overall score (0 to 100), a dimension scorecard, strengths, and prioritized suggestions. Results are saved per resume and reload after refresh.
|
||||
|
||||
@@ -126,7 +126,7 @@ Here are the most commonly used scripts during development:
|
||||
| `pnpm run build` | Build the application for production |
|
||||
| `pnpm run start` | Start the production server |
|
||||
| `pnpm run lint` | Run Oxlint linter and formatter |
|
||||
| `pnpm run fmt` | Run Oxfmt formatter |
|
||||
| `pnpm run format` | Run Oxfmt formatter |
|
||||
| `pnpm run typecheck` | Run TypeScript type checking |
|
||||
|
||||
### Database
|
||||
@@ -252,8 +252,8 @@ Uses [Oxlint](https://oxlint.dev/) for linting and [Oxfmt](https://oxfmt.dev/) f
|
||||
|
||||
```bash
|
||||
# Check and auto-fix issues
|
||||
pnpm run lint
|
||||
pnpm run fmt
|
||||
pnpm run lint:fix
|
||||
pnpm run format:fix
|
||||
```
|
||||
|
||||
### Type Checking
|
||||
|
||||
@@ -39,8 +39,8 @@ description: "Welcome to the documentation for Reactive Resume, a free and open-
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Multiple Templates" icon="grid-2">
|
||||
Choose from a variety of professionally designed templates including Azurill, Bronzor, Chikorita, Ditto, Gengar,
|
||||
Glalie, Kakuna, Lapras, Leafish, Onyx, Pikachu, and Rhyhorn - each with unique layouts and styles.
|
||||
Choose from a variety of professionally designed templates including Azurill, Bronzor, Chikorita, Ditgar, Ditto,
|
||||
Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, and Rhyhorn - each with unique layouts and styles.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Rich Text Editor" icon="text">
|
||||
|
||||
@@ -67,28 +67,60 @@ Before you begin, ensure you have the following installed:
|
||||
|
||||
<Steps>
|
||||
<Step title="Clone the Repository">
|
||||
```bash git clone https://github.com/amruthpillai/reactive-resume.git cd Reactive-Resume ```
|
||||
```bash
|
||||
git clone https://github.com/amruthpillai/reactive-resume.git
|
||||
cd Reactive-Resume
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Configure Environment Variables">
|
||||
Create a `.env` file in the root directory with the following variables: ```bash # Application
|
||||
APP_URL=http://localhost:3000 # Database DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres #
|
||||
Authentication (generate a secure secret) AUTH_SECRET=your-secure-secret-key-here # Storage (S3-compatible)
|
||||
S3_ACCESS_KEY_ID=seaweedfs S3_SECRET_ACCESS_KEY=seaweedfs S3_ENDPOINT=http://seaweedfs:8333
|
||||
S3_BUCKET=reactive-resume S3_FORCE_PATH_STYLE=true ```
|
||||
Create a `.env` file in the root directory with the following variables:
|
||||
|
||||
```bash .env
|
||||
# Application
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Printer (Browserless)
|
||||
BROWSERLESS_TOKEN=change-me
|
||||
PRINTER_ENDPOINT=ws://browserless:3000?token=change-me
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
|
||||
|
||||
# Authentication (generate a secure secret)
|
||||
AUTH_SECRET=your-secure-secret-key-here
|
||||
|
||||
# Storage (S3-compatible via SeaweedFS)
|
||||
S3_ACCESS_KEY_ID=seaweedfs
|
||||
S3_SECRET_ACCESS_KEY=seaweedfs
|
||||
S3_ENDPOINT=http://seaweedfs:8333
|
||||
S3_BUCKET=reactive-resume
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
```
|
||||
|
||||
<Warning>
|
||||
For production deployments, always use strong, unique values for `AUTH_SECRET` and database credentials.
|
||||
For production deployments, always use strong, unique values for `AUTH_SECRET`, `BROWSERLESS_TOKEN`, and database credentials.
|
||||
</Warning>
|
||||
</Step>
|
||||
|
||||
<Step title="Start the Services">
|
||||
```bash docker compose up -d ``` This will start: - **PostgreSQL** — Database for storing user data and resumes -
|
||||
**SeaweedFS** — S3-compatible storage for file uploads - **Printer** — PDF and screenshot generation service
|
||||
(browserless/chromium) - **Reactive Resume** — The main application
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- **PostgreSQL** — Database for storing user data and resumes
|
||||
- **SeaweedFS** — S3-compatible storage for file uploads
|
||||
- **Browserless** — Headless Chromium service for PDF/screenshot generation
|
||||
- **Reactive Resume** — The main application
|
||||
</Step>
|
||||
|
||||
<Step title="Access Your Instance">
|
||||
Once all services are running, access your Reactive Resume instance at: ``` http://localhost:3000 ```
|
||||
Once all services are running, access your Reactive Resume instance at:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
</Step>
|
||||
|
||||
</Steps>
|
||||
@@ -97,12 +129,12 @@ Before you begin, ensure you have the following installed:
|
||||
|
||||
Here's what each service in the stack does:
|
||||
|
||||
| Service | Port | Description |
|
||||
| ----------- | ---- | ----------------------------------------------------------- |
|
||||
| `postgres` | 5432 | PostgreSQL database for storing all application data |
|
||||
| `seaweedfs` | 8333 | S3-compatible object storage for file uploads |
|
||||
| `printer` | 4000 | Headless Chromium service for PDF and screenshot generation |
|
||||
| `app` | 3000 | The main Reactive Resume application |
|
||||
| Service | Port | Description |
|
||||
| ----------------- | ---------------------------------- | ----------------------------------------------------------- |
|
||||
| `postgres` | 5432 | PostgreSQL database for storing all application data |
|
||||
| `seaweedfs` | 8333 | S3-compatible object storage for file uploads |
|
||||
| `browserless` | 3000 (internal) / 4000 (published) | Headless Chromium service for PDF and screenshot generation |
|
||||
| `reactive_resume` | 3000 | The main Reactive Resume application |
|
||||
|
||||
### Health Checks
|
||||
|
||||
@@ -122,51 +154,58 @@ Here's a complete list of environment variables you can configure:
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ------------------ | ------------------------------ | --------------------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/db` |
|
||||
| `AUTH_SECRET` | Secret key for authentication | Generate with `openssl rand -base64 32` |
|
||||
| `APP_URL` | Public URL of your Application | `https://rxresu.me` |
|
||||
| `PRINTER_ENDPOINT` | URL of the printer service | `http://printer:3000` |
|
||||
| Variable | Description | Example |
|
||||
| ------------------ | ------------------------------ | ------------------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/db` |
|
||||
| `AUTH_SECRET` | Secret key for authentication | Generate with `openssl rand -hex 32` |
|
||||
| `APP_URL` | Public URL of your Application | `https://rxresu.me` |
|
||||
| `PRINTER_ENDPOINT` | URL of the printer service | `ws://browserless:3000?token=...` |
|
||||
|
||||
### Optional Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------- | ----------------------------------------------------------- | ---------------------- |
|
||||
| `PRINTER_APP_URL` | Public URL for the printer to access the Application | — |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth Client ID | — |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth Client ID | — |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | — |
|
||||
| `LINKEDIN_CLIENT_ID` | LinkedIn OAuth Client ID | — |
|
||||
| `LINKEDIN_CLIENT_SECRET` | LinkedIn OAuth Client Secret | — |
|
||||
| `OAUTH_PROVIDER_NAME` | Custom OAuth Provider Name | — |
|
||||
| `OAUTH_CLIENT_ID` | Custom OAuth Client ID | — |
|
||||
| `OAUTH_CLIENT_SECRET` | Custom OAuth Client Secret | — |
|
||||
| `OAUTH_DISCOVERY_URL` | OIDC Discovery URL (use this OR manual URLs below) | — |
|
||||
| `OAUTH_AUTHORIZATION_URL` | OAuth Authorization URL (manual config) | — |
|
||||
| `OAUTH_TOKEN_URL` | OAuth Token URL (manual config) | — |
|
||||
| `OAUTH_USER_INFO_URL` | OAuth User Info URL (manual config) | — |
|
||||
| `OAUTH_SCOPES` | OAuth Scopes (space-separated) | `openid profile email` |
|
||||
| `SMTP_HOST` | SMTP Server Host (for email features) | — |
|
||||
| `SMTP_PORT` | SMTP Server Port | `587` |
|
||||
| `SMTP_USER` | SMTP Username | — |
|
||||
| `SMTP_PASS` | SMTP Password | — |
|
||||
| `SMTP_FROM` | Default FROM address for emails | — |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (`true` or `false`) | `false` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 Access Key | — |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 Secret Key | — |
|
||||
| `S3_REGION` | S3 Region | `us-east-1` |
|
||||
| `S3_ENDPOINT` | S3-compatible Endpoint URL | — |
|
||||
| `S3_BUCKET` | S3 Bucket Name | — |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 (set `true` for MinIO/SeaweedFS) | `false` |
|
||||
| `FLAG_DEBUG_PRINTER` | Used for debugging the printer route | `false` |
|
||||
| `FLAG_DISABLE_SIGNUPS` | Disables new user signups | `false` |
|
||||
| `FLAG_DISABLE_EMAIL_AUTH` | Disables email/password login (SSO only) | `false` |
|
||||
| `FLAG_DISABLE_IMAGE_PROCESSING` | Disables image processing | `false` |
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | ----------------------------------------------------------- | ---------------------- |
|
||||
| `PRINTER_APP_URL` | Public URL for the printer to access the Application | — |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth Client ID | — |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth Client ID | — |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | — |
|
||||
| `LINKEDIN_CLIENT_ID` | LinkedIn OAuth Client ID | — |
|
||||
| `LINKEDIN_CLIENT_SECRET` | LinkedIn OAuth Client Secret | — |
|
||||
| `OAUTH_PROVIDER_NAME` | Custom OAuth Provider Name | — |
|
||||
| `OAUTH_CLIENT_ID` | Custom OAuth Client ID | — |
|
||||
| `OAUTH_CLIENT_SECRET` | Custom OAuth Client Secret | — |
|
||||
| `OAUTH_DISCOVERY_URL` | OIDC Discovery URL (use this OR manual URLs below) | — |
|
||||
| `OAUTH_AUTHORIZATION_URL` | OAuth Authorization URL (manual config) | — |
|
||||
| `OAUTH_TOKEN_URL` | OAuth Token URL (manual config) | — |
|
||||
| `OAUTH_USER_INFO_URL` | OAuth User Info URL (manual config) | — |
|
||||
| `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS` | Trusted HTTPS hosts/origins for dynamic OAuth redirects | — |
|
||||
| `OAUTH_SCOPES` | OAuth Scopes (space-separated) | `openid profile email` |
|
||||
| `BETTER_AUTH_API_KEY` | Better Auth dashboard API key | — |
|
||||
| `BETTER_AUTH_URL` | Better Auth base URL override (advanced) | `APP_URL` |
|
||||
| `BETTER_AUTH_SECRET` | Better Auth secret override (advanced) | `AUTH_SECRET` |
|
||||
| `AI_ALLOWED_BASE_URLS` | Allowlist for custom AI provider base URLs | — |
|
||||
| `SMTP_HOST` | SMTP Server Host (for email features) | — |
|
||||
| `SMTP_PORT` | SMTP Server Port | `587` |
|
||||
| `SMTP_USER` | SMTP Username | — |
|
||||
| `SMTP_PASS` | SMTP Password | — |
|
||||
| `SMTP_FROM` | Default FROM address for emails | — |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (`true` or `false`) | `false` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 Access Key | — |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 Secret Key | — |
|
||||
| `S3_REGION` | S3 Region | `us-east-1` |
|
||||
| `S3_ENDPOINT` | S3-compatible Endpoint URL | — |
|
||||
| `S3_BUCKET` | S3 Bucket Name | — |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 (set `true` for MinIO/SeaweedFS) | `false` |
|
||||
| `FLAG_DEBUG_PRINTER` | Used for debugging the printer route | `false` |
|
||||
| `FLAG_DISABLE_SIGNUPS` | Disables new user signups | `false` |
|
||||
| `FLAG_DISABLE_EMAIL_AUTH` | Disables email/password login (SSO only) | `false` |
|
||||
| `FLAG_DISABLE_IMAGE_PROCESSING` | Disables image processing | `false` |
|
||||
|
||||
> **Note:** Some variables are only required for using related features (OAuth, SMTP, S3, etc.) and can be left unset if unused.
|
||||
|
||||
> **Health check behavior:** `/api/health` reports status for database, printer, and storage. A failure in any dependency returns HTTP `503`.
|
||||
|
||||
<Note>
|
||||
**Hybrid Setup Note**: The `PRINTER_APP_URL` variable is required when running Reactive Resume outside of Docker while
|
||||
the printer service is running inside Docker. In this scenario, the printer needs to reach your local app to render
|
||||
|
||||
@@ -132,6 +132,10 @@ Reactive Resume includes a variety of professionally designed templates. Each te
|
||||
<img src="/images/templates/leafish.webp" alt="Leafish template preview" style={{ aspectRatio: "210/297" }} />
|
||||
</Frame>
|
||||
|
||||
<Frame caption="Meowth">
|
||||
<img src="/images/templates/meowth.webp" alt="Meowth template preview" style={{ aspectRatio: "210/297" }} />
|
||||
</Frame>
|
||||
|
||||
<Frame caption="Onyx">
|
||||
<img src="/images/templates/onyx.webp" alt="Onyx template preview" style={{ aspectRatio: "210/297" }} />
|
||||
</Frame>
|
||||
|
||||
@@ -937,6 +937,7 @@ The exported JSON follows the Reactive Resume schema. Here's a high-level overvi
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
|
||||
+647
-165
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,6 @@ title: "Setting up passkeys"
|
||||
description: "Learn how to register passkeys (WebAuthn) to sign in securely using biometrics or your device PIN"
|
||||
---
|
||||
|
||||
<Warning>
|
||||
<strong>Passkeys Temporarily Disabled:</strong> Passkey registration and sign-in are currently unavailable in Reactive Resume due to an upstream issue with our authentication provider. We are closely monitoring the situation and will re-enable passkeys once it is resolved.
|
||||
|
||||
For more details and technical updates, see the <a href="https://github.com/better-auth/better-auth/issues/7463" target="_blank" rel="noopener">GitHub issue #7463</a>.
|
||||
|
||||
</Warning>
|
||||
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step title="Sign in to the dashboard">
|
||||
Head over to [https://rxresu.me](https://rxresu.me) and sign in with your account credentials.
|
||||
@@ -27,8 +18,7 @@ For more details and technical updates, see the <a href="https://github.com/bett
|
||||
</Step>
|
||||
|
||||
<Step title="Name your passkey">
|
||||
You'll be prompted to enter a name for the passkey. Use something descriptive so you can recognize it later (for
|
||||
example, “MacBook Touch ID” or “iPhone Face ID”).
|
||||
Enter a descriptive name so you can recognize it later (for example, "MacBook Touch ID" or "iPhone Face ID").
|
||||
</Step>
|
||||
|
||||
<Step title="Complete the passkey prompt">
|
||||
@@ -41,11 +31,15 @@ For more details and technical updates, see the <a href="https://github.com/bett
|
||||
</Step>
|
||||
|
||||
<Step title="Manage passkeys (optional)">
|
||||
After registering, your passkey will show up in the list. You can delete a passkey using the trash icon next to it.
|
||||
After registering, your passkey will show up in the list. You can rename it or delete it from the same section.
|
||||
|
||||
<Warning>
|
||||
Deleting a passkey cannot be undone. After deletion, you won't be able to sign in using that passkey anymore.
|
||||
</Warning>
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Sign in with a passkey">
|
||||
On the login page, click <Badge>Sign in with Passkey</Badge> to authenticate without entering your password.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -207,25 +207,29 @@ If you're running a self-hosted Reactive Resume instance, replace `https://rxres
|
||||
|
||||
## Available Tools
|
||||
|
||||
Tool names use a hierarchical `reactive_resume.*` prefix ([SEP-986](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986) style) so they stay distinct when multiple MCP servers are enabled in the same client.
|
||||
Tool names use a `reactive_resume_` prefix so they stay distinct when multiple MCP servers are enabled in the same client.
|
||||
|
||||
| Tool | Description |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `reactive_resume.list_resumes` | List all resumes with IDs, names, tags, and status. Supports filtering by tags and sorting by last updated, creation date, or name |
|
||||
| `reactive_resume.get_resume` | Get the full data of a specific resume by ID |
|
||||
| `reactive_resume.create_resume` | Create a new, empty resume with a name and slug. Optionally pre-fill with sample data |
|
||||
| `reactive_resume.duplicate_resume` | Create a copy of an existing resume with a new name and slug |
|
||||
| `reactive_resume.patch_resume` | Apply JSON Patch (RFC 6902) operations to modify a resume's data |
|
||||
| `reactive_resume.delete_resume` | Permanently delete a resume and all associated files. **Irreversible** |
|
||||
| `reactive_resume.lock_resume` | Lock a resume to prevent edits, patches, and deletion |
|
||||
| `reactive_resume.unlock_resume` | Unlock a previously locked resume to re-enable editing |
|
||||
| `reactive_resume.export_resume_pdf` | Generate a PDF from the resume and return a download URL |
|
||||
| `reactive_resume.get_resume_screenshot` | Get a visual preview of the resume's first page as a WebP image URL |
|
||||
| `reactive_resume.get_resume_statistics` | Get view and download statistics for a resume |
|
||||
| `reactive_resume_list_resumes` | List all resumes with IDs, names, tags, and status. Supports filtering by tags and sorting by last updated, creation date, or name |
|
||||
| `reactive_resume_list_resume_tags` | List every distinct tag in use across your resumes (sorted) |
|
||||
| `reactive_resume_get_resume` | Get the full data of a specific resume by ID |
|
||||
| `reactive_resume_get_resume_analysis` | Get the latest saved AI analysis for a resume (from the web app), if any |
|
||||
| `reactive_resume_create_resume` | Create a new, empty resume with a name and slug. Optionally pre-fill with sample data |
|
||||
| `reactive_resume_import_resume` | Create a resume from a full ResumeData JSON export (random name/slug). Large files may exceed client limits |
|
||||
| `reactive_resume_duplicate_resume` | Create a copy of an existing resume with a new name and slug |
|
||||
| `reactive_resume_patch_resume` | Apply JSON Patch (RFC 6902) operations to modify a resume's data |
|
||||
| `reactive_resume_update_resume` | Update metadata only: name, slug, tags, `isPublic`. Returns canonical share URL; passwords are not managed via MCP |
|
||||
| `reactive_resume_delete_resume` | Permanently delete a resume and all associated files. **Irreversible** |
|
||||
| `reactive_resume_lock_resume` | Lock a resume to prevent edits, patches, and deletion |
|
||||
| `reactive_resume_unlock_resume` | Unlock a previously locked resume to re-enable editing |
|
||||
| `reactive_resume_export_resume_pdf` | Generate a PDF from the resume and return a download URL |
|
||||
| `reactive_resume_get_resume_screenshot` | Get a visual preview of the resume's first page as a WebP image URL |
|
||||
| `reactive_resume_get_resume_statistics` | Get view and download statistics for a resume |
|
||||
|
||||
### Breaking change (tool names)
|
||||
|
||||
Older clients may refer to unprefixed names (`list_resumes`, `get_resume`, …). Those names are no longer used; update automations and saved prompts to the `reactive_resume.*` names above.
|
||||
Older clients may refer to unprefixed names (`list_resumes`, `get_resume`, …) or dot-separated names (`reactive_resume.list_resumes`, …). Those names are no longer used; update automations and saved prompts to the `reactive_resume_*` names above.
|
||||
|
||||
## Available Resources
|
||||
|
||||
@@ -235,12 +239,12 @@ Resources follow MCP conventions: **static** items appear in `resources/list`; *
|
||||
| ------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `resources/list` | Static resources only — currently **`resume://_meta/schema`** (ResumeData JSON Schema) |
|
||||
| `resources/templates/list` | **`resume://{id}`** — template for reading full resume JSON by ID (not enumerated per resume) |
|
||||
| `reactive_resume.list_resumes` (tool) | **Primary way to discover resume IDs** — resumes are not listed as separate MCP resources |
|
||||
| `reactive_resume_list_resumes` (tool) | **Primary way to discover resume IDs** — resumes are not listed as separate MCP resources |
|
||||
|
||||
| URI | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------------ |
|
||||
| `resume://_meta/schema` | ResumeData JSON Schema — use for valid JSON Patch paths and value types |
|
||||
| `resume://{id}` | Full resume data as JSON — use an ID from `reactive_resume.list_resumes` |
|
||||
| `resume://{id}` | Full resume data as JSON — use an ID from `reactive_resume_list_resumes` |
|
||||
|
||||
### Breaking change (schema URI)
|
||||
|
||||
@@ -275,7 +279,10 @@ Once your MCP client is connected, you can use natural language to interact with
|
||||
### Creating & Managing
|
||||
|
||||
- "Create a new resume called 'Frontend Engineer 2026'"
|
||||
- "Import this exported ResumeData JSON as a new resume"
|
||||
- "What tags do I use across my resumes?"
|
||||
- "Duplicate my 'Software Engineer' resume for a product manager role"
|
||||
- "Make my resume public and give me the share link"
|
||||
- "Lock my finalized resume so it can't be accidentally edited"
|
||||
- "Delete my old draft resume"
|
||||
|
||||
@@ -306,8 +313,9 @@ Once your MCP client is connected, you can use natural language to interact with
|
||||
- "Tailor my resume for this job description: ..." (uses `tailor_resume`)
|
||||
|
||||
<Tip>
|
||||
The AI will use `reactive_resume.get_resume` to inspect your current resume before making changes with
|
||||
`reactive_resume.patch_resume`. This ensures the correct JSON paths are used.
|
||||
The AI will use `reactive_resume_get_resume` to inspect your current resume before making changes with
|
||||
`reactive_resume_patch_resume`. This ensures the correct JSON paths are used. Use `reactive_resume_update_resume` for
|
||||
name, slug, tags, and public visibility (not for section content).
|
||||
</Tip>
|
||||
|
||||
## Troubleshooting
|
||||
@@ -317,7 +325,7 @@ Once your MCP client is connected, you can use natural language to interact with
|
||||
| "Unauthorized" with no login prompt | Your client may not support MCP OAuth discovery. Use API key mode (`x-api-key`) |
|
||||
| OAuth login opens but fails redirect/callback | Confirm your client's MCP OAuth callback settings and retry the connection |
|
||||
| "API error (401)" | Your API key is invalid or expired. Create a new one in **Settings → API Keys** |
|
||||
| "API error (404)" | The resume ID doesn't exist. Use `reactive_resume.list_resumes` to find valid IDs |
|
||||
| "API error (404)" | The resume ID doesn't exist. Use `reactive_resume_list_resumes` to find valid IDs |
|
||||
| "API error (403)" | The resume is locked. Unlock it in the Reactive Resume dashboard |
|
||||
| Connection refused | Check that the URL is correct and the instance is running |
|
||||
| "ReferenceError: File is not defined" when using `mcp-remote` | You're running Node.js 18. `mcp-remote` requires **Node.js 20 or later** — upgrade with `nvm use 20` or `nvm alias default 20` |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -54,9 +54,9 @@ APP_URL="http://localhost:3000"
|
||||
PRINTER_APP_URL="http://host.docker.internal:3000"
|
||||
|
||||
# --- Printer ---
|
||||
# If using browserless with token authentication, include the token as a query parameter:
|
||||
# PRINTER_ENDPOINT="ws://printer:3000?token=your-secret-token"
|
||||
PRINTER_ENDPOINT="ws://printer:3000"
|
||||
# Keep this token in sync with the Browserless TOKEN value.
|
||||
BROWSERLESS_TOKEN="change-me"
|
||||
PRINTER_ENDPOINT="ws://printer:3000?token=change-me"
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
@@ -64,6 +64,8 @@ DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
AUTH_SECRET=""
|
||||
# Better Auth dashboard API key (optional)
|
||||
BETTER_AUTH_API_KEY=""
|
||||
|
||||
# Social Auth (Google, optional)
|
||||
GOOGLE_CLIENT_ID=""
|
||||
@@ -87,20 +89,30 @@ OAUTH_DISCOVERY_URL=""
|
||||
OAUTH_AUTHORIZATION_URL=""
|
||||
OAUTH_TOKEN_URL=""
|
||||
OAUTH_USER_INFO_URL=""
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
|
||||
# Custom scopes (space-separated, defaults to "openid profile email")
|
||||
OAUTH_SCOPES=""
|
||||
|
||||
# Optional Better Auth runtime overrides for advanced deployments:
|
||||
# BETTER_AUTH_URL="https://auth.example.com"
|
||||
# BETTER_AUTH_SECRET=""
|
||||
|
||||
# --- AI (optional) ---
|
||||
# Comma-separated hostnames/origins for custom AI base URLs
|
||||
# Example: api.openai.com,https://gateway.ai.vercel.com
|
||||
AI_ALLOWED_BASE_URLS=""
|
||||
|
||||
# --- Email (optional) ---
|
||||
# If all keys are disabled, the app logs the email to be sent to the console instead.
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT="465"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# --- Storage (optional) ---
|
||||
# If all keys are disabled, the app uses local filesystem (/data) to store uploads instead.
|
||||
# If all keys are disabled, the app uses local filesystem (usually /app/data) to store uploads instead.
|
||||
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
||||
S3_ACCESS_KEY_ID=""
|
||||
S3_SECRET_ACCESS_KEY=""
|
||||
@@ -115,6 +127,7 @@ S3_FORCE_PATH_STYLE="false"
|
||||
FLAG_DEBUG_PRINTER="false"
|
||||
FLAG_DISABLE_SIGNUPS="false"
|
||||
FLAG_DISABLE_EMAIL_AUTH="false"
|
||||
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
||||
```
|
||||
|
||||
</Step>
|
||||
@@ -134,9 +147,6 @@ FLAG_DISABLE_EMAIL_AUTH="false"
|
||||
[byte[]]$bytes = New-Object byte[] 32; (New-Object System.Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes); $bytes | ForEach-Object { "{0:x2}" -f $_ } | Out-String -Stream | ForEach-Object { $_.Trim() } | Write-Host -NoNewline
|
||||
```
|
||||
|
||||
```cmd Windows (alternative)
|
||||
certutil -generateSRS 32 | findstr /r /v "^$" | findstr /v ":" | findstr /v " " | findstr /v "-" | findstr /v "certutil"
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
</Step>
|
||||
@@ -172,10 +182,9 @@ services:
|
||||
- HEALTH=true
|
||||
- CONCURRENT=20
|
||||
- QUEUED=10
|
||||
# Optional: Set a token for authentication
|
||||
# - TOKEN=your-secret-token
|
||||
- TOKEN=${BROWSERLESS_TOKEN}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=your-secret-token"]
|
||||
test: ["CMD-SHELL", 'curl -fsS "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN}" > /dev/null']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -282,11 +291,15 @@ docker compose logs -f reactive-resume
|
||||
SMTP (<code>SMTP_*</code>)
|
||||
</li>
|
||||
<li>
|
||||
Social auth (<code>GOOGLE_*</code>, <code>GITHUB_*</code>)
|
||||
Social auth (<code>GOOGLE_*</code>, <code>GITHUB_*</code>, <code>LINKEDIN_*</code>,{" "}
|
||||
<code>OAUTH_*</code>)
|
||||
</li>
|
||||
<li>
|
||||
S3 storage (<code>S3_*</code>)
|
||||
</li>
|
||||
<li>
|
||||
AI URL allowlist (<code>AI_ALLOWED_BASE_URLS</code>)
|
||||
</li>
|
||||
<li>
|
||||
Feature flags (<code>FLAG_*</code>)
|
||||
</li>
|
||||
@@ -302,7 +315,9 @@ docker compose logs -f reactive-resume
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Printer">
|
||||
- **`PRINTER_ENDPOINT`**: Base URL where Reactive Resume reaches the printer service. In Compose: `http://printer:3000`. If using browserless with token authentication, include the token as a query parameter: `ws://printer:3000?token=your-secret-token`.
|
||||
- **`PRINTER_ENDPOINT`**: Endpoint where Reactive Resume connects to the printer browser.
|
||||
- **Recommended (Browserless)**: `ws://printer:3000?token=...` and keep the token value in sync with Browserless `TOKEN`.
|
||||
- **Also supported**: `http://chrome:9222` for Chrome DevTools Protocol endpoints.
|
||||
|
||||
<Note>
|
||||
**Alternative to browserless**: You can use a lightweight headless Chrome Docker image like `chromedp/headless-shell`:
|
||||
@@ -322,7 +337,9 @@ docker compose logs -f reactive-resume
|
||||
|
||||
<Accordion title="Database (PostgreSQL)">
|
||||
- **`DATABASE_URL`**: Postgres connection string in the format `postgresql://USER:PASSWORD@HOST:PORT/DATABASE`. - In
|
||||
Docker Compose, set `HOST` to the Postgres service name (e.g. `postgres`), not `localhost`.
|
||||
Docker Compose, set `HOST` to the Postgres service name (e.g. `postgres`), not `localhost`. - If your password
|
||||
contains special characters (`@`, `#`, `:`), URL-encode it. - For managed Postgres, add provider-specific params (for
|
||||
example `?sslmode=require`) when needed.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Authentication">
|
||||
@@ -344,9 +361,16 @@ openssl rand -hex 32
|
||||
|
||||
**`LINKEDIN_CLIENT_ID`** / **`LINKEDIN_CLIENT_SECRET`** (optional): Enables LinkedIn sign-in.
|
||||
|
||||
**`BETTER_AUTH_API_KEY`** (optional): Enables Better Auth dashboard integrations.
|
||||
|
||||
**`BETTER_AUTH_URL`** (optional, advanced): Overrides auth base URL if it must differ from `APP_URL` (for split-host deployments).
|
||||
|
||||
**`BETTER_AUTH_SECRET`** (optional, advanced): Overrides `AUTH_SECRET` for Better Auth internals.
|
||||
|
||||
**Custom OAuth provider** (optional):
|
||||
- **`OAUTH_PROVIDER_NAME`**: Display name in the UI
|
||||
- **`OAUTH_CLIENT_ID`** / **`OAUTH_CLIENT_SECRET`**: Required for any custom OAuth provider
|
||||
- **`OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`**: Comma-separated allowlist for extra dynamic OAuth redirect hosts/origins (HTTPS only, non-private hosts).
|
||||
- **`OAUTH_SCOPES`**: Space-separated scopes (defaults to `openid profile email`)
|
||||
|
||||
Configure endpoints using **one** of these methods:
|
||||
@@ -358,8 +382,9 @@ openssl rand -hex 32
|
||||
<Accordion title="Email (SMTP, optional)">
|
||||
If SMTP is not configured, the app logs emails to the server console instead of sending them.
|
||||
|
||||
- Email delivery is enabled only when **all** of `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, and `SMTP_FROM` are set.
|
||||
- **`SMTP_HOST`**: SMTP host (if empty, email sending is disabled).
|
||||
- **`SMTP_PORT`**: Usually `465` (implicit TLS) or `587` (STARTTLS).
|
||||
- **`SMTP_PORT`**: Defaults to `587` in the app.
|
||||
- **`SMTP_USER`** / **`SMTP_PASS`**: SMTP credentials.
|
||||
- **`SMTP_FROM`**: Default from address (for example, `Reactive Resume <noreply@rxresu.me>`).
|
||||
- **`SMTP_SECURE`**: `"true"` or `"false"` (string). Match your provider settings.
|
||||
@@ -367,15 +392,17 @@ openssl rand -hex 32
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Storage (S3 or local)">
|
||||
- **Default (local)**: If all `S3_*` values are empty, uploads are stored under `/app/data`. Mount it to persistent
|
||||
storage (for example `./data:/app/data`) or uploads can be lost on container recreation. - **S3/S3-compatible**:
|
||||
Configure these to store uploads in an S3-compatible service (SeaweedFS, MinIO, AWS S3, etc.): -
|
||||
**`S3_ACCESS_KEY_ID`** - **`S3_SECRET_ACCESS_KEY`** - **`S3_REGION`** - **`S3_ENDPOINT`** (for S3-compatible
|
||||
providers; may be blank for AWS depending on your setup) - **`S3_BUCKET`** - **`S3_FORCE_PATH_STYLE`**: Controls how
|
||||
the bucket is addressed in URLs. Defaults to `"false"`. - Set to `"true"` for **path-style** URLs
|
||||
(`https://s3-server.com/bucket`). Common with **MinIO**, **SeaweedFS**, and other self-hosted S3-compatible services.
|
||||
- Set to `"false"` for **virtual-hosted-style** URLs (`https://bucket.s3-server.com`). Common with **AWS S3**,
|
||||
**Cloudflare R2**, and most cloud providers.
|
||||
- **Default (local)**: If all `S3_*` values are empty, uploads are stored under `<cwd>/data` (usually `/app/data` in the official image).
|
||||
- Mount local uploads to persistent storage (for example `./data:/app/data`) or uploads can be lost on container recreation.
|
||||
- **S3/S3-compatible**: Configure `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION`, `S3_ENDPOINT`, and `S3_BUCKET`.
|
||||
- **`S3_FORCE_PATH_STYLE`** controls bucket addressing (defaults to `"false"`):
|
||||
- `"true"` for path-style URLs (`https://endpoint/bucket`) common with MinIO/SeaweedFS.
|
||||
- `"false"` for virtual-hosted-style URLs (`https://bucket.endpoint`) common with AWS S3 / Cloudflare R2.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="AI (optional)">
|
||||
- **`AI_ALLOWED_BASE_URLS`**: Comma-separated hosts or origins allowed as custom AI API base URLs. - Use this when
|
||||
routing AI requests through your own gateway/proxy. - Example: `api.openai.com,https://gateway.ai.vercel.com`
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Feature Flags">
|
||||
@@ -390,24 +417,32 @@ openssl rand -hex 32
|
||||
|
||||
To update your Reactive Resume installation to the latest available version, follow these steps:
|
||||
|
||||
1. **Pull the latest images** for all services defined in your Docker Compose file.
|
||||
1. **Back up your database and uploads first** (highly recommended before every update).
|
||||
|
||||
2. **Pull the latest images** for all services defined in your Docker Compose file.
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
2. **Restart the containers** to run the new images.
|
||||
3. **Restart the containers** to run the new images.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
3. **(Optional) Remove old, unused Docker images** to free up disk space.
|
||||
4. **Check migration/startup logs** after deploy.
|
||||
|
||||
```bash
|
||||
docker compose logs -f reactive-resume
|
||||
```
|
||||
|
||||
5. **(Optional) Remove old, unused Docker images** to free up disk space.
|
||||
```bash
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
This process ensures your app, database, and printer are all up-to-date while keeping your data and configuration intact.
|
||||
This process updates app services and automatically runs DB migrations on startup. If migration fails, restore from backup and fix configuration before retrying.
|
||||
|
||||
## Backups (recommended)
|
||||
|
||||
@@ -425,7 +460,7 @@ If you're using S3-compatible storage, consider enabling versioning on your buck
|
||||
|
||||
## Health Checks
|
||||
|
||||
Reactive Resume exposes a health check endpoint at `/api/health` that verifies the application and its dependencies are functioning correctly. If any critical service (such as the database connection) fails, the health check will return an unhealthy status.
|
||||
Reactive Resume exposes a health check endpoint at `/api/health` that verifies the application and its dependencies. It checks **database**, **printer**, and **storage**; if any one is unhealthy, the endpoint returns HTTP `503`.
|
||||
|
||||
### How it works
|
||||
|
||||
@@ -448,7 +483,7 @@ Most reverse proxies (such as **Traefik**, **Caddy**, or **nginx** with upstream
|
||||
- **Healthy containers** receive traffic as normal
|
||||
- **Unhealthy containers** are automatically removed from the load balancer pool
|
||||
|
||||
This is particularly useful in high-availability setups where you have multiple instances of Reactive Resume. If one instance becomes unhealthy (for example, it loses its database connection), the reverse proxy will stop routing traffic to it until it recovers.
|
||||
This is particularly useful in high-availability setups where you have multiple instances of Reactive Resume. If one instance becomes unhealthy (for example, it loses database, printer, or storage connectivity), the reverse proxy will stop routing traffic to it until it recovers.
|
||||
|
||||
<Tip>
|
||||
If you're using **Traefik**, it automatically respects Docker health checks when using the Docker provider. Unhealthy
|
||||
@@ -467,40 +502,51 @@ curl -f http://localhost:3000/api/health
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
A healthy response returns HTTP 200. Any other response (or a connection failure) indicates a problem that should be investigated in the container logs.
|
||||
A healthy response returns HTTP 200. Any other response (or a connection failure) indicates a problem that should be investigated in the JSON response body and container logs.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The app container exits immediately">
|
||||
- **Common cause**: database migrations failed (often a bad `DATABASE_URL`).
|
||||
- **What to do**:
|
||||
When the app container exits right away, you'll want to check the logs for more information about the error. Run the following command to view real-time logs from the Reactive Resume container:
|
||||
- **What to do**:
|
||||
Check logs for migration errors and database connectivity details:
|
||||
```bash
|
||||
docker compose logs -f reactive-resume
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can't sign in / redirects loop / cookies don't stick">
|
||||
- **Common cause**: `APP_URL` doesn't match the URL you're actually using (especially behind a reverse proxy). -
|
||||
**Fix**: set `APP_URL` to the public URL (preferably HTTPS) and restart the container.
|
||||
- **Common cause**: `APP_URL` doesn't match the URL you're actually using (especially behind a reverse proxy), or
|
||||
you're serving HTTPS while `APP_URL` is `http://...`. - **Fix**: set `APP_URL` to your canonical public HTTPS URL and
|
||||
restart the container.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="PDF export fails / printing is stuck">
|
||||
- **Common cause**: Reactive Resume can't reach the printer or the printer can't reach your app. - **Checks**: -
|
||||
`PRINTER_ENDPOINT` should usually be `http://printer:3000` in Compose. - If you use
|
||||
`PRINTER_APP_URL="http://host.docker.internal:3000"`, ensure `extra_hosts: host-gateway` is present for the printer
|
||||
service.
|
||||
- **Common cause**: Reactive Resume can't reach the printer, token mismatch, or the printer can't reach your app. -
|
||||
**Checks**: - `PRINTER_ENDPOINT` should usually be `ws://printer:3000?token=...` in Compose. - Browserless `TOKEN` and
|
||||
the token in `PRINTER_ENDPOINT` must match. - If you use `PRINTER_APP_URL="http://host.docker.internal:3000"` on
|
||||
Linux, set `extra_hosts: ["host.docker.internal:host-gateway"]` for the printer service.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="/api/health returns 503 even though Postgres is up">
|
||||
- **Common cause**: printer or storage health failed (not only database). - **Fix**: inspect the endpoint response
|
||||
payload and check `printer` / `storage` fields: ```bash curl -s http://localhost:3000/api/health ```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Uploads disappear after restart">
|
||||
- **Cause**: you didn't mount persistent storage for `/app/data` (when not using S3). - **Fix**: add a volume mount
|
||||
like `./data:/app/data` and redeploy.
|
||||
- **Cause**: local upload storage wasn't mounted to a persistent volume. - **Fix**: add a volume mount like
|
||||
`./data:/app/data` and redeploy.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Emails aren't being delivered">
|
||||
- **Expected behavior**: if SMTP vars are empty, the app logs emails to the console instead. - **Fix**: configure SMTP
|
||||
and verify your provider's TLS/port settings.
|
||||
- **Expected behavior**: if SMTP isn't fully configured, the app logs emails to the console. - **Fix**: set
|
||||
`SMTP_HOST`, `SMTP_USER`, `SMTP_PASS`, and `SMTP_FROM`, then verify `SMTP_PORT` and `SMTP_SECURE`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Dynamic OAuth redirect URI is rejected">
|
||||
- **Common cause**: redirect host is not trusted for dynamic client registration. - **Fix**: add trusted HTTPS
|
||||
hosts/origins to `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="S3 storage error: ENOTFOUND bucket.endpoint">
|
||||
|
||||
@@ -85,12 +85,11 @@ services:
|
||||
- QUEUED=10
|
||||
- HEALTH=true
|
||||
- CONCURRENT=5
|
||||
# Optional: Set a token for authentication
|
||||
# - TOKEN=your-secret-token
|
||||
- TOKEN=${BROWSERLESS_TOKEN}
|
||||
networks:
|
||||
- reactive_resume_network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=your-secret-token"]
|
||||
test: ["CMD-SHELL", 'curl -fsS "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN}" > /dev/null']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -102,7 +101,7 @@ services:
|
||||
- APP_URL=https://resume.${DOMAIN}
|
||||
- PRINTER_APP_URL=http://reactive_resume:3000
|
||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
|
||||
- PRINTER_ENDPOINT=http://printer:3000
|
||||
- PRINTER_ENDPOINT=ws://printer:3000?token=${BROWSERLESS_TOKEN}
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
# Add other optional env vars as needed (SMTP, S3, OAuth, etc.)
|
||||
volumes:
|
||||
@@ -143,6 +142,7 @@ DOMAIN="example.com"
|
||||
ACME_EMAIL="admin@example.com"
|
||||
POSTGRES_PASSWORD="your-secure-postgres-password"
|
||||
AUTH_SECRET="your-auth-secret-from-openssl-rand-hex-32"
|
||||
BROWSERLESS_TOKEN="your-browserless-token"
|
||||
# Optional: Traefik dashboard auth (generate with: htpasswd -nb admin password)
|
||||
TRAEFIK_DASHBOARD_AUTH="admin:$$apr1$$..."
|
||||
```
|
||||
@@ -191,10 +191,14 @@ services:
|
||||
- QUEUED=10
|
||||
- HEALTH=true
|
||||
- CONCURRENT=5
|
||||
# Optional: Set a token for authentication
|
||||
# - TOKEN=your-secret-token
|
||||
- TOKEN=${BROWSERLESS_TOKEN}
|
||||
networks:
|
||||
- reactive_resume_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", 'curl -fsS "http://localhost:3000/pressure?token=${BROWSERLESS_TOKEN}" > /dev/null']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
reactive_resume:
|
||||
image: amruthpillai/reactive-resume:latest
|
||||
@@ -203,7 +207,7 @@ services:
|
||||
- APP_URL=https://resume.${DOMAIN}
|
||||
- PRINTER_APP_URL=http://reactive_resume:3000
|
||||
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
|
||||
- PRINTER_ENDPOINT=http://printer:3000
|
||||
- PRINTER_ENDPOINT=ws://printer:3000?token=${BROWSERLESS_TOKEN}
|
||||
- AUTH_SECRET=${AUTH_SECRET}
|
||||
# Add other optional env vars as needed (SMTP, S3, OAuth, etc.)
|
||||
volumes:
|
||||
@@ -339,10 +343,9 @@ services:
|
||||
- QUEUED=10
|
||||
- HEALTH=true
|
||||
- CONCURRENT=5
|
||||
# Optional: Set a token for authentication
|
||||
# - TOKEN=your-secret-token
|
||||
- TOKEN=$BROWSERLESS_TOKEN
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/pressure?token=your-secret-token"]
|
||||
test: ["CMD-SHELL", 'curl -fsS "http://localhost:3000/pressure?token=$BROWSERLESS_TOKEN" > /dev/null']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -469,10 +472,10 @@ docker stack deploy -c compose-swarm.yml reactive_resume
|
||||
docker stack services reactive_resume
|
||||
|
||||
# View logs for the app
|
||||
docker service logs -f reactive_resume_app
|
||||
docker service logs -f reactive_resume_reactive_resume
|
||||
|
||||
# Scale the app
|
||||
docker service scale reactive_resume_app=3
|
||||
docker service scale reactive_resume_reactive_resume=3
|
||||
|
||||
# Remove the stack
|
||||
docker stack rm reactive_resume
|
||||
|
||||
@@ -7,6 +7,11 @@ description: "A step-by-step guide to migrate your Reactive Resume instance from
|
||||
|
||||
This guide walks you through migrating your Reactive Resume installation from **v4 to v5**. The migration process involves setting up a new v5 instance alongside your existing v4 instance, then transferring your users and resumes to the new system.
|
||||
|
||||
<Info>
|
||||
This page is for **v4 → v5 data migration** only. For normal v5 upgrades, use the [Self-Hosting with
|
||||
Docker](/self-hosting/docker) guide. v5 schema migrations run automatically on app startup.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
**Keep your v4 instance running** until you have successfully migrated all data to v5 and verified everything works
|
||||
correctly. This ensures you have a fallback in case anything goes wrong during the migration.
|
||||
@@ -86,7 +91,7 @@ To run the migration scripts, you need the following installed on your host mach
|
||||
</Card>
|
||||
<Card title="Reactive Resume Source Code">
|
||||
Clone the Reactive Resume repository to access the migration scripts: ```bash git clone
|
||||
https://github.com/amruthpillai/reactive-resume.git cd reactive-resume pnpm install ```
|
||||
https://github.com/amruthpillai/reactive-resume.git cd reactive-resume vp install ```
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -107,6 +112,8 @@ PRODUCTION_DATABASE_URL="postgresql://user:password@localhost:5432/reactive_resu
|
||||
`PRODUCTION_DATABASE_URL` should point to your **old v4 database**. Mixing these up could cause data loss.
|
||||
</Warning>
|
||||
|
||||
<Warning>`PRODUCTION_DATABASE_URL` is used only by these migration scripts. It is not a runtime app variable.</Warning>
|
||||
|
||||
### Step 1: Migrate Users
|
||||
|
||||
The user migration script transfers all user accounts, authentication data, and two-factor settings from v4 to v5.
|
||||
|
||||
@@ -62,10 +62,13 @@ You must configure endpoints using **one** of these two methods:
|
||||
|
||||
### Optional Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------- | ---------------------------------------- | ---------------------- |
|
||||
| `OAUTH_PROVIDER_NAME` | Display name shown on the sign-in button | `Custom OAuth` |
|
||||
| `OAUTH_SCOPES` | Space-separated list of OAuth scopes | `openid profile email` |
|
||||
| Variable | Description | Default |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------- |
|
||||
| `OAUTH_PROVIDER_NAME` | Display name shown on the sign-in button | `Custom OAuth` |
|
||||
| `OAUTH_SCOPES` | Space-separated list of OAuth scopes | `openid profile email` |
|
||||
| `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS` | Comma-separated allowlist for dynamic OAuth client redirect hosts/origins (HTTPS only) | _empty_ |
|
||||
| `BETTER_AUTH_URL` | Optional auth base URL override for split-host setups | `APP_URL` |
|
||||
| `BETTER_AUTH_SECRET` | Optional Better Auth secret override | `AUTH_SECRET` |
|
||||
|
||||
## Callback URL
|
||||
|
||||
@@ -86,6 +89,18 @@ https://resume.example.com/api/auth/oauth2/callback/custom
|
||||
authentication to fail.
|
||||
</Warning>
|
||||
|
||||
<Info>
|
||||
Built-in providers (Google, GitHub, LinkedIn) use callback URLs in this format: `{APP_URL}/api/auth/callback/
|
||||
{provider}` (for example `.../google`, `.../github`, `.../linkedin`).
|
||||
</Info>
|
||||
|
||||
## URL and Proxy Requirements
|
||||
|
||||
- Set `APP_URL` to the exact public URL users access (prefer HTTPS in production).
|
||||
- If auth metadata/JWKS must be served from a different public host, set `BETTER_AUTH_URL`.
|
||||
- Behind a reverse proxy, forward `Host` and `X-Forwarded-Proto` correctly, or cookie/session behavior may break.
|
||||
- `trustedOrigins` are derived from `APP_URL`, so alternate domains are not automatically trusted.
|
||||
|
||||
## Profile Mapping
|
||||
|
||||
Reactive Resume automatically maps user profile data from the OAuth provider. The following fields are used:
|
||||
@@ -280,6 +295,11 @@ OAUTH_DISCOVERY_URL="https://auth.company.com/application/o/reactive-resume/.wel
|
||||
number differences - Path case sensitivity
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Session/cookie issues after successful OAuth login">
|
||||
Common cause: `APP_URL` does not match the real HTTPS public origin (for example, app is behind TLS but `APP_URL` is
|
||||
`http://...`). Fix: set `APP_URL` to the canonical HTTPS URL and restart the app.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Custom OAuth button not appearing">
|
||||
The custom OAuth option only appears if both `OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are set, **and** either:
|
||||
- `OAUTH_DISCOVERY_URL` is set, **or**
|
||||
@@ -295,6 +315,11 @@ OAUTH_DISCOVERY_URL="https://auth.company.com/application/o/reactive-resume/.wel
|
||||
domain
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Dynamic client redirect URI is rejected">
|
||||
Dynamic OAuth client registration only allows HTTPS redirect URIs on trusted hosts. Add allowed hosts/origins to
|
||||
`OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS` (comma-separated).
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="User profile data is missing or incorrect">
|
||||
The profile mapping depends on your provider returning standard claims:
|
||||
- `email` (required)
|
||||
@@ -319,6 +344,9 @@ OAUTH_DISCOVERY_URL="https://auth.company.com/application/o/reactive-resume/.wel
|
||||
<Card title="Verify redirect URIs" icon="shield-check">
|
||||
Configure your OAuth provider to only allow the exact redirect URI. Avoid wildcards in redirect URI configurations.
|
||||
</Card>
|
||||
<Card title="Protect auth internals" icon="shield">
|
||||
Keep `BETTER_AUTH_SECRET` and `BETTER_AUTH_API_KEY` private. Rotating these values may invalidate active sessions.
|
||||
</Card>
|
||||
<Card title="Review scopes" icon="list-check">
|
||||
Only request the scopes you need. The default (`openid profile email`) is sufficient for Reactive Resume.
|
||||
</Card>
|
||||
|
||||
@@ -1686,6 +1686,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
@@ -5050,6 +5051,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
@@ -7222,6 +7224,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
@@ -9428,6 +9431,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
@@ -11741,6 +11745,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
@@ -14020,6 +14025,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
@@ -16189,6 +16195,7 @@
|
||||
"kakuna",
|
||||
"lapras",
|
||||
"leafish",
|
||||
"meowth",
|
||||
"onyx",
|
||||
"pikachu",
|
||||
"rhyhorn"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"entry": ["src/server.ts", "src/router.tsx"],
|
||||
"entry": ["src/server.ts", "src/router.tsx", "lingui.config.ts"],
|
||||
"tailwind": { "entry": ["src/styles.css"] },
|
||||
"project": ["src/**/*.{js,jsx,ts,tsx,mdx,css}"],
|
||||
"project": ["src/**/*.{js,jsx,ts,tsx,css}"],
|
||||
"ignoreBinaries": ["mint"],
|
||||
"ignoreDependencies": ["npm-check-updates", "node-addon-api", "node-gyp"],
|
||||
"ignore": ["src/schema/**/*.ts", "src/components/ui/**/*.{ts,tsx}"]
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "@lingui/cli";
|
||||
import { formatter } from "@lingui/format-po";
|
||||
|
||||
export default defineConfig({
|
||||
sourceLocale: "en-US",
|
||||
@@ -64,9 +65,9 @@ export default defineConfig({
|
||||
"zu-ZA": "en-US",
|
||||
default: "en-US",
|
||||
},
|
||||
formatOptions: {
|
||||
format: formatter({
|
||||
lineNumbers: false,
|
||||
},
|
||||
}),
|
||||
catalogs: [
|
||||
{
|
||||
path: "<rootDir>/locales/{locale}",
|
||||
|
||||
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+862
-209
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+763
-111
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+780
-127
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+784
-131
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+862
-209
File diff suppressed because it is too large
Load Diff
Generated
+862
-209
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+784
-131
File diff suppressed because it is too large
Load Diff
Generated
+862
-209
File diff suppressed because it is too large
Load Diff
Generated
+862
-209
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+786
-133
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+785
-132
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+781
-128
File diff suppressed because it is too large
Load Diff
Generated
+783
-130
File diff suppressed because it is too large
Load Diff
Generated
+782
-129
File diff suppressed because it is too large
Load Diff
Generated
+758
-106
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX "user_email_lower_unique_idx" ON "user" (lower("email"));
|
||||
File diff suppressed because it is too large
Load Diff
+85
-78
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reactive-resume",
|
||||
"version": "5.0.17",
|
||||
"version": "5.0.20",
|
||||
"description": "Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -22,8 +22,9 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"dev": "vp dev",
|
||||
"docs:dev": "cd docs && npx mint dev",
|
||||
"fmt": "vp fmt --check",
|
||||
"fmt:fix": "vp fmt",
|
||||
"email:dev": "email dev --dir src/integrations/email/templates --port 3001",
|
||||
"format": "vp fmt --check",
|
||||
"format:fix": "vp fmt",
|
||||
"knip": "knip",
|
||||
"lingui:extract": "lingui extract --clean --overwrite",
|
||||
"lint": "vp lint --type-aware",
|
||||
@@ -35,89 +36,95 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.68",
|
||||
"@ai-sdk/google": "^3.0.60",
|
||||
"@ai-sdk/openai": "^3.0.52",
|
||||
"@aws-sdk/client-s3": "^3.1027.0",
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@better-auth/api-key": "^1.6.1",
|
||||
"@better-auth/drizzle-adapter": "^1.6.1",
|
||||
"@better-auth/infra": "^0.1.14",
|
||||
"@better-auth/oauth-provider": "^1.6.1",
|
||||
"@ai-sdk/anthropic": "^3.0.71",
|
||||
"@ai-sdk/google": "^3.0.64",
|
||||
"@ai-sdk/openai": "^3.0.53",
|
||||
"@ai-sdk/openai-compatible": "^2.0.41",
|
||||
"@aws-sdk/client-s3": "^3.1037.0",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@better-auth/api-key": "^1.6.9",
|
||||
"@better-auth/drizzle-adapter": "^1.6.9",
|
||||
"@better-auth/infra": "^0.2.5",
|
||||
"@better-auth/oauth-provider": "^1.6.9",
|
||||
"@better-auth/passkey": "^1.6.9",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lingui/core": "^5.9.5",
|
||||
"@lingui/react": "^5.9.5",
|
||||
"@lingui/core": "^6.0.0",
|
||||
"@lingui/react": "^6.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "4.8.0-rc.3",
|
||||
"@orpc/client": "^1.13.13",
|
||||
"@orpc/json-schema": "^1.13.13",
|
||||
"@orpc/openapi": "^1.13.13",
|
||||
"@orpc/server": "^1.13.13",
|
||||
"@orpc/tanstack-query": "^1.13.13",
|
||||
"@orpc/zod": "^1.13.13",
|
||||
"@orpc/client": "^1.14.0",
|
||||
"@orpc/experimental-ratelimit": "^1.14.0",
|
||||
"@orpc/json-schema": "^1.14.0",
|
||||
"@orpc/openapi": "^1.14.0",
|
||||
"@orpc/server": "^1.14.0",
|
||||
"@orpc/tanstack-query": "^1.14.0",
|
||||
"@orpc/zod": "^1.14.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.10",
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@tanstack/react-router": "^1.168.25",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.12",
|
||||
"@tanstack/react-start": "^1.167.50",
|
||||
"@tanstack/zod-adapter": "^1.166.9",
|
||||
"@tiptap/extension-highlight": "^3.22.3",
|
||||
"@tiptap/extension-table": "^3.22.3",
|
||||
"@tiptap/extension-text-align": "^3.22.3",
|
||||
"@tiptap/pm": "^3.22.3",
|
||||
"@tiptap/react": "^3.22.3",
|
||||
"@tiptap/starter-kit": "^3.22.3",
|
||||
"@tiptap/extension-color": "^3.22.4",
|
||||
"@tiptap/extension-highlight": "^3.22.4",
|
||||
"@tiptap/extension-table": "^3.22.4",
|
||||
"@tiptap/extension-text-align": "^3.22.4",
|
||||
"@tiptap/extension-text-style": "^3.22.4",
|
||||
"@tiptap/pm": "^3.22.4",
|
||||
"@tiptap/react": "^3.22.4",
|
||||
"@tiptap/starter-kit": "^3.22.4",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"@uiw/react-color-colorful": "^2.10.1",
|
||||
"ai": "^6.0.154",
|
||||
"ai-sdk-ollama": "^3.8.3",
|
||||
"ai": "^6.0.168",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.6.1",
|
||||
"better-auth": "^1.6.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"docx": "^9.6.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"drizzle-orm": "1.0.0-beta.21",
|
||||
"dompurify": "^3.4.1",
|
||||
"drizzle-orm": "1.0.0-beta.22",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"es-toolkit": "^1.46.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^7.3.0",
|
||||
"immer": "^11.1.4",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsonrepair": "^3.13.3",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.38.0",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "^8.0.7",
|
||||
"ollama-ai-provider-v2": "^3.5.0",
|
||||
"pg": "^8.20.0",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"puppeteer-core": "^24.42.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-email": "^6.0.0",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-resizable-panels": "^4.9.0",
|
||||
"react-resizable-panels": "^4.10.0",
|
||||
"react-window": "^2.2.7",
|
||||
"react-zoom-pan-pinch": "^4.0.3",
|
||||
"shadcn": "^4.2.0",
|
||||
"shadcn": "^4.5.0",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"srvx": "^0.11.15",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^14.0.0",
|
||||
"workbox-window": "^7.4.0",
|
||||
"zod": "^4.3.6",
|
||||
"zundo": "^2.3.0",
|
||||
@@ -125,58 +132,58 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
"@lingui/babel-plugin-lingui-macro": "^5.9.5",
|
||||
"@lingui/cli": "^5.9.5",
|
||||
"@lingui/vite-plugin": "^5.9.5",
|
||||
"@rolldown/plugin-babel": "^0.2.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@lingui/babel-plugin-lingui-macro": "^6.0.0",
|
||||
"@lingui/cli": "^6.0.0",
|
||||
"@lingui/format-po": "^6.0.0",
|
||||
"@lingui/vite-plugin": "^6.0.0",
|
||||
"@react-email/ui": "^6.0.0",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.5.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260408.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260427.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.3",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"drizzle-kit": "1.0.0-beta.21",
|
||||
"happy-dom": "^20.8.9",
|
||||
"drizzle-kit": "1.0.0-beta.22",
|
||||
"happy-dom": "^20.9.0",
|
||||
"jose": "^6.2.2",
|
||||
"knip": "^6.3.1",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"knip": "^6.7.0",
|
||||
"nitro": "3.0.260415-beta",
|
||||
"node-addon-api": "^8.7.0",
|
||||
"node-gyp": "^12.2.0",
|
||||
"npm-check-updates": "^20.0.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.16",
|
||||
"node-gyp": "^12.3.0",
|
||||
"npm-check-updates": "^22.0.1",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.19",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plus": "latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.16"
|
||||
"vite-plus": "^0.1.19",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.19"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@isaacs/brace-expansion": "^5.0.1",
|
||||
"ajv": "^8.18.0",
|
||||
"basic-ftp": "^5.2.0",
|
||||
"fast-xml-parser": "^5.3.8",
|
||||
"hono": "^4.11.10",
|
||||
"lodash": "^4.17.23",
|
||||
"markdown-it": "^14.1.1",
|
||||
"minimatch": "^10.2.3",
|
||||
"qs": "^6.14.2",
|
||||
"serialize-javascript": "^7.0.4",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"bcrypt",
|
||||
"esbuild",
|
||||
"msw",
|
||||
"prisma",
|
||||
"sharp"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"dompurify@<=3.3.1": ">=3.3.2",
|
||||
"dompurify@<=3.3.3": ">=3.4.0",
|
||||
"dompurify@<3.3.2": ">=3.3.2",
|
||||
"dompurify@<3.4.0": ">=3.4.0",
|
||||
"dompurify@>=1.0.10 <3.4.0": ">=3.4.0",
|
||||
"dompurify@>=3.0.1 <3.4.0": ">=3.4.0",
|
||||
"dompurify@>=3.1.3 <=3.3.1": ">=3.3.2",
|
||||
"postcss@<8.5.10": ">=8.5.10",
|
||||
"serialize-javascript@<=7.0.2": ">=7.0.3",
|
||||
"serialize-javascript@<7.0.5": ">=7.0.5",
|
||||
"uuid@<14.0.0": ">=14.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+3760
-3948
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"name": "Reactive Resume",
|
||||
"short_name": "Reactive Resume",
|
||||
"description": "A free and open-source resume builder.",
|
||||
"start_url": "/?source=pwa",
|
||||
"display": "standalone",
|
||||
"background_color": "#09090B",
|
||||
"theme_color": "#09090B",
|
||||
"lang": "en",
|
||||
"scope": "/",
|
||||
"id": "/?source=pwa",
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{ "src": "favicon.ico", "sizes": "128x128", "type": "image/x-icon" },
|
||||
{ "src": "pwa-64x64.png", "sizes": "64x64", "type": "image/png" },
|
||||
{ "src": "pwa-192x192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "pwa-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "maskable-icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "screenshots/web/1-landing-page.webp",
|
||||
"sizes": "1920x1080 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "wide",
|
||||
"label": "Landing Page"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/web/2-resume-dashboard.webp",
|
||||
"sizes": "1920x1080 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "wide",
|
||||
"label": "Resume Dashboard"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/web/3-builder-screen.webp",
|
||||
"sizes": "1920x1080 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "wide",
|
||||
"label": "Builder Screen"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/web/4-template-gallery.webp",
|
||||
"sizes": "1920x1080 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "wide",
|
||||
"label": "Template Gallery"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/mobile/1-landing-page.webp",
|
||||
"sizes": "1284x2778 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow",
|
||||
"label": "Landing Page"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/mobile/2-resume-dashboard.webp",
|
||||
"sizes": "1284x2778 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow",
|
||||
"label": "Resume Dashboard"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/mobile/3-builder-screen.webp",
|
||||
"sizes": "1284x2778 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow",
|
||||
"label": "Builder Screen"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/mobile/4-template-gallery.webp",
|
||||
"sizes": "1284x2778 any",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow",
|
||||
"label": "Template Gallery"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"ai",
|
||||
"builder",
|
||||
"business",
|
||||
"career",
|
||||
"cv",
|
||||
"editor",
|
||||
"free",
|
||||
"generator",
|
||||
"job-search",
|
||||
"multilingual",
|
||||
"open-source",
|
||||
"privacy",
|
||||
"productivity",
|
||||
"resume",
|
||||
"self-hosted",
|
||||
"templates",
|
||||
"utilities",
|
||||
"writing"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 954 KiB |
Binary file not shown.
@@ -99,9 +99,9 @@ function getComputerModernWebFonts(): WebFont[] {
|
||||
preview: `${CDN}/Sans/cmunss.woff`,
|
||||
files: {
|
||||
"400": `${CDN}/Sans/cmunss.woff`,
|
||||
"400italic": `${CDN}/Sans/cmunsl.woff`,
|
||||
"400italic": `${CDN}/Sans/cmunsi.woff`,
|
||||
"700": `${CDN}/Sans/cmunsx.woff`,
|
||||
"700italic": `${CDN}/Sans/cmunsi.woff`,
|
||||
"700italic": `${CDN}/Sans/cmunso.woff`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useRef } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
@@ -81,33 +82,65 @@ export function CommandPalette() {
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogHeader className="sr-only print:hidden">
|
||||
<DialogTitle>
|
||||
<Trans>Builder Command Palette</Trans>
|
||||
<Trans comment="Screen-reader dialog title for the command palette in the resume builder">
|
||||
Builder Command Palette
|
||||
</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Type a command or search...</Trans>
|
||||
<Trans comment="Screen-reader dialog description instructing users how to use the command palette">
|
||||
Type a command or search...
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0"
|
||||
aria-label={isFirstPage ? "Command Palette" : `Command Palette - ${currentPage}`}
|
||||
aria-label={
|
||||
isFirstPage
|
||||
? t({
|
||||
comment: "Accessible label for the command palette dialog",
|
||||
message: "Command Palette",
|
||||
})
|
||||
: t({
|
||||
comment: "Accessible label for command palette dialog when browsing a nested command page",
|
||||
message: `Command Palette - ${currentPage}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Command
|
||||
loop
|
||||
aria-label="Command Palette"
|
||||
aria-label={t({
|
||||
comment: "Accessible label for command list region inside command palette",
|
||||
message: "Command Palette",
|
||||
})}
|
||||
className="[&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3"
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
placeholder={isFirstPage ? "Type a command or search..." : "Search..."}
|
||||
aria-label="Search commands"
|
||||
placeholder={
|
||||
isFirstPage
|
||||
? t({
|
||||
comment: "Placeholder in command palette input on root page",
|
||||
message: "Type a command or search...",
|
||||
})
|
||||
: t({
|
||||
comment: "Placeholder in command palette input on nested pages",
|
||||
message: "Search...",
|
||||
})
|
||||
}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for command palette search input",
|
||||
message: "Search commands",
|
||||
})}
|
||||
/>
|
||||
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<Trans>The command you're looking for doesn't exist.</Trans>
|
||||
<Trans comment="Empty-state message when no command palette results match the search query">
|
||||
The command you're looking for doesn't exist.
|
||||
</Trans>
|
||||
</CommandEmpty>
|
||||
|
||||
<ResumesCommandGroup />
|
||||
|
||||
@@ -95,12 +95,12 @@ export function NavigationCommandGroup() {
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
keywords={[t`Artificial Intelligence`]}
|
||||
value="navigation.settings.ai"
|
||||
onSelect={() => onNavigate("/dashboard/settings/ai")}
|
||||
keywords={[t`Integrations`, t`Artificial Intelligence`, t`Job Search API`]}
|
||||
value="navigation.settings.integrations"
|
||||
onSelect={() => onNavigate("/dashboard/settings/integrations")}
|
||||
>
|
||||
<OpenAiLogoIcon />
|
||||
<Trans>Artificial Intelligence</Trans>
|
||||
<Trans>Integrations</Trans>
|
||||
</CommandItem>
|
||||
|
||||
<CommandItem
|
||||
|
||||
@@ -73,7 +73,9 @@ export function ResumesCommandGroup() {
|
||||
{resume.name}
|
||||
|
||||
<CommandShortcut className="opacity-0 transition-opacity group-data-[selected=true]/command-item:opacity-100">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
<Trans comment="Command palette hint that pressing Enter opens the selected resume">
|
||||
Press <Kbd>Enter</Kbd> to open
|
||||
</Trans>
|
||||
</CommandShortcut>
|
||||
</CommandItem>
|
||||
))
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import type * as DndKitCore from "@dnd-kit/core";
|
||||
import type * as DndKitSortable from "@dnd-kit/sortable";
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { ChipInput } from "./chip-input";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock dnd-kit
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
closestCenter: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
closestCenter: vi.fn<typeof DndKitCore.closestCenter>(),
|
||||
KeyboardSensor: vi.fn<typeof DndKitCore.KeyboardSensor>(),
|
||||
PointerSensor: vi.fn<typeof DndKitCore.PointerSensor>(),
|
||||
useSensor: vi.fn<typeof DndKitCore.useSensor>(),
|
||||
useSensors: vi.fn<typeof DndKitCore.useSensors>(),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
sortableKeyboardCoordinates: vi.fn(),
|
||||
horizontalListSortingStrategy: vi.fn<typeof DndKitSortable.horizontalListSortingStrategy>(),
|
||||
sortableKeyboardCoordinates: vi.fn<typeof DndKitSortable.sortableKeyboardCoordinates>(),
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
setNodeRef: vi.fn<(node: HTMLElement | null) => void>(),
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
@@ -39,6 +44,25 @@ vi.mock("motion/react", () => ({
|
||||
}));
|
||||
|
||||
// Mock lingui
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (stringsOrDescriptor: TemplateStringsArray | { message: string }, ...values: unknown[]) => {
|
||||
if (
|
||||
typeof stringsOrDescriptor === "object" &&
|
||||
"message" in stringsOrDescriptor &&
|
||||
typeof stringsOrDescriptor.message === "string"
|
||||
) {
|
||||
return stringsOrDescriptor.message;
|
||||
}
|
||||
|
||||
const strings = stringsOrDescriptor as TemplateStringsArray;
|
||||
let result = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
result += String(values[i]) + strings[i + 1];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@lingui/react/macro", () => ({
|
||||
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
@@ -49,8 +73,6 @@ vi.mock("@phosphor-icons/react", () => ({
|
||||
XIcon: ({ className }: any) => <span data-testid="remove-icon" className={className} />,
|
||||
}));
|
||||
|
||||
import { ChipInput } from "./chip-input";
|
||||
|
||||
describe("ChipInput", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders the input field", () => {
|
||||
@@ -85,7 +107,7 @@ describe("ChipInput", () => {
|
||||
|
||||
describe("adding chips via Enter key", () => {
|
||||
it("adds a chip when Enter is pressed", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
@@ -96,7 +118,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("does not add empty chips", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
@@ -107,7 +129,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("trims whitespace from chips", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
@@ -118,7 +140,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("prevents duplicate chips", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={["React"]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
@@ -132,7 +154,7 @@ describe("ChipInput", () => {
|
||||
|
||||
describe("adding chips via comma", () => {
|
||||
it("adds a chip when comma is typed in the input", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
@@ -142,7 +164,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("handles multiple comma-separated values", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
@@ -155,7 +177,7 @@ describe("ChipInput", () => {
|
||||
|
||||
describe("removing chips", () => {
|
||||
it("calls onChange without the removed chip when remove button is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={["React", "Vue", "Angular"]} onChange={onChange} />);
|
||||
|
||||
// Find all remove buttons
|
||||
@@ -166,7 +188,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("removes the first chip correctly", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
|
||||
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
@@ -176,7 +198,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("removes the last chip correctly", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
|
||||
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
@@ -212,7 +234,7 @@ describe("ChipInput", () => {
|
||||
});
|
||||
|
||||
it("saves edit when Enter is pressed", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<NonNullable<React.ComponentProps<typeof ChipInput>["onChange"]>>();
|
||||
render(<ChipInput value={["React"]} onChange={onChange} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit React");
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { motion } from "motion/react";
|
||||
@@ -78,7 +79,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Edit ${chip}`}
|
||||
aria-label={t({
|
||||
comment:
|
||||
"Screen reader label for button that edits a keyword chip. Variable is the current keyword text.",
|
||||
message: `Edit ${chip}`,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(index);
|
||||
@@ -90,7 +95,11 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
aria-label={t({
|
||||
comment:
|
||||
"Screen reader label for button that removes a keyword chip. Variable is the current keyword text.",
|
||||
message: `Remove ${chip}`,
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
@@ -122,6 +131,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
const [input, setInput] = React.useState("");
|
||||
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isEditingKeyword = editingIndex !== null;
|
||||
|
||||
const addChip = React.useCallback(
|
||||
(chip: string) => {
|
||||
@@ -289,8 +299,8 @@ export function ChipInput({ value, defaultValue = [], onChange, className, hideD
|
||||
type="text"
|
||||
value={input}
|
||||
autoComplete="off"
|
||||
aria-label={editingIndex !== null ? "Edit keyword" : "Add keyword"}
|
||||
placeholder={editingIndex !== null ? "Editing keyword..." : "Add a keyword..."}
|
||||
aria-label={isEditingKeyword ? t`Edit keyword` : t`Add keyword`}
|
||||
placeholder={isEditingKeyword ? t`Editing keyword...` : t`Add a keyword...`}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { type ColorResult, hsvaToRgbaString, rgbaStringToHsva } from "@uiw/color-convert";
|
||||
import ReactColorColorful from "@uiw/react-color-colorful";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Separator } from "../ui/separator";
|
||||
|
||||
const presetColors = [
|
||||
"rgba(0, 0, 0, 1)",
|
||||
"rgba(231, 0, 11, 1)",
|
||||
"rgba(245, 73, 0, 1)",
|
||||
"rgba(225, 113, 0, 1)",
|
||||
"rgba(208, 135, 0, 1)",
|
||||
"rgba(94, 165, 0, 1)",
|
||||
"rgba(0, 166, 62, 1)",
|
||||
"rgba(0, 153, 102, 1)",
|
||||
"rgba(0, 146, 184, 1)",
|
||||
"rgba(0, 132, 209, 1)",
|
||||
"rgba(21, 93, 252, 1)",
|
||||
"rgba(79, 57, 246, 1)",
|
||||
"rgba(127, 34, 254, 1)",
|
||||
"rgba(200, 0, 222, 1)",
|
||||
"rgba(230, 0, 118, 1)",
|
||||
"rgba(69, 85, 108, 1)",
|
||||
] as const;
|
||||
|
||||
type ColorPickerProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
trigger?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps) {
|
||||
export function ColorPicker({ value, defaultValue, onChange, trigger, children }: ColorPickerProps) {
|
||||
const [currentValue, setCurrentValue] = useControlledState<string>({
|
||||
value,
|
||||
defaultValue,
|
||||
@@ -28,15 +53,56 @@ export function ColorPicker({ value, defaultValue, onChange }: ColorPickerProps)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground transition-opacity hover:opacity-60"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
{trigger ?? (
|
||||
<PopoverTrigger>
|
||||
<div
|
||||
className="size-6 shrink-0 cursor-pointer rounded-full border border-foreground/60 transition-all hover:scale-105 focus-visible:outline-hidden"
|
||||
style={{ backgroundColor: currentValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
|
||||
<PopoverContent className="max-w-fit rounded-md p-2">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
<PopoverContent align="start" className="min-w-xs">
|
||||
{children && (
|
||||
<>
|
||||
{children}
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Presets</Trans>
|
||||
</span>
|
||||
|
||||
<div className="grid grid-cols-8 gap-3 rounded bg-muted p-3">
|
||||
{presetColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
title={color}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={t`Use color ${color}`}
|
||||
aria-pressed={currentValue === color}
|
||||
onClick={() => setCurrentValue(color)}
|
||||
className={cn(
|
||||
"size-5 shrink-0 cursor-pointer rounded-full transition-all hover:scale-105 focus-visible:outline-hidden",
|
||||
currentValue === color && "border border-foreground/60",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
<Trans>Custom</Trans>
|
||||
</span>
|
||||
|
||||
<div className="rounded bg-muted p-3 *:w-full! [&_.w-color-alpha]:mt-4! [&_.w-color-alpha]:h-4! [&_.w-color-alpha>div]:rounded-full! [&_.w-color-hue]:mt-4! [&_.w-color-hue]:h-4! [&_.w-color-hue]:rounded-full! [&_.w-color-saturation]:h-36! [&_.w-color-saturation]:rounded-[calc(var(--radius-lg)-0.25rem)]!">
|
||||
<ReactColorColorful color={color} onChange={onColorChange} />
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -24,12 +24,17 @@ type IconSearchInputProps = {
|
||||
function _IconSearchInput(props: IconSearchInputProps) {
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
inputMode="search"
|
||||
value={props.value}
|
||||
aria-label={t`Search for an icon`}
|
||||
placeholder={t`Search for an icon`}
|
||||
aria-label={t({
|
||||
comment: "Accessible label for icon picker search input",
|
||||
message: "Search for an icon",
|
||||
})}
|
||||
placeholder={t({
|
||||
comment: "Placeholder text in icon picker search input",
|
||||
message: "Search for an icon",
|
||||
})}
|
||||
onChange={(e) => props.onChange(e.currentTarget.value)}
|
||||
className={cn("rounded-none border-0 focus-visible:ring-0", props.className)}
|
||||
/>
|
||||
|
||||
@@ -38,9 +38,11 @@ import {
|
||||
TextUnderlineIcon,
|
||||
TrashSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { TableKit } from "@tiptap/extension-table";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import {
|
||||
type Editor,
|
||||
EditorContent,
|
||||
@@ -65,14 +67,18 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
import { isRTL } from "@/utils/locale";
|
||||
import { sanitizeHtml } from "@/utils/sanitize";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
import { Toggle } from "../ui/toggle";
|
||||
import { ColorPicker } from "./color-picker";
|
||||
import styles from "./rich-input.module.css";
|
||||
|
||||
const defaultTextColor = "rgba(0, 0, 0, 1)";
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
@@ -88,6 +94,8 @@ const extensions = [
|
||||
protocols: ["http", "https"],
|
||||
},
|
||||
}),
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md px-0.5 py-px",
|
||||
@@ -172,8 +180,16 @@ export function RichInput({ value, onChange, style, className, editorClassName,
|
||||
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
|
||||
<DialogContent className="flex h-[95svh] max-h-none! w-[95svw] max-w-none! flex-col p-4 sm:max-w-none! 2xl:max-w-none!">
|
||||
<div className="sr-only">
|
||||
<DialogTitle>Fullscreen Editor</DialogTitle>
|
||||
<DialogDescription>Edit content in fullscreen mode</DialogDescription>
|
||||
<DialogTitle>
|
||||
<Trans comment="Screen reader title for the fullscreen rich-text editor dialog">
|
||||
Fullscreen Editor
|
||||
</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans comment="Screen reader description for the fullscreen rich-text editor dialog">
|
||||
Edit content in fullscreen mode
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
{editorElement}
|
||||
</DialogContent>
|
||||
@@ -223,6 +239,12 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
|
||||
toggleHighlight: () => ctx.editor.chain().focus().toggleHighlight().run(),
|
||||
|
||||
// Text Color
|
||||
textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
|
||||
canTextColor: ctx.editor.can().chain().setColor(defaultTextColor).run() ?? false,
|
||||
setTextColor: (color: string) => ctx.editor.chain().focus().setColor(color).run(),
|
||||
unsetTextColor: () => ctx.editor.chain().focus().unsetColor().run(),
|
||||
|
||||
// Heading 1
|
||||
isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
|
||||
canHeading1: ctx.editor.can().chain().toggleHeading({ level: 1 }).run() ?? false,
|
||||
@@ -418,6 +440,66 @@ function EditorToolbar({ editor, isFullscreen }: { editor: Editor; isFullscreen:
|
||||
<HighlighterCircleIcon className="size-3.5" />
|
||||
</Toggle>
|
||||
|
||||
<ColorPicker
|
||||
defaultValue={defaultTextColor}
|
||||
value={state.textColor ?? undefined}
|
||||
onChange={state.setTextColor}
|
||||
trigger={
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
size={isFullscreen ? "lg" : "sm"}
|
||||
tabIndex={-1}
|
||||
variant="ghost"
|
||||
className={cn("rounded-none px-2", state.textColor && "bg-muted text-foreground")}
|
||||
title={t`Text Color`}
|
||||
disabled={!state.canTextColor}
|
||||
>
|
||||
<span className="flex flex-col items-center leading-none">
|
||||
<span className="text-xs font-semibold">A</span>
|
||||
<span
|
||||
className="mt-0.5 h-0.5 w-3 rounded-full"
|
||||
style={{ backgroundColor: state.textColor ?? "currentColor" }}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PopoverHeader className="flex-row items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className="grid size-9 place-items-center rounded-lg border border-border bg-muted/60 text-sm font-semibold shadow-xs"
|
||||
style={{ color: state.textColor ?? "currentColor" }}
|
||||
>
|
||||
A
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<PopoverTitle>
|
||||
<Trans>Text Color</Trans>
|
||||
</PopoverTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<Trans comment="Preset or custom shade refer to the color picker">
|
||||
Choose a preset or custom shade.
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className="shrink-0"
|
||||
onClick={state.unsetTextColor}
|
||||
disabled={!state.textColor}
|
||||
>
|
||||
<Trans comment="Clear the text color">Clear</Trans>
|
||||
</Button>
|
||||
</PopoverHeader>
|
||||
</ColorPicker>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -16,12 +16,14 @@ vi.mock("@phosphor-icons/react", () => ({
|
||||
TagIcon: () => <span data-testid="tag-icon" />,
|
||||
}));
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { URLInput } from "./url-input";
|
||||
|
||||
describe("URLInput", () => {
|
||||
describe("URL prefix handling", () => {
|
||||
it("displays URL without https:// prefix", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByDisplayValue("example.com");
|
||||
@@ -29,7 +31,7 @@ describe("URLInput", () => {
|
||||
});
|
||||
|
||||
it("displays empty string when URL is empty", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
@@ -38,7 +40,7 @@ describe("URLInput", () => {
|
||||
});
|
||||
|
||||
it("adds https:// prefix when user types", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
@@ -51,7 +53,7 @@ describe("URLInput", () => {
|
||||
});
|
||||
|
||||
it("does not double-prefix when user types https://", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
@@ -64,7 +66,7 @@ describe("URLInput", () => {
|
||||
});
|
||||
|
||||
it("sends empty URL when input is cleared", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
@@ -79,7 +81,7 @@ describe("URLInput", () => {
|
||||
|
||||
describe("label preservation", () => {
|
||||
it("preserves label when URL changes", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "https://old.com", label: "My Site" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
@@ -94,14 +96,14 @@ describe("URLInput", () => {
|
||||
|
||||
describe("hideLabelButton prop", () => {
|
||||
it("hides the label button when hideLabelButton is true", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} hideLabelButton={true} />);
|
||||
|
||||
expect(screen.queryByTestId("tag-icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the label button by default", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByTestId("tag-icon")).toBeDefined();
|
||||
@@ -110,7 +112,7 @@ describe("URLInput", () => {
|
||||
|
||||
describe("displays the https:// prefix text", () => {
|
||||
it("shows https:// as static text", () => {
|
||||
const onChange = vi.fn();
|
||||
const onChange = vi.fn<React.ComponentProps<typeof URLInput>["onChange"]>();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByText("https://")).toBeDefined();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user