Merge branch 'main' into l10n

This commit is contained in:
Amruth Pillai
2026-01-21 23:43:13 +01:00
committed by GitHub
58 changed files with 2614 additions and 1177 deletions
+2 -10
View File
@@ -5,19 +5,11 @@ APP_URL="http://localhost:3000"
# Optional, uses APP_URL by default
# This can be set to a different URL (like http://host.docker.internal:3000 or http://{docker_service}:3000)
# to let the browser navigate to a non-public instance of Reactive Resume
# Note: set this to `http://host.docker.internal:3000` if your printer service is running on docker, but Reactive Resume is running outside of Docker.
PRINTER_APP_URL="http://host.docker.internal:3000"
# Note: set this to `http://host.docker.internal:3000` if your `gotenberg` service is running on docker, but Reactive Resume is running outside of Docker.
# --- Printer ---
GOTENBERG_ENDPOINT="http://localhost:4000"
# Gotenberg Authentication (Optional)
# TIP: It's safest to avoid exposing your Gotenberg instance to the public internet; connect via a private Docker network instead.
# However, if Gotenberg needs to be hosted remotely from Reactive Resume, use these credentials to enable basic authentication for security.
# For setup details and security best practices, see: https://gotenberg.dev/docs/configuration#api:~:text=API%5FENABLE%5FBASIC%5FAUTH
# GOTENBERG_USERNAME=""
# GOTENBERG_PASSWORD=""
PRINTER_ENDPOINT="ws://localhost:4000?token=1234567890"
# --- Database (PostgreSQL) ---
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
+44 -16
View File
@@ -66,8 +66,7 @@ jobs:
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
tags: |
type=sha,format=short,suffix=-${{ matrix.arch }}
type=raw,value=v${{ steps.version.outputs.version }}-${{ matrix.arch }}
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
- name: Build and Push by Digest
id: build
@@ -80,6 +79,7 @@ jobs:
platforms: ${{ matrix.platform }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha,scope=${{ env.IMAGE }}-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ env.IMAGE }}-${{ matrix.arch }}
@@ -110,7 +110,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
sparse-checkout: package.json
sparse-checkout-cone-mode: false
@@ -142,6 +142,16 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Parse version components
id: semver
run: |
VERSION="${{ steps.version.outputs.version }}"
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
@@ -150,30 +160,48 @@ jobs:
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
tags: |
type=sha,format=short
type=sha,prefix=sha-
type=raw,value=latest
type=raw,value=v${{ steps.version.outputs.version }}
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }}
type=raw,value=v${{ steps.semver.outputs.major }}
- name: Create manifest list and push
id: manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
--annotation "index:org.opencontainers.image.licenses=MIT" \
--annotation "index:org.opencontainers.image.title=Reactive Resume" \
--annotation "index:org.opencontainers.image.description=A free and open-source resume builder." \
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
# Get the digest of the multi-arch manifest
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Sign images with Cosign
run: |
# Sign GHCR image
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
# Sign Docker Hub image
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
- name: Inspect image
run: |
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:latest
- name: Redeploy Service
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: appleboy/ssh-action@v1
with:
key: ${{ secrets.SSH_KEY }}
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
script: |
cd docker
./manage_stack.sh up reactive_resume
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
+15
View File
@@ -35,6 +35,18 @@ RUN pnpm run build
# ---------- Runtime Layer ----------
FROM node:24-slim AS runtime
LABEL maintainer="amruthpillai"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.title="Reactive Resume"
LABEL org.opencontainers.image.description="A free and open-source resume builder."
LABEL org.opencontainers.image.vendor="Amruth Pillai"
LABEL org.opencontainers.image.url="https://rxresu.me"
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
@@ -45,4 +57,7 @@ COPY --from=dependencies /tmp/prod/node_modules ./node_modules
EXPOSE 3000/tcp
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:3000/api/health || exit 1
ENTRYPOINT ["node", "-r", "reflect-metadata", ".output/server/index.mjs"]
+1 -1
View File
@@ -190,7 +190,7 @@ Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
Reactive Resume can be self-hosted using Docker. The stack includes:
- **PostgreSQL** — Database for storing user data and resumes
- **Gotenberg** — Headless Chrome service for PDF generation
- **Printer** — Headless Chromium service for PDF and screenshot generation
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
Pull the latest image from Docker Hub or GitHub Container Registry:
+21 -14
View File
@@ -19,25 +19,32 @@ services:
timeout: 10s
retries: 3
gotenberg:
image: gotenberg/gotenberg:8
browserless:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
ports:
- "4000:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- WEBHOOK_DISABLE=true
- CHROMIUM_AUTO_START=true
- LIBREOFFICE_DISABLE_ROUTES=true
- PROMETHEUS_DISABLE_COLLECT=true
- CHROMIUM_HOST_RESOLVER_RULES=MAP localhost host.docker.internal
ports:
- "4000:3000"
- QUEUED=10
- HEALTH=true
- CONCURRENT=20
- TOKEN=1234567890
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
start_period: 10s
interval: 30s
timeout: 10s
retries: 3
test:
["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
interval: 10s
timeout: 5s
retries: 10
# As an alternative to browserless, you can also use a lightweight image like chromedp/headless-shell:latest
# See https://docs.rxresu.me/self-hosting/docker#alternative-printer-options for more information.
# chrome:
# image: chromedp/headless-shell:latest
# restart: unless-stopped
# ports:
# - "9222:9222"
seaweedfs:
image: chrislusf/seaweedfs:latest
+24 -19
View File
@@ -17,22 +17,32 @@ services:
timeout: 10s
retries: 3
gotenberg:
image: gotenberg/gotenberg:8
browserless:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
ports:
- "4000:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- WEBHOOK_DISABLE=true
- CHROMIUM_AUTO_START=true
- LIBREOFFICE_DISABLE_ROUTES=true
- PROMETHEUS_DISABLE_COLLECT=true
- QUEUED=10
- HEALTH=true
- CONCURRENT=20
- TOKEN=1234567890
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
start_period: 10s
interval: 30s
timeout: 10s
retries: 3
test:
["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
interval: 10s
timeout: 5s
retries: 10
# As an alternative to browserless, you can also use a lightweight image like chromedp/headless-shell:latest
# See https://docs.rxresu.me/self-hosting/docker#alternative-printer-options for more information.
# chrome:
# image: chromedp/headless-shell:latest
# restart: unless-stopped
# ports:
# - "9222:9222"
seaweedfs:
image: chrislusf/seaweedfs:latest
@@ -74,7 +84,8 @@ services:
- APP_URL=http://localhost:3000
- PRINTER_APP_URL=http://app:3000
# Printer
- GOTENBERG_ENDPOINT=http://gotenberg:3000
- PRINTER_ENDPOINT=ws://browserless:3000?token=1234567890
# - PRINTER_ENDPOINT=http://chrome:9222 # Or, if you're using chromedp/headless-shell
# Database
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres
# Authentication
@@ -97,13 +108,7 @@ services:
seaweedfs-create-bucket:
condition: service_completed_successfully
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
start_period: 10s
interval: 30s
timeout: 10s
+1 -1
View File
@@ -49,7 +49,7 @@ flowchart TD
Database["PostgreSQL"]
Storage["File System OR S3-Compatible Storage"]
Printer["Gotenberg"]
Printer["Printer (Browserless/Chromium)"]
Router --> ORPCClient
Query --> ORPCClient
+3 -3
View File
@@ -47,7 +47,7 @@ This guide walks you through setting up Reactive Resume for local development. W
This starts the following infrastructure services:
- **PostgreSQL** — Database (port 5432)
- **SeaweedFS** — S3-compatible storage (port 8333)
- **Gotenberg** — PDF generation service (port 4000)
- **Printer** — PDF and screenshot generation service (port 4000)
- **Mailpit** — Email testing server (SMTP on port 1025, UI on port 8025)
<Tip>
@@ -88,7 +88,7 @@ This guide walks you through setting up Reactive Resume for local development. W
```
<Note>
**PDF Generation Note**: The `PRINTER_APP_URL` variable is required when running Reactive Resume outside of Docker while the Gotenberg PDF service is running inside Docker (which is the case when using `compose.dev.yml`). Gotenberg needs to reach your local app to render resumes for PDF generation. Since Docker containers cannot access `localhost` on your host machine directly, you must set `PRINTER_APP_URL` to `http://host.docker.internal:3000`. This special hostname allows Docker containers to communicate with services running on your host machine.
**PDF Generation Note**: The `PRINTER_APP_URL` variable is required when running Reactive Resume outside of Docker while the printer service is running inside Docker (which is the case when using `compose.dev.yml`). The printer needs to reach your local app to render resumes for PDF generation. Since Docker containers cannot access `localhost` on your host machine directly, you must set `PRINTER_APP_URL` to `http://host.docker.internal:3000`. This special hostname allows Docker containers to communicate with services running on your host machine.
</Note>
<Tip>
@@ -329,7 +329,7 @@ pnpm run typecheck
**Why this happens:**
The app running on your host uploads images to SeaweedFS (S3 storage) using `localhost:8333`. When Gotenberg tries to fetch these images to render the PDF, it cannot resolve `localhost` the same way your host machine does — they're on different networks.
The app running on your host uploads images to SeaweedFS (S3 storage) using `localhost:8333`. When the printer tries to fetch these images to render the PDF, it cannot resolve `localhost` the same way your host machine does — they're on different networks.
**Solutions:**
+9 -7
View File
@@ -112,7 +112,7 @@ Before you begin, ensure you have the following installed:
This will start:
- **PostgreSQL** — Database for storing user data and resumes
- **SeaweedFS** — S3-compatible storage for file uploads
- **Gotenberg** — PDF generation service
- **Printer** — PDF and screenshot generation service (browserless/chromium)
- **Reactive Resume** — The main application
</Step>
@@ -133,7 +133,7 @@ Here's what each service in the stack does:
|---------|------|-------------|
| `postgres` | 5432 | PostgreSQL database for storing all application data |
| `seaweedfs` | 8333 | S3-compatible object storage for file uploads |
| `gotenberg` | 4000 | Headless Chrome service for PDF generation |
| `printer` | 4000 | Headless Chromium service for PDF and screenshot generation |
| `app` | 3000 | The main Reactive Resume application |
### Health Checks
@@ -159,15 +159,13 @@ Here's a complete list of environment variables you can configure:
| `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` |
| `GOTENBERG_ENDPOINT` | URL of the Gotenberg PDF service | `http://gotenberg:4000` |
| `PRINTER_ENDPOINT` | URL of the printer service | `http://printer:3000` |
### Optional Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `PRINTER_APP_URL` | Public URL for Gotenberg to access the Application | — |
| `GOTENBERG_USERNAME` | Gotenberg Basic Auth Username (if enabled) | — |
| `GOTENBERG_PASSWORD` | Gotenberg Basic Auth Password (if enabled) | — |
| `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 | — |
@@ -198,7 +196,11 @@ Here's a complete list of environment variables you can configure:
> **Note:** Some variables are only required for using related features (OAuth, SMTP, S3, etc.) and can be left unset if unused.
<Note>
**Hybrid Setup Note**: The `PRINTER_APP_URL` variable is required when running Reactive Resume outside of Docker while the Gotenberg PDF service is running inside Docker. In this scenario, Gotenberg needs to reach your local app to render resumes for PDF generation. Since Docker containers cannot access `localhost` on your host machine directly, you must set `PRINTER_APP_URL` to `http://host.docker.internal:3000`. This special hostname allows Docker containers to communicate with services running on your host machine.
**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 resumes for PDF generation. Since Docker containers cannot access `localhost` on your host machine directly, you must set `PRINTER_APP_URL` to `http://host.docker.internal:3000`. This special hostname allows Docker containers to communicate with services running on your host machine.
</Note>
<Note>
**Alternative Printer Options**: If you don't want to use browserless, you can use any headless Chrome/Chromium instance with its remote debugging port open. For example, run `chromium --remote-debugging-port=9222` and point `PRINTER_ENDPOINT` to that instance.
</Note>
---
+3 -3
View File
@@ -120,10 +120,10 @@ The Service does not include built-in behavioral advertising or third-party anal
We share information only as needed to provide the Service:
### PDF generation and screenshots (Gotenberg)
When you export to PDF or request a screenshot, the Service sends a request to a configured **Gotenberg** endpoint to render a resume URL.
### PDF generation and screenshots (Printer)
When you export to PDF or request a screenshot, the Service sends a request to a configured printer endpoint (a headless Chromium browser) to render a resume URL.
Depending on your deployment, Gotenberg may be:
Depending on your deployment, the printer may be:
- Self-hosted and controlled by the Service Operator, or
- Operated by a third party (in which case the third party will process the resume content for rendering)
+1 -1
View File
@@ -84,7 +84,7 @@ You represent that you have the rights necessary to upload and use any files and
## Exports (PDF) and Screenshots
When you request a PDF export or screenshot, the Service may use a rendering service (commonly **Gotenberg**) to load a resume URL and generate the output.
When you request a PDF export or screenshot, the Service uses a printer service (a headless Chromium browser) to load a resume URL and generate the output.
Depending on the deployment, this rendering service may be operated by the Service Operator or a third party. You understand that resume content must be processed for rendering in order to provide the export/screenshot functionality.
+54 -26
View File
@@ -1,6 +1,6 @@
---
title: "Self-Hosting with Docker"
description: "A comprehensive guide to self-host Reactive Resume with Docker (Postgres + Gotenberg), including a detailed environment variable reference and troubleshooting tips."
description: "A comprehensive guide to self-host Reactive Resume with Docker (Postgres + Printer), including a detailed environment variable reference and troubleshooting tips."
---
## Overview
@@ -11,8 +11,8 @@ Reactive Resume can be self-hosted using Docker in a matter of minutes, and this
<Card title="PostgreSQL">
Stores accounts, resumes, and application data.
</Card>
<Card title="Gotenberg">
Generates PDFs by rendering a special print route.
<Card title="Printer">
Generates PDFs and screenshots using a headless Chromium browser.
</Card>
<Card title="Email (optional)">
SMTP for verification emails, password reset, etc. If not configured, emails are logged to the server console.
@@ -34,7 +34,7 @@ You can pull the latest app image from:
Docker Engine + Docker Compose plugin (or Docker Desktop).
</Card>
<Card title="Compute">
2 vCPU / 2 GB RAM minimum (4 GB recommended if Postgres + Gotenberg run on the same host).
2 vCPU / 2 GB RAM minimum (4 GB recommended if Postgres + Printer run on the same host).
</Card>
<Card title="Storage">
Enough for Postgres + uploads (start with 10-20 GB and scale as needed).
@@ -64,11 +64,9 @@ APP_URL="http://localhost:3000"
PRINTER_APP_URL="http://host.docker.internal:3000"
# --- Printer ---
GOTENBERG_ENDPOINT="http://gotenberg:3000"
# Gotenberg Authentication (Optional)
# GOTENBERG_USERNAME=""
# GOTENBERG_PASSWORD=""
# 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"
# --- Database (PostgreSQL) ---
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
@@ -150,7 +148,7 @@ FLAG_DISABLE_SIGNUP="false"
</Step>
<Step title="Create compose.yml">
This setup runs Postgres + Gotenberg + Reactive Resume on a private Docker network.
This setup runs Postgres + Printer + Reactive Resume on a private Docker network.
<CodeGroup>
@@ -171,18 +169,21 @@ services:
timeout: 5s
retries: 10
gotenberg:
image: gotenberg/gotenberg:edge
printer:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
ports:
- "4000:3000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- CHROMIUM_AUTO_START=true
- LIBREOFFICE_DISABLE_ROUTES=true
- HEALTH=true
- CONCURRENT=20
- QUEUED=10
# Optional: Set a token for authentication
# - TOKEN=your-secret-token
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 10s
timeout: 5s
retries: 10
@@ -201,10 +202,10 @@ services:
depends_on:
postgres:
condition: service_healthy
gotenberg:
printer:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
@@ -215,6 +216,20 @@ volumes:
</CodeGroup>
<Note>
**Alternative Printer Options**: If you don't want to use browserless, you can also use a lightweight headless Chrome Docker image like `chromedp/headless-shell`:
```yaml
chrome:
image: chromedp/headless-shell:latest
restart: unless-stopped
ports:
- "9222:9222"
```
Then set `PRINTER_ENDPOINT` to `http://chrome:9222` (or `http://localhost:9222` if running outside Docker Compose). This provides the same PDF/screenshot generation functionality with a smaller image footprint.
</Note>
<Tip>
Prefer pulling from Docker Hub? Keep <code>amruthpillai/reactive-resume:latest</code>. Prefer GHCR? Swap it to <code>ghcr.io/amruthpillai/reactive-resume:latest</code>.
</Tip>
@@ -254,7 +269,7 @@ docker compose logs -f reactive-resume
<ul>
<li><code>APP_URL</code></li>
<li><code>DATABASE_URL</code></li>
<li><code>GOTENBERG_ENDPOINT</code></li>
<li><code>PRINTER_ENDPOINT</code></li>
<li><code>AUTH_SECRET</code></li>
</ul>
</Card>
@@ -272,12 +287,25 @@ docker compose logs -f reactive-resume
<Accordion title="Server">
- **`TZ`**: Sets the container timezone (affects logs and server-side timestamps). Recommended: `Etc/UTC`.
- **`APP_URL`**: Canonical/public URL for your instance (used for absolute URLs, redirects, and auth flows). If behind a reverse proxy, set this to your public HTTPS URL (for example, `https://resume.example.com`).
- **`PRINTER_APP_URL`** (optional): Overrides the base URL used when rendering the print route for Gotenberg. Defaults to `APP_URL`. Useful when Gotenberg must access the app via a different internal URL (for example, `http://host.docker.internal:3000`).
- **`PRINTER_APP_URL`** (optional): Overrides the base URL used when rendering the print route for the printer. Defaults to `APP_URL`. Useful when the printer must access the app via a different internal URL (for example, `http://host.docker.internal:3000`).
</Accordion>
<Accordion title="Printer (Gotenberg)">
- **`GOTENBERG_ENDPOINT`**: Base URL where Reactive Resume reaches Gotenberg. In Compose: `http://gotenberg:3000`.
- **`GOTENBERG_USERNAME`** / **`GOTENBERG_PASSWORD`** (optional): Use if Gotenberg is configured with Basic Auth (recommended only if Gotenberg is reachable outside your private network).
<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`.
<Note>
**Alternative to browserless**: You can use a lightweight headless Chrome Docker image like `chromedp/headless-shell`:
```yaml
chrome:
image: chromedp/headless-shell:latest
restart: unless-stopped
ports:
- "9222:9222"
```
Set `PRINTER_ENDPOINT` to `http://chrome:9222` (in Docker Compose) or `http://localhost:9222` (if running externally). This provides the same PDF/screenshot generation with a smaller image footprint.
</Note>
</Accordion>
<Accordion title="Database (PostgreSQL)">
@@ -386,7 +414,7 @@ The Docker Compose configuration includes a health check that periodically calls
```yaml
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
@@ -439,10 +467,10 @@ A healthy response returns HTTP 200. Any other response (or a connection failure
</Accordion>
<Accordion title="PDF export fails / printing is stuck">
- **Common cause**: Reactive Resume can't reach Gotenberg or Gotenberg can't reach your app.
- **Common cause**: Reactive Resume can't reach the printer or the printer can't reach your app.
- **Checks**:
- `GOTENBERG_ENDPOINT` should usually be `http://gotenberg:3000` in Compose.
- If you use `PRINTER_APP_URL="http://host.docker.internal:3000"`, ensure `extra_hosts: host-gateway` is present for Gotenberg.
- `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.
</Accordion>
<Accordion title="Uploads disappear after restart">
+33 -40
View File
@@ -17,7 +17,7 @@ These examples go beyond the basic setup in the [Self-Hosting with Docker](/self
## Docker with Traefik
This example uses [Traefik](https://traefik.io/) as a reverse proxy with automatic SSL certificate management via Let's Encrypt. Only the Reactive Resume app is exposed through Traefik—Postgres and Gotenberg remain on an internal network.
This example uses [Traefik](https://traefik.io/) as a reverse proxy with automatic SSL certificate management via Let's Encrypt. Only the Reactive Resume app is exposed through Traefik—Postgres and the printer remain on an internal network.
<Tip>
Traefik automatically discovers services via Docker labels and handles SSL certificates, making it ideal for setups where you want minimal configuration.
@@ -75,19 +75,19 @@ services:
timeout: 5s
retries: 5
gotenberg:
image: gotenberg/gotenberg:8
printer:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-auto-start"
- "--api-timeout=120s"
environment:
- TIMEOUT=120000
- CONCURRENT=10
- HEALTH=true
networks:
- reactive_resume_network
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
@@ -99,7 +99,7 @@ services:
- APP_URL=https://resume.${DOMAIN}
- PRINTER_APP_URL=http://app:3000
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
- GOTENBERG_ENDPOINT=http://gotenberg:3000
- PRINTER_ENDPOINT=http://printer:3000
- AUTH_SECRET=${AUTH_SECRET}
# Add other optional env vars as needed (SMTP, S3, OAuth, etc.)
volumes:
@@ -109,7 +109,7 @@ services:
depends_on:
postgres:
condition: service_healthy
gotenberg:
printer:
condition: service_healthy
labels:
- "traefik.enable=true"
@@ -118,7 +118,7 @@ services:
- "traefik.http.routers.reactive-resume.tls.certresolver=letsencrypt"
- "traefik.http.services.reactive-resume.loadbalancer.server.port=3000"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
@@ -183,19 +183,19 @@ services:
timeout: 5s
retries: 5
gotenberg:
image: gotenberg/gotenberg:8
printer:
image: ghcr.io/browserless/chromium:latest
restart: unless-stopped
command:
- "gotenberg"
- "--chromium-auto-start"
- "--api-timeout=120s"
environment:
- TIMEOUT=120000
- CONCURRENT=10
- HEALTH=true
networks:
- reactive_resume_network
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
@@ -207,7 +207,7 @@ services:
- APP_URL=https://resume.${DOMAIN}
- PRINTER_APP_URL=http://app:3000
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
- GOTENBERG_ENDPOINT=http://gotenberg:3000
- PRINTER_ENDPOINT=http://printer:3000
- AUTH_SECRET=${AUTH_SECRET}
# Add other optional env vars as needed (SMTP, S3, OAuth, etc.)
volumes:
@@ -217,10 +217,10 @@ services:
depends_on:
postgres:
condition: service_healthy
gotenberg:
printer:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\""]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
@@ -347,19 +347,18 @@ services:
max_attempts: 3
window: 120s
gotenberg:
image: gotenberg/gotenberg:8
printer:
image: ghcr.io/browserless/chromium:latest
networks:
- reactive_resume_network
environment:
- WEBHOOK_DISABLE=true
- CHROMIUM_AUTO_START=true
- API_ENABLE_BASIC_AUTH=true
- PROMETHEUS_DISABLE_COLLECT=true
- GOTENBERG_API_BASIC_AUTH_USERNAME=$GOTENBERG_USERNAME
- GOTENBERG_API_BASIC_AUTH_PASSWORD=$GOTENBERG_PASSWORD
- TIMEOUT=120000
- CONCURRENT=10
- HEALTH=true
# Optional: Set a token for authentication
# - TOKEN=your-secret-token
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
test: ["CMD", "curl", "-f", "http://localhost:3000/"]
interval: 30s
timeout: 10s
retries: 3
@@ -438,9 +437,9 @@ services:
- reactive_resume_app_data:/app/data
environment:
- APP_URL=$APP_URL
- GOTENBERG_ENDPOINT=$GOTENBERG_ENDPOINT
- GOTENBERG_USERNAME=$GOTENBERG_USERNAME
- GOTENBERG_PASSWORD=$GOTENBERG_PASSWORD
# If using browserless with token auth, include the token in the URL:
# PRINTER_ENDPOINT=ws://printer:3000?token=your-secret-token
- PRINTER_ENDPOINT=$PRINTER_ENDPOINT
- DATABASE_URL=$DATABASE_URL
- AUTH_SECRET=$AUTH_SECRET
- GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID
@@ -459,13 +458,7 @@ services:
- S3_ENDPOINT=$S3_ENDPOINT
- S3_BUCKET=$S3_BUCKET
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
]
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health", "||", "exit", "1"]
interval: 30s
timeout: 10s
retries: 3
+180 -157
View File
@@ -32,7 +32,12 @@ msgstr "Name"
msgid "(opens in new tab)"
msgstr "(opens in new tab)"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:163
#. placeholder {0}: section.items.length
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:122
msgid "{0, plural, one {# item} other {# items}}"
msgstr "{0, plural, one {# item} other {# items}}"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:244
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:149
msgid "{column, plural, one {# Column} other {# Columns}}"
msgstr "{column, plural, one {# Column} other {# Columns}}"
@@ -78,7 +83,7 @@ msgstr "A confirmation link has been sent to your current email address. Please
msgid "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
msgstr "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
#: src/routes/builder/$resumeId/-components/dock.tsx:49
#: src/routes/builder/$resumeId/-components/dock.tsx:50
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx:39
msgid "A link to your resume has been copied to clipboard."
msgstr "A link to your resume has been copied to clipboard."
@@ -119,7 +124,7 @@ msgstr "Add a new award"
msgid "Add a new certification"
msgstr "Add a new certification"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:47
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:83
msgid "Add a new custom section"
msgstr "Add a new custom section"
@@ -135,6 +140,7 @@ msgstr "Add a new experience"
msgid "Add a new interest"
msgstr "Add a new interest"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:152
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:120
msgid "Add a new item"
msgstr "Add a new item"
@@ -303,7 +309,7 @@ msgstr "Are you sure you want to lock this resume?"
msgid "Are you sure you want to remove password protection?"
msgstr "Are you sure you want to remove password protection?"
#: src/dialogs/resume/sections/education.tsx:185
#: src/dialogs/resume/sections/education.tsx:196
msgid "Area of Study"
msgstr "Area of Study"
@@ -329,7 +335,7 @@ msgstr "Authentication"
msgid "Available in multiple languages. If you would like to contribute, check out Crowdin."
msgstr "Available in multiple languages. If you would like to contribute, check out Crowdin."
#: src/dialogs/resume/sections/award.tsx:163
#: src/dialogs/resume/sections/award.tsx:174
msgctxt "(noun) person, organization, or entity that gives an award"
msgid "Awarder"
msgstr "Awarder"
@@ -414,32 +420,32 @@ msgid "Can I export my resume to PDF?"
msgstr "Can I export my resume to PDF?"
#: src/dialogs/auth/enable-two-factor.tsx:267
#: src/dialogs/resume/sections/award.tsx:68
#: src/dialogs/resume/sections/award.tsx:123
#: src/dialogs/resume/sections/certification.tsx:68
#: src/dialogs/resume/sections/certification.tsx:123
#: src/dialogs/resume/sections/custom.tsx:69
#: src/dialogs/resume/sections/custom.tsx:122
#: src/dialogs/resume/sections/education.tsx:71
#: src/dialogs/resume/sections/education.tsx:129
#: src/dialogs/resume/sections/experience.tsx:69
#: src/dialogs/resume/sections/experience.tsx:125
#: src/dialogs/resume/sections/interest.tsx:68
#: src/dialogs/resume/sections/interest.tsx:121
#: src/dialogs/resume/sections/language.tsx:66
#: src/dialogs/resume/sections/language.tsx:119
#: src/dialogs/resume/sections/profile.tsx:69
#: src/dialogs/resume/sections/profile.tsx:123
#: src/dialogs/resume/sections/project.tsx:67
#: src/dialogs/resume/sections/project.tsx:121
#: src/dialogs/resume/sections/publication.tsx:68
#: src/dialogs/resume/sections/publication.tsx:123
#: src/dialogs/resume/sections/reference.tsx:64
#: src/dialogs/resume/sections/reference.tsx:116
#: src/dialogs/resume/sections/skill.tsx:72
#: src/dialogs/resume/sections/skill.tsx:127
#: src/dialogs/resume/sections/volunteer.tsx:68
#: src/dialogs/resume/sections/volunteer.tsx:123
#: src/dialogs/resume/sections/award.tsx:73
#: src/dialogs/resume/sections/award.tsx:134
#: src/dialogs/resume/sections/certification.tsx:73
#: src/dialogs/resume/sections/certification.tsx:134
#: src/dialogs/resume/sections/custom.tsx:84
#: src/dialogs/resume/sections/custom.tsx:138
#: src/dialogs/resume/sections/education.tsx:76
#: src/dialogs/resume/sections/education.tsx:140
#: src/dialogs/resume/sections/experience.tsx:74
#: src/dialogs/resume/sections/experience.tsx:136
#: src/dialogs/resume/sections/interest.tsx:73
#: src/dialogs/resume/sections/interest.tsx:132
#: src/dialogs/resume/sections/language.tsx:71
#: src/dialogs/resume/sections/language.tsx:130
#: src/dialogs/resume/sections/profile.tsx:74
#: src/dialogs/resume/sections/profile.tsx:134
#: src/dialogs/resume/sections/project.tsx:72
#: src/dialogs/resume/sections/project.tsx:132
#: src/dialogs/resume/sections/publication.tsx:73
#: src/dialogs/resume/sections/publication.tsx:134
#: src/dialogs/resume/sections/reference.tsx:69
#: src/dialogs/resume/sections/reference.tsx:127
#: src/dialogs/resume/sections/skill.tsx:77
#: src/dialogs/resume/sections/skill.tsx:138
#: src/dialogs/resume/sections/volunteer.tsx:73
#: src/dialogs/resume/sections/volunteer.tsx:134
#: src/hooks/use-prompt.tsx:52
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx:86
#: src/routes/dashboard/settings/api-keys.tsx:40
@@ -456,7 +462,7 @@ msgstr "Catalan"
msgid "Center Align"
msgstr "Center Align"
#: src/routes/builder/$resumeId/-components/dock.tsx:73
#: src/routes/builder/$resumeId/-components/dock.tsx:83
msgid "Center view"
msgstr "Center view"
@@ -512,7 +518,7 @@ msgstr "Click here to select a file to import"
msgid "Code Block"
msgstr "Code Block"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:156
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:237
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:142
msgid "Columns"
msgstr "Columns"
@@ -521,7 +527,7 @@ msgstr "Columns"
msgid "Community"
msgstr "Community"
#: src/dialogs/resume/sections/experience.tsx:149
#: src/dialogs/resume/sections/experience.tsx:160
msgid "Company"
msgstr "Company"
@@ -540,10 +546,6 @@ msgstr "Confirm"
msgid "Connect"
msgstr "Connect"
#: src/dialogs/resume/sections/custom.tsx:162
msgid "Content"
msgstr "Content"
#: src/dialogs/auth/enable-two-factor.tsx:207
#: src/dialogs/auth/enable-two-factor.tsx:270
#: src/dialogs/auth/enable-two-factor.tsx:305
@@ -583,25 +585,25 @@ msgstr "Copy Backup Codes"
msgid "Copy this secret key and use it in your applications to access your data."
msgstr "Copy this secret key and use it in your applications to access your data."
#: src/routes/builder/$resumeId/-components/dock.tsx:75
#: src/routes/builder/$resumeId/-components/dock.tsx:85
msgid "Copy URL"
msgstr "Copy URL"
#: src/dialogs/api-key/create.tsx:153
#: src/dialogs/resume/index.tsx:122
#: src/dialogs/resume/sections/award.tsx:72
#: src/dialogs/resume/sections/certification.tsx:72
#: src/dialogs/resume/sections/custom.tsx:73
#: src/dialogs/resume/sections/education.tsx:75
#: src/dialogs/resume/sections/experience.tsx:73
#: src/dialogs/resume/sections/interest.tsx:72
#: src/dialogs/resume/sections/language.tsx:70
#: src/dialogs/resume/sections/profile.tsx:73
#: src/dialogs/resume/sections/project.tsx:71
#: src/dialogs/resume/sections/publication.tsx:72
#: src/dialogs/resume/sections/reference.tsx:68
#: src/dialogs/resume/sections/skill.tsx:76
#: src/dialogs/resume/sections/volunteer.tsx:72
#: src/dialogs/resume/sections/award.tsx:77
#: src/dialogs/resume/sections/certification.tsx:77
#: src/dialogs/resume/sections/custom.tsx:88
#: src/dialogs/resume/sections/education.tsx:80
#: src/dialogs/resume/sections/experience.tsx:78
#: src/dialogs/resume/sections/interest.tsx:77
#: src/dialogs/resume/sections/language.tsx:75
#: src/dialogs/resume/sections/profile.tsx:78
#: src/dialogs/resume/sections/project.tsx:76
#: src/dialogs/resume/sections/publication.tsx:77
#: src/dialogs/resume/sections/reference.tsx:73
#: src/dialogs/resume/sections/skill.tsx:81
#: src/dialogs/resume/sections/volunteer.tsx:77
msgid "Create"
msgstr "Create"
@@ -614,47 +616,47 @@ msgstr "Create a new account"
msgid "Create a new API key"
msgstr "Create a new API key"
#: src/dialogs/resume/sections/award.tsx:57
#: src/dialogs/resume/sections/award.tsx:62
msgid "Create a new award"
msgstr "Create a new award"
#: src/dialogs/resume/sections/certification.tsx:57
#: src/dialogs/resume/sections/certification.tsx:62
msgid "Create a new certification"
msgstr "Create a new certification"
#: src/dialogs/resume/sections/custom.tsx:58
#: src/dialogs/resume/sections/custom.tsx:73
msgid "Create a new custom section"
msgstr "Create a new custom section"
#: src/dialogs/resume/sections/education.tsx:60
#: src/dialogs/resume/sections/education.tsx:65
msgid "Create a new education"
msgstr "Create a new education"
#: src/dialogs/resume/sections/experience.tsx:58
#: src/dialogs/resume/sections/experience.tsx:63
msgid "Create a new experience"
msgstr "Create a new experience"
#: src/dialogs/resume/sections/interest.tsx:57
#: src/dialogs/resume/sections/interest.tsx:62
msgid "Create a new interest"
msgstr "Create a new interest"
#: src/dialogs/resume/sections/language.tsx:55
#: src/dialogs/resume/sections/language.tsx:60
msgid "Create a new language"
msgstr "Create a new language"
#: src/dialogs/resume/sections/profile.tsx:58
#: src/dialogs/resume/sections/profile.tsx:63
msgid "Create a new profile"
msgstr "Create a new profile"
#: src/dialogs/resume/sections/project.tsx:56
#: src/dialogs/resume/sections/project.tsx:61
msgid "Create a new project"
msgstr "Create a new project"
#: src/dialogs/resume/sections/publication.tsx:57
#: src/dialogs/resume/sections/publication.tsx:62
msgid "Create a new publication"
msgstr "Create a new publication"
#: src/dialogs/resume/sections/reference.tsx:53
#: src/dialogs/resume/sections/reference.tsx:58
msgid "Create a new reference"
msgstr "Create a new reference"
@@ -664,11 +666,11 @@ msgstr "Create a new reference"
msgid "Create a new resume"
msgstr "Create a new resume"
#: src/dialogs/resume/sections/skill.tsx:61
#: src/dialogs/resume/sections/skill.tsx:66
msgid "Create a new skill"
msgstr "Create a new skill"
#: src/dialogs/resume/sections/volunteer.tsx:57
#: src/dialogs/resume/sections/volunteer.tsx:62
msgid "Create a new volunteer experience"
msgstr "Create a new volunteer experience"
@@ -730,19 +732,19 @@ msgstr "Dark theme"
msgid "Data Security"
msgstr "Data Security"
#: src/dialogs/resume/sections/award.tsx:179
#: src/dialogs/resume/sections/certification.tsx:179
#: src/dialogs/resume/sections/publication.tsx:179
#: src/dialogs/resume/sections/award.tsx:190
#: src/dialogs/resume/sections/certification.tsx:190
#: src/dialogs/resume/sections/publication.tsx:190
msgid "Date"
msgstr "Date"
#: src/dialogs/resume/sections/education.tsx:169
#: src/dialogs/resume/sections/education.tsx:180
msgid "Degree"
msgstr "Degree"
#: src/routes/builder/$resumeId/-components/header.tsx:152
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:176
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:141
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:257
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:291
#: src/routes/dashboard/resumes/-components/resume-card.tsx:152
#: src/routes/dashboard/settings/api-keys.tsx:39
msgid "Delete"
@@ -789,14 +791,14 @@ msgstr "Deleting your passkey..."
msgid "Deleting your resume..."
msgstr "Deleting your resume..."
#: src/dialogs/resume/sections/award.tsx:211
#: src/dialogs/resume/sections/certification.tsx:211
#: src/dialogs/resume/sections/education.tsx:265
#: src/dialogs/resume/sections/experience.tsx:229
#: src/dialogs/resume/sections/project.tsx:193
#: src/dialogs/resume/sections/publication.tsx:211
#: src/dialogs/resume/sections/reference.tsx:156
#: src/dialogs/resume/sections/volunteer.tsx:211
#: src/dialogs/resume/sections/award.tsx:222
#: src/dialogs/resume/sections/certification.tsx:222
#: src/dialogs/resume/sections/education.tsx:276
#: src/dialogs/resume/sections/experience.tsx:240
#: src/dialogs/resume/sections/project.tsx:204
#: src/dialogs/resume/sections/publication.tsx:222
#: src/dialogs/resume/sections/reference.tsx:167
#: src/dialogs/resume/sections/volunteer.tsx:222
msgid "Description"
msgstr "Description"
@@ -859,7 +861,11 @@ msgstr "Download a copy of your resume in JSON format. Use this file for backup
msgid "Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume with recruiters."
msgstr "Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume with recruiters."
#: src/routes/builder/$resumeId/-components/dock.tsx:77
#: src/routes/builder/$resumeId/-components/dock.tsx:86
msgid "Download JSON"
msgstr "Download JSON"
#: src/routes/builder/$resumeId/-components/dock.tsx:88
msgid "Download PDF"
msgstr "Download PDF"
@@ -877,8 +883,8 @@ msgstr "Drag and drop sections here to move them between columns"
#: src/dialogs/resume/index.tsx:269
#: src/routes/builder/$resumeId/-components/header.tsx:140
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:150
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:132
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:231
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:280
#: src/routes/dashboard/resumes/-components/resume-card.tsx:140
msgid "Duplicate"
msgstr "Duplicate"
@@ -1032,7 +1038,7 @@ msgstr "Finnish"
msgid "Flexibility"
msgstr "Flexibility"
#: src/dialogs/resume/sections/language.tsx:159
#: src/dialogs/resume/sections/language.tsx:170
msgid "Fluency"
msgstr "Fluency"
@@ -1116,7 +1122,7 @@ msgstr "Go to dashboard"
msgid "Go to..."
msgstr "Go to..."
#: src/dialogs/resume/sections/education.tsx:201
#: src/dialogs/resume/sections/education.tsx:212
msgid "Grade"
msgstr "Grade"
@@ -1182,13 +1188,13 @@ msgid "Here's your new API key"
msgstr "Here's your new API key"
#: src/components/level/combobox.tsx:14
#: src/dialogs/resume/sections/language.tsx:187
#: src/dialogs/resume/sections/skill.tsx:214
#: src/dialogs/resume/sections/language.tsx:198
#: src/dialogs/resume/sections/skill.tsx:225
msgid "Hidden"
msgstr "Hidden"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:140
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:118
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:221
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:266
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:131
msgid "Hide"
msgstr "Hide"
@@ -1276,7 +1282,7 @@ msgstr "Is Reactive Resume available in multiple languages?"
msgid "Is Reactive Resume really free?"
msgstr "Is Reactive Resume really free?"
#: src/dialogs/resume/sections/certification.tsx:163
#: src/dialogs/resume/sections/certification.tsx:174
msgid "Issuer"
msgstr "Issuer"
@@ -1300,8 +1306,8 @@ msgstr "Justify Align"
msgid "Kannada"
msgstr "Kannada"
#: src/dialogs/resume/sections/interest.tsx:180
#: src/dialogs/resume/sections/skill.tsx:225
#: src/dialogs/resume/sections/interest.tsx:191
#: src/dialogs/resume/sections/skill.tsx:236
msgid "Keywords"
msgstr "Keywords"
@@ -1323,7 +1329,7 @@ msgstr "Landscape"
#: src/components/command-palette/pages/preferences/language.tsx:17
#: src/components/user/dropdown-menu.tsx:74
#: src/dialogs/resume/sections/language.tsx:143
#: src/dialogs/resume/sections/language.tsx:154
#: src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx:51
#: src/routes/dashboard/settings/preferences.tsx:39
msgid "Language"
@@ -1367,8 +1373,8 @@ msgstr "Leave empty to reset the title to the original."
msgid "Left Align"
msgstr "Left Align"
#: src/dialogs/resume/sections/language.tsx:175
#: src/dialogs/resume/sections/skill.tsx:202
#: src/dialogs/resume/sections/language.tsx:186
#: src/dialogs/resume/sections/skill.tsx:213
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx:228
msgid "Level"
msgstr "Level"
@@ -1411,9 +1417,9 @@ msgstr "Loading resumes..."
msgid "Loading..."
msgstr "Loading..."
#: src/dialogs/resume/sections/education.tsx:217
#: src/dialogs/resume/sections/experience.tsx:181
#: src/dialogs/resume/sections/volunteer.tsx:163
#: src/dialogs/resume/sections/education.tsx:228
#: src/dialogs/resume/sections/experience.tsx:192
#: src/dialogs/resume/sections/volunteer.tsx:174
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx:114
msgid "Location"
msgstr "Location"
@@ -1467,16 +1473,20 @@ msgstr "Margin (Vertical)"
msgid "Model"
msgstr "Model"
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:108
msgid "Move to"
msgstr "Move to"
#: src/routes/_home/-sections/features.tsx:70
msgid "Multilingual"
msgstr "Multilingual"
#: src/dialogs/api-key/create.tsx:94
#: src/dialogs/resume/index.tsx:298
#: src/dialogs/resume/sections/interest.tsx:163
#: src/dialogs/resume/sections/project.tsx:145
#: src/dialogs/resume/sections/reference.tsx:140
#: src/dialogs/resume/sections/skill.tsx:169
#: src/dialogs/resume/sections/interest.tsx:174
#: src/dialogs/resume/sections/project.tsx:156
#: src/dialogs/resume/sections/reference.tsx:151
#: src/dialogs/resume/sections/skill.tsx:180
#: src/routes/auth/register.tsx:107
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx:50
#: src/routes/dashboard/settings/profile.tsx:134
@@ -1487,7 +1497,7 @@ msgstr "Name"
msgid "Nepali"
msgstr "Nepali"
#: src/dialogs/resume/sections/profile.tsx:165
#: src/dialogs/resume/sections/profile.tsx:176
msgid "Network"
msgstr "Network"
@@ -1499,11 +1509,19 @@ msgstr "New features are constantly being added and improved, so be sure to chec
msgid "New Line"
msgstr "New Line"
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:145
msgid "New Page"
msgstr "New Page"
#: src/dialogs/auth/change-password.tsx:116
#: src/routes/auth/reset-password.tsx:82
msgid "New Password"
msgstr "New Password"
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:134
msgid "New Section"
msgstr "New Section"
#: src/routes/_home/-sections/features.tsx:52
msgid "No Advertising, No Tracking"
msgstr "No Advertising, No Tracking"
@@ -1565,7 +1583,7 @@ msgstr "or continue with"
msgid "Ordered List"
msgstr "Ordered List"
#: src/dialogs/resume/sections/volunteer.tsx:147
#: src/dialogs/resume/sections/volunteer.tsx:158
msgid "Organization"
msgstr "Organization"
@@ -1574,6 +1592,7 @@ msgid "Page"
msgstr "Page"
#. placeholder {0}: pageIndex + 1
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:117
#: src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx:246
msgid "Page {0}"
msgstr "Page {0}"
@@ -1619,10 +1638,10 @@ msgstr "Password protection has been disabled."
msgid "Password protection has been enabled."
msgstr "Password protection has been enabled."
#: src/dialogs/resume/sections/education.tsx:233
#: src/dialogs/resume/sections/experience.tsx:197
#: src/dialogs/resume/sections/project.tsx:161
#: src/dialogs/resume/sections/volunteer.tsx:179
#: src/dialogs/resume/sections/education.tsx:244
#: src/dialogs/resume/sections/experience.tsx:208
#: src/dialogs/resume/sections/project.tsx:172
#: src/dialogs/resume/sections/volunteer.tsx:190
msgid "Period"
msgstr "Period"
@@ -1670,7 +1689,7 @@ msgstr "Portuguese (Brazil)"
msgid "Portuguese (Portugal)"
msgstr "Portuguese (Portugal)"
#: src/dialogs/resume/sections/experience.tsx:165
#: src/dialogs/resume/sections/experience.tsx:176
msgid "Position"
msgstr "Position"
@@ -1686,7 +1705,7 @@ msgstr "Preferences"
msgid "Primary Color"
msgstr "Primary Color"
#: src/dialogs/resume/sections/skill.tsx:186
#: src/dialogs/resume/sections/skill.tsx:197
msgid "Proficiency"
msgstr "Proficiency"
@@ -1725,7 +1744,7 @@ msgstr "Provider"
msgid "Publications"
msgstr "Publications"
#: src/dialogs/resume/sections/publication.tsx:163
#: src/dialogs/resume/sections/publication.tsx:174
msgid "Publisher"
msgstr "Publisher"
@@ -1817,7 +1836,7 @@ msgstr "Reset Password"
msgid "Reset your password"
msgstr "Reset your password"
#: src/routes/builder/$resumeId/-components/dock.tsx:72
#: src/routes/builder/$resumeId/-components/dock.tsx:82
msgid "Reset zoom"
msgstr "Reset zoom"
@@ -1857,19 +1876,19 @@ msgid "Russian"
msgstr "Russian"
#: src/dialogs/resume/index.tsx:201
#: src/dialogs/resume/sections/award.tsx:127
#: src/dialogs/resume/sections/certification.tsx:127
#: src/dialogs/resume/sections/custom.tsx:126
#: src/dialogs/resume/sections/education.tsx:133
#: src/dialogs/resume/sections/experience.tsx:129
#: src/dialogs/resume/sections/interest.tsx:125
#: src/dialogs/resume/sections/language.tsx:123
#: src/dialogs/resume/sections/profile.tsx:127
#: src/dialogs/resume/sections/project.tsx:125
#: src/dialogs/resume/sections/publication.tsx:127
#: src/dialogs/resume/sections/reference.tsx:120
#: src/dialogs/resume/sections/skill.tsx:131
#: src/dialogs/resume/sections/volunteer.tsx:127
#: src/dialogs/resume/sections/award.tsx:138
#: src/dialogs/resume/sections/certification.tsx:138
#: src/dialogs/resume/sections/custom.tsx:142
#: src/dialogs/resume/sections/education.tsx:144
#: src/dialogs/resume/sections/experience.tsx:140
#: src/dialogs/resume/sections/interest.tsx:136
#: src/dialogs/resume/sections/language.tsx:134
#: src/dialogs/resume/sections/profile.tsx:138
#: src/dialogs/resume/sections/project.tsx:136
#: src/dialogs/resume/sections/publication.tsx:138
#: src/dialogs/resume/sections/reference.tsx:131
#: src/dialogs/resume/sections/skill.tsx:142
#: src/dialogs/resume/sections/volunteer.tsx:138
#: src/routes/dashboard/settings/profile.tsx:224
msgid "Save Changes"
msgstr "Save Changes"
@@ -1878,7 +1897,7 @@ msgstr "Save Changes"
msgid "Scan the QR code below with your preferred authenticator app. You can also copy the secret below and paste it into your app."
msgstr "Scan the QR code below with your preferred authenticator app. You can also copy the secret below and paste it into your app."
#: src/dialogs/resume/sections/education.tsx:153
#: src/dialogs/resume/sections/education.tsx:164
msgid "School"
msgstr "School"
@@ -1900,6 +1919,10 @@ msgstr "Search..."
msgid "Secret copied to clipboard."
msgstr "Secret copied to clipboard."
#: src/dialogs/resume/sections/custom.tsx:178
msgid "Section Type"
msgstr "Section Type"
#: src/components/ui/combobox.tsx:41
#: src/components/ui/multiple-combobox.tsx:49
msgid "Select..."
@@ -1958,8 +1981,8 @@ msgstr "Shareable Links"
msgid "Sharing"
msgstr "Sharing"
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:140
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:118
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:221
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:266
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:131
msgid "Show"
msgstr "Show"
@@ -2256,10 +2279,10 @@ msgstr "Tip: Give your API key a name, corresponding to the purpose of the key,
msgid "Tip: You can name the resume referring to the position you are applying for."
msgstr "Tip: You can name the resume referring to the position you are applying for."
#: src/dialogs/resume/sections/award.tsx:147
#: src/dialogs/resume/sections/certification.tsx:147
#: src/dialogs/resume/sections/custom.tsx:146
#: src/dialogs/resume/sections/publication.tsx:147
#: src/dialogs/resume/sections/award.tsx:158
#: src/dialogs/resume/sections/certification.tsx:158
#: src/dialogs/resume/sections/custom.tsx:162
#: src/dialogs/resume/sections/publication.tsx:158
msgid "Title"
msgstr "Title"
@@ -2378,61 +2401,61 @@ msgid "Unverified"
msgstr "Unverified"
#: src/routes/builder/$resumeId/-components/header.tsx:135
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:145
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:127
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:226
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:275
#: src/routes/dashboard/resumes/-components/resume-card.tsx:135
msgid "Update"
msgstr "Update"
#: src/dialogs/resume/sections/award.tsx:112
#: src/dialogs/resume/sections/award.tsx:123
msgid "Update an existing award"
msgstr "Update an existing award"
#: src/dialogs/resume/sections/certification.tsx:112
#: src/dialogs/resume/sections/certification.tsx:123
msgid "Update an existing certification"
msgstr "Update an existing certification"
#: src/dialogs/resume/sections/custom.tsx:111
#: src/dialogs/resume/sections/custom.tsx:127
msgid "Update an existing custom section"
msgstr "Update an existing custom section"
#: src/dialogs/resume/sections/education.tsx:118
#: src/dialogs/resume/sections/education.tsx:129
msgid "Update an existing education"
msgstr "Update an existing education"
#: src/dialogs/resume/sections/experience.tsx:114
#: src/dialogs/resume/sections/experience.tsx:125
msgid "Update an existing experience"
msgstr "Update an existing experience"
#: src/dialogs/resume/sections/interest.tsx:110
#: src/dialogs/resume/sections/interest.tsx:121
msgid "Update an existing interest"
msgstr "Update an existing interest"
#: src/dialogs/resume/sections/language.tsx:108
#: src/dialogs/resume/sections/language.tsx:119
msgid "Update an existing language"
msgstr "Update an existing language"
#: src/dialogs/resume/sections/profile.tsx:112
#: src/dialogs/resume/sections/profile.tsx:123
msgid "Update an existing profile"
msgstr "Update an existing profile"
#: src/dialogs/resume/sections/project.tsx:110
#: src/dialogs/resume/sections/project.tsx:121
msgid "Update an existing project"
msgstr "Update an existing project"
#: src/dialogs/resume/sections/publication.tsx:112
#: src/dialogs/resume/sections/publication.tsx:123
msgid "Update an existing publication"
msgstr "Update an existing publication"
#: src/dialogs/resume/sections/reference.tsx:105
#: src/dialogs/resume/sections/reference.tsx:116
msgid "Update an existing reference"
msgstr "Update an existing reference"
#: src/dialogs/resume/sections/skill.tsx:116
#: src/dialogs/resume/sections/skill.tsx:127
msgid "Update an existing skill"
msgstr "Update an existing skill"
#: src/dialogs/resume/sections/volunteer.tsx:112
#: src/dialogs/resume/sections/volunteer.tsx:123
msgid "Update an existing volunteer experience"
msgstr "Update an existing volunteer experience"
@@ -2465,7 +2488,7 @@ msgstr "Uploading picture..."
msgid "URL"
msgstr "URL"
#: src/dialogs/resume/sections/profile.tsx:182
#: src/dialogs/resume/sections/profile.tsx:193
#: src/routes/auth/register.tsx:123
#: src/routes/dashboard/settings/profile.tsx:150
msgid "Username"
@@ -2525,14 +2548,14 @@ msgstr "Views"
msgid "Volunteer"
msgstr "Volunteer"
#: src/dialogs/resume/sections/award.tsx:195
#: src/dialogs/resume/sections/certification.tsx:195
#: src/dialogs/resume/sections/education.tsx:249
#: src/dialogs/resume/sections/experience.tsx:213
#: src/dialogs/resume/sections/profile.tsx:206
#: src/dialogs/resume/sections/project.tsx:177
#: src/dialogs/resume/sections/publication.tsx:195
#: src/dialogs/resume/sections/volunteer.tsx:195
#: src/dialogs/resume/sections/award.tsx:206
#: src/dialogs/resume/sections/certification.tsx:206
#: src/dialogs/resume/sections/education.tsx:260
#: src/dialogs/resume/sections/experience.tsx:224
#: src/dialogs/resume/sections/profile.tsx:217
#: src/dialogs/resume/sections/project.tsx:188
#: src/dialogs/resume/sections/publication.tsx:206
#: src/dialogs/resume/sections/volunteer.tsx:206
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx:130
msgid "Website"
msgstr "Website"
@@ -2666,11 +2689,11 @@ msgstr "Your resume's public link is currently protected by a password. Share th
msgid "Your support ensures the project remains free and accessible for everyone, now and in the future."
msgstr "Your support ensures the project remains free and accessible for everyone, now and in the future."
#: src/routes/builder/$resumeId/-components/dock.tsx:70
#: src/routes/builder/$resumeId/-components/dock.tsx:80
msgid "Zoom in"
msgstr "Zoom in"
#: src/routes/builder/$resumeId/-components/dock.tsx:71
#: src/routes/builder/$resumeId/-components/dock.tsx:81
msgid "Zoom out"
msgstr "Zoom out"
+21
View File
@@ -42,6 +42,11 @@ msgstr "(s'ouvre dans un nouvel onglet)"
msgid "{0, plural, one {# item} other {# items}}"
msgstr ""
#. placeholder {0}: section.items.length
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:122
msgid "{0, plural, one {# item} other {# items}}"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:244
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:149
msgid "{column, plural, one {# Column} other {# Columns}}"
@@ -1482,6 +1487,10 @@ msgstr "Modèle"
msgid "Move to"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:108
msgid "Move to"
msgstr ""
#: src/routes/_home/-sections/features.tsx:70
msgid "Multilingual"
msgstr "Multilingue"
@@ -1518,6 +1527,10 @@ msgstr "Nouvelle ligne"
msgid "New Page"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:145
msgid "New Page"
msgstr ""
#: src/dialogs/auth/change-password.tsx:116
#: src/routes/auth/reset-password.tsx:82
msgid "New Password"
@@ -1527,6 +1540,10 @@ msgstr "Nouveau mot de passe"
msgid "New Section"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:134
msgid "New Section"
msgstr ""
#: src/routes/_home/-sections/features.tsx:52
msgid "No Advertising, No Tracking"
msgstr "Pas de publicité, pas de suivi"
@@ -1928,6 +1945,10 @@ msgstr "Secret copié dans le presse-papiers."
msgid "Section Type"
msgstr ""
#: src/dialogs/resume/sections/custom.tsx:178
msgid "Section Type"
msgstr ""
#: src/components/ui/combobox.tsx:41
#: src/components/ui/multiple-combobox.tsx:49
msgid "Select..."
+180 -157
View File
@@ -32,7 +32,12 @@ msgstr ""
msgid "(opens in new tab)"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:163
#. placeholder {0}: section.items.length
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:122
msgid "{0, plural, one {# item} other {# items}}"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:244
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:149
msgid "{column, plural, one {# Column} other {# Columns}}"
msgstr ""
@@ -78,7 +83,7 @@ msgstr ""
msgid "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:49
#: src/routes/builder/$resumeId/-components/dock.tsx:50
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx:39
msgid "A link to your resume has been copied to clipboard."
msgstr ""
@@ -119,7 +124,7 @@ msgstr ""
msgid "Add a new certification"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:47
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:83
msgid "Add a new custom section"
msgstr ""
@@ -135,6 +140,7 @@ msgstr ""
msgid "Add a new interest"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:152
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:120
msgid "Add a new item"
msgstr ""
@@ -303,7 +309,7 @@ msgstr ""
msgid "Are you sure you want to remove password protection?"
msgstr ""
#: src/dialogs/resume/sections/education.tsx:185
#: src/dialogs/resume/sections/education.tsx:196
msgid "Area of Study"
msgstr ""
@@ -329,7 +335,7 @@ msgstr ""
msgid "Available in multiple languages. If you would like to contribute, check out Crowdin."
msgstr ""
#: src/dialogs/resume/sections/award.tsx:163
#: src/dialogs/resume/sections/award.tsx:174
msgctxt "(noun) person, organization, or entity that gives an award"
msgid "Awarder"
msgstr ""
@@ -414,32 +420,32 @@ msgid "Can I export my resume to PDF?"
msgstr ""
#: src/dialogs/auth/enable-two-factor.tsx:267
#: src/dialogs/resume/sections/award.tsx:68
#: src/dialogs/resume/sections/award.tsx:123
#: src/dialogs/resume/sections/certification.tsx:68
#: src/dialogs/resume/sections/certification.tsx:123
#: src/dialogs/resume/sections/custom.tsx:69
#: src/dialogs/resume/sections/custom.tsx:122
#: src/dialogs/resume/sections/education.tsx:71
#: src/dialogs/resume/sections/education.tsx:129
#: src/dialogs/resume/sections/experience.tsx:69
#: src/dialogs/resume/sections/experience.tsx:125
#: src/dialogs/resume/sections/interest.tsx:68
#: src/dialogs/resume/sections/interest.tsx:121
#: src/dialogs/resume/sections/language.tsx:66
#: src/dialogs/resume/sections/language.tsx:119
#: src/dialogs/resume/sections/profile.tsx:69
#: src/dialogs/resume/sections/profile.tsx:123
#: src/dialogs/resume/sections/project.tsx:67
#: src/dialogs/resume/sections/project.tsx:121
#: src/dialogs/resume/sections/publication.tsx:68
#: src/dialogs/resume/sections/publication.tsx:123
#: src/dialogs/resume/sections/reference.tsx:64
#: src/dialogs/resume/sections/reference.tsx:116
#: src/dialogs/resume/sections/skill.tsx:72
#: src/dialogs/resume/sections/skill.tsx:127
#: src/dialogs/resume/sections/volunteer.tsx:68
#: src/dialogs/resume/sections/volunteer.tsx:123
#: src/dialogs/resume/sections/award.tsx:73
#: src/dialogs/resume/sections/award.tsx:134
#: src/dialogs/resume/sections/certification.tsx:73
#: src/dialogs/resume/sections/certification.tsx:134
#: src/dialogs/resume/sections/custom.tsx:84
#: src/dialogs/resume/sections/custom.tsx:138
#: src/dialogs/resume/sections/education.tsx:76
#: src/dialogs/resume/sections/education.tsx:140
#: src/dialogs/resume/sections/experience.tsx:74
#: src/dialogs/resume/sections/experience.tsx:136
#: src/dialogs/resume/sections/interest.tsx:73
#: src/dialogs/resume/sections/interest.tsx:132
#: src/dialogs/resume/sections/language.tsx:71
#: src/dialogs/resume/sections/language.tsx:130
#: src/dialogs/resume/sections/profile.tsx:74
#: src/dialogs/resume/sections/profile.tsx:134
#: src/dialogs/resume/sections/project.tsx:72
#: src/dialogs/resume/sections/project.tsx:132
#: src/dialogs/resume/sections/publication.tsx:73
#: src/dialogs/resume/sections/publication.tsx:134
#: src/dialogs/resume/sections/reference.tsx:69
#: src/dialogs/resume/sections/reference.tsx:127
#: src/dialogs/resume/sections/skill.tsx:77
#: src/dialogs/resume/sections/skill.tsx:138
#: src/dialogs/resume/sections/volunteer.tsx:73
#: src/dialogs/resume/sections/volunteer.tsx:134
#: src/hooks/use-prompt.tsx:52
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx:86
#: src/routes/dashboard/settings/api-keys.tsx:40
@@ -456,7 +462,7 @@ msgstr ""
msgid "Center Align"
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:73
#: src/routes/builder/$resumeId/-components/dock.tsx:83
msgid "Center view"
msgstr ""
@@ -512,7 +518,7 @@ msgstr ""
msgid "Code Block"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:156
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:237
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:142
msgid "Columns"
msgstr ""
@@ -521,7 +527,7 @@ msgstr ""
msgid "Community"
msgstr ""
#: src/dialogs/resume/sections/experience.tsx:149
#: src/dialogs/resume/sections/experience.tsx:160
msgid "Company"
msgstr ""
@@ -540,10 +546,6 @@ msgstr ""
msgid "Connect"
msgstr ""
#: src/dialogs/resume/sections/custom.tsx:162
msgid "Content"
msgstr ""
#: src/dialogs/auth/enable-two-factor.tsx:207
#: src/dialogs/auth/enable-two-factor.tsx:270
#: src/dialogs/auth/enable-two-factor.tsx:305
@@ -583,25 +585,25 @@ msgstr ""
msgid "Copy this secret key and use it in your applications to access your data."
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:75
#: src/routes/builder/$resumeId/-components/dock.tsx:85
msgid "Copy URL"
msgstr ""
#: src/dialogs/api-key/create.tsx:153
#: src/dialogs/resume/index.tsx:122
#: src/dialogs/resume/sections/award.tsx:72
#: src/dialogs/resume/sections/certification.tsx:72
#: src/dialogs/resume/sections/custom.tsx:73
#: src/dialogs/resume/sections/education.tsx:75
#: src/dialogs/resume/sections/experience.tsx:73
#: src/dialogs/resume/sections/interest.tsx:72
#: src/dialogs/resume/sections/language.tsx:70
#: src/dialogs/resume/sections/profile.tsx:73
#: src/dialogs/resume/sections/project.tsx:71
#: src/dialogs/resume/sections/publication.tsx:72
#: src/dialogs/resume/sections/reference.tsx:68
#: src/dialogs/resume/sections/skill.tsx:76
#: src/dialogs/resume/sections/volunteer.tsx:72
#: src/dialogs/resume/sections/award.tsx:77
#: src/dialogs/resume/sections/certification.tsx:77
#: src/dialogs/resume/sections/custom.tsx:88
#: src/dialogs/resume/sections/education.tsx:80
#: src/dialogs/resume/sections/experience.tsx:78
#: src/dialogs/resume/sections/interest.tsx:77
#: src/dialogs/resume/sections/language.tsx:75
#: src/dialogs/resume/sections/profile.tsx:78
#: src/dialogs/resume/sections/project.tsx:76
#: src/dialogs/resume/sections/publication.tsx:77
#: src/dialogs/resume/sections/reference.tsx:73
#: src/dialogs/resume/sections/skill.tsx:81
#: src/dialogs/resume/sections/volunteer.tsx:77
msgid "Create"
msgstr ""
@@ -614,47 +616,47 @@ msgstr ""
msgid "Create a new API key"
msgstr ""
#: src/dialogs/resume/sections/award.tsx:57
#: src/dialogs/resume/sections/award.tsx:62
msgid "Create a new award"
msgstr ""
#: src/dialogs/resume/sections/certification.tsx:57
#: src/dialogs/resume/sections/certification.tsx:62
msgid "Create a new certification"
msgstr ""
#: src/dialogs/resume/sections/custom.tsx:58
#: src/dialogs/resume/sections/custom.tsx:73
msgid "Create a new custom section"
msgstr ""
#: src/dialogs/resume/sections/education.tsx:60
#: src/dialogs/resume/sections/education.tsx:65
msgid "Create a new education"
msgstr ""
#: src/dialogs/resume/sections/experience.tsx:58
#: src/dialogs/resume/sections/experience.tsx:63
msgid "Create a new experience"
msgstr ""
#: src/dialogs/resume/sections/interest.tsx:57
#: src/dialogs/resume/sections/interest.tsx:62
msgid "Create a new interest"
msgstr ""
#: src/dialogs/resume/sections/language.tsx:55
#: src/dialogs/resume/sections/language.tsx:60
msgid "Create a new language"
msgstr ""
#: src/dialogs/resume/sections/profile.tsx:58
#: src/dialogs/resume/sections/profile.tsx:63
msgid "Create a new profile"
msgstr ""
#: src/dialogs/resume/sections/project.tsx:56
#: src/dialogs/resume/sections/project.tsx:61
msgid "Create a new project"
msgstr ""
#: src/dialogs/resume/sections/publication.tsx:57
#: src/dialogs/resume/sections/publication.tsx:62
msgid "Create a new publication"
msgstr ""
#: src/dialogs/resume/sections/reference.tsx:53
#: src/dialogs/resume/sections/reference.tsx:58
msgid "Create a new reference"
msgstr ""
@@ -664,11 +666,11 @@ msgstr ""
msgid "Create a new resume"
msgstr ""
#: src/dialogs/resume/sections/skill.tsx:61
#: src/dialogs/resume/sections/skill.tsx:66
msgid "Create a new skill"
msgstr ""
#: src/dialogs/resume/sections/volunteer.tsx:57
#: src/dialogs/resume/sections/volunteer.tsx:62
msgid "Create a new volunteer experience"
msgstr ""
@@ -730,19 +732,19 @@ msgstr ""
msgid "Data Security"
msgstr ""
#: src/dialogs/resume/sections/award.tsx:179
#: src/dialogs/resume/sections/certification.tsx:179
#: src/dialogs/resume/sections/publication.tsx:179
#: src/dialogs/resume/sections/award.tsx:190
#: src/dialogs/resume/sections/certification.tsx:190
#: src/dialogs/resume/sections/publication.tsx:190
msgid "Date"
msgstr ""
#: src/dialogs/resume/sections/education.tsx:169
#: src/dialogs/resume/sections/education.tsx:180
msgid "Degree"
msgstr ""
#: src/routes/builder/$resumeId/-components/header.tsx:152
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:176
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:141
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:257
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:291
#: src/routes/dashboard/resumes/-components/resume-card.tsx:152
#: src/routes/dashboard/settings/api-keys.tsx:39
msgid "Delete"
@@ -789,14 +791,14 @@ msgstr ""
msgid "Deleting your resume..."
msgstr ""
#: src/dialogs/resume/sections/award.tsx:211
#: src/dialogs/resume/sections/certification.tsx:211
#: src/dialogs/resume/sections/education.tsx:265
#: src/dialogs/resume/sections/experience.tsx:229
#: src/dialogs/resume/sections/project.tsx:193
#: src/dialogs/resume/sections/publication.tsx:211
#: src/dialogs/resume/sections/reference.tsx:156
#: src/dialogs/resume/sections/volunteer.tsx:211
#: src/dialogs/resume/sections/award.tsx:222
#: src/dialogs/resume/sections/certification.tsx:222
#: src/dialogs/resume/sections/education.tsx:276
#: src/dialogs/resume/sections/experience.tsx:240
#: src/dialogs/resume/sections/project.tsx:204
#: src/dialogs/resume/sections/publication.tsx:222
#: src/dialogs/resume/sections/reference.tsx:167
#: src/dialogs/resume/sections/volunteer.tsx:222
msgid "Description"
msgstr ""
@@ -859,7 +861,11 @@ msgstr ""
msgid "Download a copy of your resume in PDF format. Use this file for printing or to easily share your resume with recruiters."
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:77
#: src/routes/builder/$resumeId/-components/dock.tsx:86
msgid "Download JSON"
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:88
msgid "Download PDF"
msgstr ""
@@ -877,8 +883,8 @@ msgstr ""
#: src/dialogs/resume/index.tsx:269
#: src/routes/builder/$resumeId/-components/header.tsx:140
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:150
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:132
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:231
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:280
#: src/routes/dashboard/resumes/-components/resume-card.tsx:140
msgid "Duplicate"
msgstr ""
@@ -1032,7 +1038,7 @@ msgstr ""
msgid "Flexibility"
msgstr ""
#: src/dialogs/resume/sections/language.tsx:159
#: src/dialogs/resume/sections/language.tsx:170
msgid "Fluency"
msgstr ""
@@ -1116,7 +1122,7 @@ msgstr ""
msgid "Go to..."
msgstr ""
#: src/dialogs/resume/sections/education.tsx:201
#: src/dialogs/resume/sections/education.tsx:212
msgid "Grade"
msgstr ""
@@ -1182,13 +1188,13 @@ msgid "Here's your new API key"
msgstr ""
#: src/components/level/combobox.tsx:14
#: src/dialogs/resume/sections/language.tsx:187
#: src/dialogs/resume/sections/skill.tsx:214
#: src/dialogs/resume/sections/language.tsx:198
#: src/dialogs/resume/sections/skill.tsx:225
msgid "Hidden"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:140
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:118
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:221
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:266
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:131
msgid "Hide"
msgstr ""
@@ -1276,7 +1282,7 @@ msgstr ""
msgid "Is Reactive Resume really free?"
msgstr ""
#: src/dialogs/resume/sections/certification.tsx:163
#: src/dialogs/resume/sections/certification.tsx:174
msgid "Issuer"
msgstr ""
@@ -1300,8 +1306,8 @@ msgstr ""
msgid "Kannada"
msgstr ""
#: src/dialogs/resume/sections/interest.tsx:180
#: src/dialogs/resume/sections/skill.tsx:225
#: src/dialogs/resume/sections/interest.tsx:191
#: src/dialogs/resume/sections/skill.tsx:236
msgid "Keywords"
msgstr ""
@@ -1323,7 +1329,7 @@ msgstr ""
#: src/components/command-palette/pages/preferences/language.tsx:17
#: src/components/user/dropdown-menu.tsx:74
#: src/dialogs/resume/sections/language.tsx:143
#: src/dialogs/resume/sections/language.tsx:154
#: src/routes/builder/$resumeId/-sidebar/right/sections/page.tsx:51
#: src/routes/dashboard/settings/preferences.tsx:39
msgid "Language"
@@ -1367,8 +1373,8 @@ msgstr ""
msgid "Left Align"
msgstr ""
#: src/dialogs/resume/sections/language.tsx:175
#: src/dialogs/resume/sections/skill.tsx:202
#: src/dialogs/resume/sections/language.tsx:186
#: src/dialogs/resume/sections/skill.tsx:213
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx:228
msgid "Level"
msgstr ""
@@ -1411,9 +1417,9 @@ msgstr ""
msgid "Loading..."
msgstr ""
#: src/dialogs/resume/sections/education.tsx:217
#: src/dialogs/resume/sections/experience.tsx:181
#: src/dialogs/resume/sections/volunteer.tsx:163
#: src/dialogs/resume/sections/education.tsx:228
#: src/dialogs/resume/sections/experience.tsx:192
#: src/dialogs/resume/sections/volunteer.tsx:174
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx:114
msgid "Location"
msgstr ""
@@ -1467,16 +1473,20 @@ msgstr ""
msgid "Model"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:108
msgid "Move to"
msgstr ""
#: src/routes/_home/-sections/features.tsx:70
msgid "Multilingual"
msgstr ""
#: src/dialogs/api-key/create.tsx:94
#: src/dialogs/resume/index.tsx:298
#: src/dialogs/resume/sections/interest.tsx:163
#: src/dialogs/resume/sections/project.tsx:145
#: src/dialogs/resume/sections/reference.tsx:140
#: src/dialogs/resume/sections/skill.tsx:169
#: src/dialogs/resume/sections/interest.tsx:174
#: src/dialogs/resume/sections/project.tsx:156
#: src/dialogs/resume/sections/reference.tsx:151
#: src/dialogs/resume/sections/skill.tsx:180
#: src/routes/auth/register.tsx:107
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx:50
#: src/routes/dashboard/settings/profile.tsx:134
@@ -1487,7 +1497,7 @@ msgstr ""
msgid "Nepali"
msgstr ""
#: src/dialogs/resume/sections/profile.tsx:165
#: src/dialogs/resume/sections/profile.tsx:176
msgid "Network"
msgstr ""
@@ -1499,11 +1509,19 @@ msgstr ""
msgid "New Line"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:145
msgid "New Page"
msgstr ""
#: src/dialogs/auth/change-password.tsx:116
#: src/routes/auth/reset-password.tsx:82
msgid "New Password"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:134
msgid "New Section"
msgstr ""
#: src/routes/_home/-sections/features.tsx:52
msgid "No Advertising, No Tracking"
msgstr ""
@@ -1565,7 +1583,7 @@ msgstr ""
msgid "Ordered List"
msgstr ""
#: src/dialogs/resume/sections/volunteer.tsx:147
#: src/dialogs/resume/sections/volunteer.tsx:158
msgid "Organization"
msgstr ""
@@ -1574,6 +1592,7 @@ msgid "Page"
msgstr ""
#. placeholder {0}: pageIndex + 1
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:117
#: src/routes/builder/$resumeId/-sidebar/right/sections/layout/pages.tsx:246
msgid "Page {0}"
msgstr ""
@@ -1619,10 +1638,10 @@ msgstr ""
msgid "Password protection has been enabled."
msgstr ""
#: src/dialogs/resume/sections/education.tsx:233
#: src/dialogs/resume/sections/experience.tsx:197
#: src/dialogs/resume/sections/project.tsx:161
#: src/dialogs/resume/sections/volunteer.tsx:179
#: src/dialogs/resume/sections/education.tsx:244
#: src/dialogs/resume/sections/experience.tsx:208
#: src/dialogs/resume/sections/project.tsx:172
#: src/dialogs/resume/sections/volunteer.tsx:190
msgid "Period"
msgstr ""
@@ -1670,7 +1689,7 @@ msgstr ""
msgid "Portuguese (Portugal)"
msgstr ""
#: src/dialogs/resume/sections/experience.tsx:165
#: src/dialogs/resume/sections/experience.tsx:176
msgid "Position"
msgstr ""
@@ -1686,7 +1705,7 @@ msgstr ""
msgid "Primary Color"
msgstr ""
#: src/dialogs/resume/sections/skill.tsx:186
#: src/dialogs/resume/sections/skill.tsx:197
msgid "Proficiency"
msgstr ""
@@ -1725,7 +1744,7 @@ msgstr ""
msgid "Publications"
msgstr ""
#: src/dialogs/resume/sections/publication.tsx:163
#: src/dialogs/resume/sections/publication.tsx:174
msgid "Publisher"
msgstr ""
@@ -1817,7 +1836,7 @@ msgstr ""
msgid "Reset your password"
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:72
#: src/routes/builder/$resumeId/-components/dock.tsx:82
msgid "Reset zoom"
msgstr ""
@@ -1857,19 +1876,19 @@ msgid "Russian"
msgstr ""
#: src/dialogs/resume/index.tsx:201
#: src/dialogs/resume/sections/award.tsx:127
#: src/dialogs/resume/sections/certification.tsx:127
#: src/dialogs/resume/sections/custom.tsx:126
#: src/dialogs/resume/sections/education.tsx:133
#: src/dialogs/resume/sections/experience.tsx:129
#: src/dialogs/resume/sections/interest.tsx:125
#: src/dialogs/resume/sections/language.tsx:123
#: src/dialogs/resume/sections/profile.tsx:127
#: src/dialogs/resume/sections/project.tsx:125
#: src/dialogs/resume/sections/publication.tsx:127
#: src/dialogs/resume/sections/reference.tsx:120
#: src/dialogs/resume/sections/skill.tsx:131
#: src/dialogs/resume/sections/volunteer.tsx:127
#: src/dialogs/resume/sections/award.tsx:138
#: src/dialogs/resume/sections/certification.tsx:138
#: src/dialogs/resume/sections/custom.tsx:142
#: src/dialogs/resume/sections/education.tsx:144
#: src/dialogs/resume/sections/experience.tsx:140
#: src/dialogs/resume/sections/interest.tsx:136
#: src/dialogs/resume/sections/language.tsx:134
#: src/dialogs/resume/sections/profile.tsx:138
#: src/dialogs/resume/sections/project.tsx:136
#: src/dialogs/resume/sections/publication.tsx:138
#: src/dialogs/resume/sections/reference.tsx:131
#: src/dialogs/resume/sections/skill.tsx:142
#: src/dialogs/resume/sections/volunteer.tsx:138
#: src/routes/dashboard/settings/profile.tsx:224
msgid "Save Changes"
msgstr ""
@@ -1878,7 +1897,7 @@ msgstr ""
msgid "Scan the QR code below with your preferred authenticator app. You can also copy the secret below and paste it into your app."
msgstr ""
#: src/dialogs/resume/sections/education.tsx:153
#: src/dialogs/resume/sections/education.tsx:164
msgid "School"
msgstr ""
@@ -1900,6 +1919,10 @@ msgstr ""
msgid "Secret copied to clipboard."
msgstr ""
#: src/dialogs/resume/sections/custom.tsx:178
msgid "Section Type"
msgstr ""
#: src/components/ui/combobox.tsx:41
#: src/components/ui/multiple-combobox.tsx:49
msgid "Select..."
@@ -1958,8 +1981,8 @@ msgstr ""
msgid "Sharing"
msgstr ""
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:140
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:118
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:221
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:266
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx:131
msgid "Show"
msgstr ""
@@ -2256,10 +2279,10 @@ msgstr ""
msgid "Tip: You can name the resume referring to the position you are applying for."
msgstr ""
#: src/dialogs/resume/sections/award.tsx:147
#: src/dialogs/resume/sections/certification.tsx:147
#: src/dialogs/resume/sections/custom.tsx:146
#: src/dialogs/resume/sections/publication.tsx:147
#: src/dialogs/resume/sections/award.tsx:158
#: src/dialogs/resume/sections/certification.tsx:158
#: src/dialogs/resume/sections/custom.tsx:162
#: src/dialogs/resume/sections/publication.tsx:158
msgid "Title"
msgstr ""
@@ -2378,61 +2401,61 @@ msgid "Unverified"
msgstr ""
#: src/routes/builder/$resumeId/-components/header.tsx:135
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:145
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:127
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx:226
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx:275
#: src/routes/dashboard/resumes/-components/resume-card.tsx:135
msgid "Update"
msgstr ""
#: src/dialogs/resume/sections/award.tsx:112
#: src/dialogs/resume/sections/award.tsx:123
msgid "Update an existing award"
msgstr ""
#: src/dialogs/resume/sections/certification.tsx:112
#: src/dialogs/resume/sections/certification.tsx:123
msgid "Update an existing certification"
msgstr ""
#: src/dialogs/resume/sections/custom.tsx:111
#: src/dialogs/resume/sections/custom.tsx:127
msgid "Update an existing custom section"
msgstr ""
#: src/dialogs/resume/sections/education.tsx:118
#: src/dialogs/resume/sections/education.tsx:129
msgid "Update an existing education"
msgstr ""
#: src/dialogs/resume/sections/experience.tsx:114
#: src/dialogs/resume/sections/experience.tsx:125
msgid "Update an existing experience"
msgstr ""
#: src/dialogs/resume/sections/interest.tsx:110
#: src/dialogs/resume/sections/interest.tsx:121
msgid "Update an existing interest"
msgstr ""
#: src/dialogs/resume/sections/language.tsx:108
#: src/dialogs/resume/sections/language.tsx:119
msgid "Update an existing language"
msgstr ""
#: src/dialogs/resume/sections/profile.tsx:112
#: src/dialogs/resume/sections/profile.tsx:123
msgid "Update an existing profile"
msgstr ""
#: src/dialogs/resume/sections/project.tsx:110
#: src/dialogs/resume/sections/project.tsx:121
msgid "Update an existing project"
msgstr ""
#: src/dialogs/resume/sections/publication.tsx:112
#: src/dialogs/resume/sections/publication.tsx:123
msgid "Update an existing publication"
msgstr ""
#: src/dialogs/resume/sections/reference.tsx:105
#: src/dialogs/resume/sections/reference.tsx:116
msgid "Update an existing reference"
msgstr ""
#: src/dialogs/resume/sections/skill.tsx:116
#: src/dialogs/resume/sections/skill.tsx:127
msgid "Update an existing skill"
msgstr ""
#: src/dialogs/resume/sections/volunteer.tsx:112
#: src/dialogs/resume/sections/volunteer.tsx:123
msgid "Update an existing volunteer experience"
msgstr ""
@@ -2465,7 +2488,7 @@ msgstr ""
msgid "URL"
msgstr ""
#: src/dialogs/resume/sections/profile.tsx:182
#: src/dialogs/resume/sections/profile.tsx:193
#: src/routes/auth/register.tsx:123
#: src/routes/dashboard/settings/profile.tsx:150
msgid "Username"
@@ -2525,14 +2548,14 @@ msgstr ""
msgid "Volunteer"
msgstr ""
#: src/dialogs/resume/sections/award.tsx:195
#: src/dialogs/resume/sections/certification.tsx:195
#: src/dialogs/resume/sections/education.tsx:249
#: src/dialogs/resume/sections/experience.tsx:213
#: src/dialogs/resume/sections/profile.tsx:206
#: src/dialogs/resume/sections/project.tsx:177
#: src/dialogs/resume/sections/publication.tsx:195
#: src/dialogs/resume/sections/volunteer.tsx:195
#: src/dialogs/resume/sections/award.tsx:206
#: src/dialogs/resume/sections/certification.tsx:206
#: src/dialogs/resume/sections/education.tsx:260
#: src/dialogs/resume/sections/experience.tsx:224
#: src/dialogs/resume/sections/profile.tsx:217
#: src/dialogs/resume/sections/project.tsx:188
#: src/dialogs/resume/sections/publication.tsx:206
#: src/dialogs/resume/sections/volunteer.tsx:206
#: src/routes/builder/$resumeId/-sidebar/left/sections/basics.tsx:130
msgid "Website"
msgstr ""
@@ -2666,11 +2689,11 @@ msgstr ""
msgid "Your support ensures the project remains free and accessible for everyone, now and in the future."
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:70
#: src/routes/builder/$resumeId/-components/dock.tsx:80
msgid "Zoom in"
msgstr ""
#: src/routes/builder/$resumeId/-components/dock.tsx:71
#: src/routes/builder/$resumeId/-components/dock.tsx:81
msgid "Zoom out"
msgstr ""
+9 -8
View File
@@ -30,9 +30,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.17",
"@ai-sdk/anthropic": "^3.0.18",
"@ai-sdk/google": "^3.0.10",
"@ai-sdk/openai": "^3.0.13",
"@ai-sdk/openai": "^3.0.14",
"@aws-sdk/client-s3": "^3.972.0",
"@better-auth/core": "1.5.0-beta.9",
"@better-auth/passkey": "1.5.0-beta.9",
@@ -55,17 +55,17 @@
"@sindresorhus/slugify": "^3.0.0",
"@t3-oss/env-core": "^0.13.10",
"@tanstack/react-query": "5.90.19",
"@tanstack/react-router": "^1.153.2",
"@tanstack/react-router-ssr-query": "^1.153.2",
"@tanstack/react-start": "^1.154.0",
"@tanstack/zod-adapter": "^1.153.2",
"@tanstack/react-router": "^1.154.5",
"@tanstack/react-router-ssr-query": "^1.154.5",
"@tanstack/react-start": "^1.154.5",
"@tanstack/zod-adapter": "^1.154.5",
"@tiptap/extension-highlight": "^3.16.0",
"@tiptap/extension-table": "^3.16.0",
"@tiptap/extension-text-align": "^3.16.0",
"@tiptap/pm": "^3.16.0",
"@tiptap/react": "^3.16.0",
"@tiptap/starter-kit": "^3.16.0",
"ai": "^6.0.42",
"ai": "^6.0.44",
"ai-sdk-ollama": "^3.1.1",
"bcrypt": "^6.0.0",
"better-auth": "1.5.0-beta.9",
@@ -80,9 +80,10 @@
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"monaco-editor": "^0.55.1",
"motion": "^12.27.5",
"motion": "^12.28.1",
"nodemailer": "^7.0.12",
"pg": "^8.17.2",
"puppeteer-core": "^24.35.0",
"qrcode.react": "^4.2.0",
"radix-ui": "^1.4.3",
"react": "^19.2.3",
+579 -139
View File
File diff suppressed because it is too large Load Diff
@@ -14,10 +14,9 @@ const buttonVariants = cva(
accent: "bg-accent text-accent-foreground shadow-xs hover:bg-accent/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
outline: "border bg-background shadow-xs hover:bg-secondary/40 hover:text-secondary-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost: "hover:bg-secondary/40 hover:text-secondary-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
@@ -81,7 +81,7 @@ function AlertDialogContent({
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
transition = { type: "spring", stiffness: 150, damping: 25 },
transition = { type: "spring", stiffness: 250, damping: 25 },
...props
}: AlertDialogContentProps) {
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
@@ -99,6 +99,7 @@ function AlertDialogContent({
<motion.div
key="alert-dialog-content"
data-slot="alert-dialog-content"
transition={transition}
initial={{
opacity: 0,
filter: "blur(4px)",
@@ -114,7 +115,6 @@ function AlertDialogContent({
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
{...props}
/>
</AlertDialogPrimitive.Content>
@@ -77,7 +77,7 @@ function DialogContent({
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: "spring", stiffness: 150, damping: 25 },
transition = { type: "spring", stiffness: 250, damping: 25 },
...props
}: DialogContentProps) {
const initialRotation = from === "bottom" || from === "left" ? "20deg" : "-20deg";
@@ -97,6 +97,7 @@ function DialogContent({
<motion.div
key="dialog-content"
data-slot="dialog-content"
transition={transition}
initial={{
opacity: 0,
filter: "blur(4px)",
@@ -112,7 +113,6 @@ function DialogContent({
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
{...props}
/>
</DialogPrimitive.Content>
@@ -1,5 +1,7 @@
import { match } from "ts-pattern";
import type { SectionType } from "@/schema/resume/data";
import type { CustomSectionItem, SectionType } from "@/schema/resume/data";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
import { AwardsItem } from "./items/awards-item";
import { CertificationsItem } from "./items/certifications-item";
import { EducationItem } from "./items/education-item";
@@ -12,7 +14,6 @@ import { PublicationsItem } from "./items/publications-item";
import { ReferencesItem } from "./items/references-item";
import { SkillsItem } from "./items/skills-item";
import { VolunteerItem } from "./items/volunteer-item";
import { PageCustomSection } from "./page-custom";
import { PageSection } from "./page-section";
import { PageSummary } from "./page-summary";
@@ -21,6 +22,44 @@ type SectionComponentProps = {
itemClassName?: string;
};
// Helper to render item component based on type
function renderItemByType(type: SectionType, item: CustomSectionItem, itemClassName?: string) {
return match(type)
.with("profiles", () => (
<ProfilesItem {...(item as Parameters<typeof ProfilesItem>[0])} className={itemClassName} />
))
.with("experience", () => (
<ExperienceItem {...(item as Parameters<typeof ExperienceItem>[0])} className={itemClassName} />
))
.with("education", () => (
<EducationItem {...(item as Parameters<typeof EducationItem>[0])} className={itemClassName} />
))
.with("projects", () => (
<ProjectsItem {...(item as Parameters<typeof ProjectsItem>[0])} className={itemClassName} />
))
.with("skills", () => <SkillsItem {...(item as Parameters<typeof SkillsItem>[0])} className={itemClassName} />)
.with("languages", () => (
<LanguagesItem {...(item as Parameters<typeof LanguagesItem>[0])} className={itemClassName} />
))
.with("interests", () => (
<InterestsItem {...(item as Parameters<typeof InterestsItem>[0])} className={itemClassName} />
))
.with("awards", () => <AwardsItem {...(item as Parameters<typeof AwardsItem>[0])} className={itemClassName} />)
.with("certifications", () => (
<CertificationsItem {...(item as Parameters<typeof CertificationsItem>[0])} className={itemClassName} />
))
.with("publications", () => (
<PublicationsItem {...(item as Parameters<typeof PublicationsItem>[0])} className={itemClassName} />
))
.with("volunteer", () => (
<VolunteerItem {...(item as Parameters<typeof VolunteerItem>[0])} className={itemClassName} />
))
.with("references", () => (
<ReferencesItem {...(item as Parameters<typeof ReferencesItem>[0])} className={itemClassName} />
))
.exhaustive();
}
export function getSectionComponent(
section: "summary" | SectionType | (string & {}),
{ sectionClassName, itemClassName }: SectionComponentProps = {},
@@ -127,9 +166,39 @@ export function getSectionComponent(
return ReferencesSection;
})
.otherwise(() => {
const CustomSection = ({ id }: { id: string }) => (
<PageCustomSection sectionId={id} className={sectionClassName} />
);
return CustomSection;
// Custom section - render based on its type
const CustomSectionComponent = ({ id }: { id: string }) => {
const customSection = useResumeStore((state) => state.resume.data.customSections.find((s) => s.id === id));
if (!customSection) return null;
if (customSection.hidden) return null;
if (customSection.items.length === 0) return null;
const visibleItems = customSection.items.filter((item) => !item.hidden);
if (visibleItems.length === 0) return null;
return (
<section
className={cn(`page-section page-section-custom page-section-${id} wrap-break-word`, sectionClassName)}
>
<h6 className="mb-1 text-(--page-primary-color)">{customSection.title}</h6>
<div
className="section-content grid gap-x-(--page-gap-x) gap-y-(--page-gap-y)"
style={{ gridTemplateColumns: `repeat(${customSection.columns}, 1fr)` }}
>
{visibleItems.map((item) => (
<div
key={item.id}
className={cn(`section-item section-item-${customSection.type} wrap-break-word *:space-y-1`)}
>
{renderItemByType(customSection.type, item, itemClassName)}
</div>
))}
</div>
</section>
);
};
return CustomSectionComponent;
});
}
@@ -1,34 +0,0 @@
import { TiptapContent } from "@/components/input/rich-input";
import { cn } from "@/utils/style";
import { useResumeStore } from "../store/resume";
type PageCustomSectionProps = {
sectionId: string;
className?: string;
};
export function PageCustomSection({ sectionId, className }: PageCustomSectionProps) {
const section = useResumeStore((state) =>
state.resume.data.customSections.find((section) => section.id === sectionId),
);
// biome-ignore lint/complexity/noUselessFragments: render empty fragment, instead of null
if (!section) return <></>;
return (
<section
className={cn(
`page-section page-section-custom page-section-${sectionId} wrap-break-word`,
section.hidden && "hidden",
section.content === "" && "hidden",
className,
)}
>
<h6 className="mb-2 text-(--page-primary-color)">{section.title}</h6>
<div className="section-content">
<TiptapContent style={{ columnCount: section.columns }} content={section.content} />
</div>
</section>
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ export function AzurillTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<div className="flex gap-x-(--page-gap-x)">
+1 -1
View File
@@ -23,7 +23,7 @@ export function BronzorTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-bronzor page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<div className="space-y-(--page-gap-y)">
+1 -1
View File
@@ -20,7 +20,7 @@ export function KakunaTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-kakuna page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-kakuna page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-4">
+1 -1
View File
@@ -17,7 +17,7 @@ export function OnyxTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-onyx page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-(--page-gap-y)">
+1 -1
View File
@@ -24,7 +24,7 @@ export function PikachuTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-pikachu page-content px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-pikachu page-content px-(--page-margin-x) py-(--page-margin-y) print:p-0">
<div className="flex gap-x-(--page-margin-x)">
<aside
data-layout="sidebar"
+1 -1
View File
@@ -20,7 +20,7 @@ export function RhyhornTemplate({ pageIndex, pageLayout }: TemplateProps) {
const { main, sidebar, fullWidth } = pageLayout;
return (
<div className="template-rhyhorn page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y)">
<div className="template-rhyhorn page-content space-y-4 px-(--page-margin-x) py-(--page-margin-y) print:p-0">
{isFirstPage && <Header />}
<main data-layout="main" className="group page-main space-y-4">
+1 -1
View File
@@ -109,7 +109,7 @@ function InputGroupInput({ className, ...props }: React.ComponentProps<"input">)
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none aria-invalid:ring-0 dark:bg-transparent",
"flex-1 rounded-none border-0 bg-transparent shadow-none hover:bg-secondary/40 focus-visible:bg-secondary/40 aria-invalid:ring-0",
className,
)}
{...props}
+1 -1
View File
@@ -7,7 +7,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 shadow-xs outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground placeholder:text-muted-foreground hover:bg-secondary/40 focus-visible:border-ring focus-visible:bg-secondary/40 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
awarder: data?.awarder ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
title: data?.item?.title ?? "",
awarder: data?.item?.awarder ?? "",
date: data?.item?.date ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.awards.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.awards.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
awarder: data.awarder,
date: data.date,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
title: data.item.title,
awarder: data.item.awarder,
date: data.item.date,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.awards.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.awards.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.awards.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
issuer: data?.issuer ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
title: data?.item?.title ?? "",
issuer: data?.item?.issuer ?? "",
date: data?.item?.date ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.certifications.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.certifications.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
issuer: data.issuer,
date: data.date,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
title: data.item.title,
issuer: data.item.issuer,
date: data.item.date,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.certifications.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.certifications.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.certifications.items[index] = formData;
}
});
closeDialog();
};
+41 -15
View File
@@ -11,19 +11,33 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/animate-ui/components/radix/dialog";
import { RichInput } from "@/components/input/rich-input";
import { useResumeStore } from "@/components/resume/store/resume";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { DialogProps } from "@/dialogs/store";
import { useDialogStore } from "@/dialogs/store";
import { customSectionSchema } from "@/schema/resume/data";
import { customSectionSchema, type SectionType } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
const formSchema = customSectionSchema;
type FormValues = z.infer<typeof formSchema>;
const SECTION_TYPE_OPTIONS: { value: SectionType; label: string }[] = [
{ value: "experience", label: "Experience" },
{ value: "education", label: "Education" },
{ value: "projects", label: "Projects" },
{ value: "profiles", label: "Profiles" },
{ value: "skills", label: "Skills" },
{ value: "languages", label: "Languages" },
{ value: "interests", label: "Interests" },
{ value: "awards", label: "Awards" },
{ value: "certifications", label: "Certifications" },
{ value: "publications", label: "Publications" },
{ value: "volunteer", label: "Volunteer" },
{ value: "references", label: "References" },
];
export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections.custom.create">) {
const closeDialog = useDialogStore((state) => state.closeDialog);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
@@ -33,19 +47,20 @@ export function CreateCustomSectionDialog({ data }: DialogProps<"resume.sections
defaultValues: {
id: generateId(),
title: data?.title ?? "",
type: data?.type ?? "experience",
columns: data?.columns ?? 1,
hidden: data?.hidden ?? false,
content: data?.content ?? "",
items: data?.items ?? [],
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.customSections.push(data);
draft.customSections.push(formData);
const lastPageIndex = draft.metadata.layout.pages.length - 1;
if (lastPageIndex < 0) return;
draft.metadata.layout.pages[lastPageIndex].main.push(data.id);
draft.metadata.layout.pages[lastPageIndex].main.push(formData.id);
});
closeDialog();
};
@@ -88,17 +103,18 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
defaultValues: {
id: data.id,
title: data.title,
type: data.type,
columns: data.columns,
hidden: data.hidden,
content: data.content,
items: data.items,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.customSections.findIndex((item) => item.id === data.id);
const index = draft.customSections.findIndex((item) => item.id === formData.id);
if (index === -1) return;
draft.customSections[index] = data;
draft.customSections[index] = formData;
});
closeDialog();
};
@@ -115,7 +131,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
<Form {...form}>
<form className="grid gap-4 sm:grid-cols-2" onSubmit={form.handleSubmit(onSubmit)}>
<CustomSectionForm />
<CustomSectionForm isUpdate />
<DialogFooter className="sm:col-span-full">
<Button variant="ghost" onClick={closeDialog}>
@@ -132,7 +148,7 @@ export function UpdateCustomSectionDialog({ data }: DialogProps<"resume.sections
);
}
function CustomSectionForm() {
function CustomSectionForm({ isUpdate = false }: { isUpdate?: boolean }) {
const form = useFormContext<FormValues>();
return (
@@ -155,14 +171,24 @@ function CustomSectionForm() {
<FormField
control={form.control}
name="content"
name="type"
render={({ field }) => (
<FormItem className="sm:col-span-full">
<FormLabel>
<Trans>Content</Trans>
<Trans>Section Type</Trans>
</FormLabel>
<FormControl>
<RichInput {...field} value={field.value} onChange={field.onChange} />
<select
{...field}
disabled={isUpdate}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{SECTION_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormControl>
<FormMessage />
</FormItem>
+36 -25
View File
@@ -33,21 +33,26 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
school: data?.school ?? "",
degree: data?.degree ?? "",
area: data?.area ?? "",
grade: data?.grade ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
school: data?.item?.school ?? "",
degree: data?.item?.degree ?? "",
area: data?.item?.area ?? "",
grade: data?.item?.grade ?? "",
location: data?.item?.location ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.education.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.education.items.push(formData);
}
});
closeDialog();
};
@@ -88,24 +93,30 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
school: data.school,
degree: data.degree,
area: data.area,
grade: data.grade,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
school: data.item.school,
degree: data.item.degree,
area: data.item.area,
grade: data.item.grade,
location: data.item.location,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.education.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.education.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.education.items[index] = formData;
}
});
closeDialog();
};
+32 -21
View File
@@ -33,19 +33,24 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
company: data?.company ?? "",
position: data?.position ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
company: data?.item?.company ?? "",
position: data?.item?.position ?? "",
location: data?.item?.location ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.experience.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.experience.items.push(formData);
}
});
closeDialog();
};
@@ -86,22 +91,28 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
company: data.company,
position: data.position,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
company: data.item.company,
position: data.item.position,
location: data.item.location,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.experience.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.experience.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.experience.items[index] = formData;
}
});
closeDialog();
};
+26 -15
View File
@@ -35,16 +35,21 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
name: data?.name ?? "",
keywords: data?.keywords ?? [],
hidden: data?.item?.hidden ?? false,
icon: data?.item?.icon ?? "acorn",
name: data?.item?.name ?? "",
keywords: data?.item?.keywords ?? [],
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.interests.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.interests.items.push(formData);
}
});
closeDialog();
};
@@ -85,19 +90,25 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
name: data.name,
keywords: data.keywords,
id: data.item.id,
hidden: data.item.hidden,
icon: data.item.icon,
name: data.item.name,
keywords: data.item.keywords,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.interests.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.interests.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.interests.items[index] = formData;
}
});
closeDialog();
};
+26 -15
View File
@@ -33,16 +33,21 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
language: data?.language ?? "",
fluency: data?.fluency ?? "",
level: data?.level ?? 0,
hidden: data?.item?.hidden ?? false,
language: data?.item?.language ?? "",
fluency: data?.item?.fluency ?? "",
level: data?.item?.level ?? 0,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.languages.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.languages.items.push(formData);
}
});
closeDialog();
};
@@ -83,19 +88,25 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
language: data.language,
fluency: data.fluency,
level: data.level,
id: data.item.id,
hidden: data.item.hidden,
language: data.item.language,
fluency: data.item.fluency,
level: data.item.level,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.languages.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.languages.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.languages.items[index] = formData;
}
});
closeDialog();
};
+28 -17
View File
@@ -35,17 +35,22 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
network: data?.network ?? "",
username: data?.username ?? "",
website: data?.website ?? { url: "", label: "" },
hidden: data?.item?.hidden ?? false,
icon: data?.item?.icon ?? "acorn",
network: data?.item?.network ?? "",
username: data?.item?.username ?? "",
website: data?.item?.website ?? { url: "", label: "" },
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.profiles.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.profiles.items.push(formData);
}
});
closeDialog();
};
@@ -86,20 +91,26 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
network: data.network,
username: data.username,
website: data.website,
id: data.item.id,
hidden: data.item.hidden,
icon: data.item.icon,
network: data.item.network,
username: data.item.username,
website: data.item.website,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.profiles.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.profiles.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.profiles.items[index] = formData;
}
});
closeDialog();
};
+28 -17
View File
@@ -33,17 +33,22 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
name: data?.name ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
name: data?.item?.name ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.projects.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.projects.items.push(formData);
}
});
closeDialog();
};
@@ -84,20 +89,26 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
name: data.name,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
name: data.item.name,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.projects.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.projects.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.projects.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
title: data?.title ?? "",
publisher: data?.publisher ?? "",
date: data?.date ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
title: data?.item?.title ?? "",
publisher: data?.item?.publisher ?? "",
date: data?.item?.date ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.publications.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.publications.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
title: data.title,
publisher: data.publisher,
date: data.date,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
title: data.item.title,
publisher: data.item.publisher,
date: data.item.date,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.publications.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.publications.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.publications.items[index] = formData;
}
});
closeDialog();
};
+24 -13
View File
@@ -32,15 +32,20 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
name: data?.name ?? "",
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
name: data?.item?.name ?? "",
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.references.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.references.items.push(formData);
}
});
closeDialog();
};
@@ -81,18 +86,24 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
name: data.name,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
name: data.item.name,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.references.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.references.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.references.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -37,18 +37,23 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
icon: data?.icon ?? "acorn",
name: data?.name ?? "",
proficiency: data?.proficiency ?? "",
level: data?.level ?? 0,
keywords: data?.keywords ?? [],
hidden: data?.item?.hidden ?? false,
icon: data?.item?.icon ?? "acorn",
name: data?.item?.name ?? "",
proficiency: data?.item?.proficiency ?? "",
level: data?.item?.level ?? 0,
keywords: data?.item?.keywords ?? [],
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.skills.items.push(data);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.skills.items.push(formData);
}
});
closeDialog();
};
@@ -89,21 +94,27 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
icon: data.icon,
name: data.name,
proficiency: data.proficiency,
level: data.level,
keywords: data.keywords,
id: data.item.id,
hidden: data.item.hidden,
icon: data.item.icon,
name: data.item.name,
proficiency: data.item.proficiency,
level: data.item.level,
keywords: data.item.keywords,
},
});
const onSubmit = (data: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.skills.items.findIndex((item) => item.id === data.id);
if (index === -1) return;
draft.sections.skills.items[index] = data;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.skills.items[index] = formData;
}
});
closeDialog();
};
+30 -19
View File
@@ -33,18 +33,23 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
resolver: zodResolver(formSchema),
defaultValues: {
id: generateId(),
hidden: data?.hidden ?? false,
organization: data?.organization ?? "",
location: data?.location ?? "",
period: data?.period ?? "",
website: data?.website ?? { url: "", label: "" },
description: data?.description ?? "",
hidden: data?.item?.hidden ?? false,
organization: data?.item?.organization ?? "",
location: data?.item?.location ?? "",
period: data?.item?.period ?? "",
website: data?.item?.website ?? { url: "", label: "" },
description: data?.item?.description ?? "",
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
draft.sections.volunteer.items.push(values);
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (section) section.items.push(formData);
} else {
draft.sections.volunteer.items.push(formData);
}
});
closeDialog();
};
@@ -85,21 +90,27 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
id: data.id,
hidden: data.hidden,
organization: data.organization,
location: data.location,
period: data.period,
website: data.website,
description: data.description,
id: data.item.id,
hidden: data.item.hidden,
organization: data.item.organization,
location: data.item.location,
period: data.item.period,
website: data.item.website,
description: data.item.description,
},
});
const onSubmit = (values: FormValues) => {
const onSubmit = (formData: FormValues) => {
updateResumeData((draft) => {
const index = draft.sections.volunteer.items.findIndex((item) => item.id === values.id);
if (index === -1) return;
draft.sections.volunteer.items[index] = values;
if (data?.customSectionId) {
const section = draft.customSections.find((s) => s.id === data.customSectionId);
if (!section) return;
const index = section.items.findIndex((item) => item.id === formData.id);
if (index !== -1) section.items[index] = formData;
} else {
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
if (index !== -1) draft.sections.volunteer.items[index] = formData;
}
});
closeDialog();
};
+96 -24
View File
@@ -38,30 +38,102 @@ const dialogTypeSchema = z.discriminatedUnion("type", [
}),
}),
z.object({ type: z.literal("resume.template.gallery"), data: z.undefined() }),
z.object({ type: z.literal("resume.sections.profiles.create"), data: profileItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.profiles.update"), data: profileItemSchema }),
z.object({ type: z.literal("resume.sections.experience.create"), data: experienceItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.experience.update"), data: experienceItemSchema }),
z.object({ type: z.literal("resume.sections.education.create"), data: educationItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.education.update"), data: educationItemSchema }),
z.object({ type: z.literal("resume.sections.projects.create"), data: projectItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.projects.update"), data: projectItemSchema }),
z.object({ type: z.literal("resume.sections.skills.create"), data: skillItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.skills.update"), data: skillItemSchema }),
z.object({ type: z.literal("resume.sections.languages.create"), data: languageItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.languages.update"), data: languageItemSchema }),
z.object({ type: z.literal("resume.sections.awards.create"), data: awardItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.awards.update"), data: awardItemSchema }),
z.object({ type: z.literal("resume.sections.certifications.create"), data: certificationItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.certifications.update"), data: certificationItemSchema }),
z.object({ type: z.literal("resume.sections.publications.create"), data: publicationItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.publications.update"), data: publicationItemSchema }),
z.object({ type: z.literal("resume.sections.interests.create"), data: interestItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.interests.update"), data: interestItemSchema }),
z.object({ type: z.literal("resume.sections.volunteer.create"), data: volunteerItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.volunteer.update"), data: volunteerItemSchema }),
z.object({ type: z.literal("resume.sections.references.create"), data: referenceItemSchema.optional() }),
z.object({ type: z.literal("resume.sections.references.update"), data: referenceItemSchema }),
z.object({
type: z.literal("resume.sections.profiles.create"),
data: z.object({ item: profileItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.profiles.update"),
data: z.object({ item: profileItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.experience.create"),
data: z.object({ item: experienceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.experience.update"),
data: z.object({ item: experienceItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.education.create"),
data: z.object({ item: educationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.education.update"),
data: z.object({ item: educationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.projects.create"),
data: z.object({ item: projectItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.projects.update"),
data: z.object({ item: projectItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.skills.create"),
data: z.object({ item: skillItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.skills.update"),
data: z.object({ item: skillItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.languages.create"),
data: z.object({ item: languageItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.languages.update"),
data: z.object({ item: languageItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.awards.create"),
data: z.object({ item: awardItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.awards.update"),
data: z.object({ item: awardItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.certifications.create"),
data: z.object({ item: certificationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.certifications.update"),
data: z.object({ item: certificationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.publications.create"),
data: z.object({ item: publicationItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.publications.update"),
data: z.object({ item: publicationItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.interests.create"),
data: z.object({ item: interestItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.interests.update"),
data: z.object({ item: interestItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.volunteer.create"),
data: z.object({ item: volunteerItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.volunteer.update"),
data: z.object({ item: volunteerItemSchema, customSectionId: z.string().optional() }),
}),
z.object({
type: z.literal("resume.sections.references.create"),
data: z.object({ item: referenceItemSchema.optional(), customSectionId: z.string().optional() }).optional(),
}),
z.object({
type: z.literal("resume.sections.references.update"),
data: z.object({ item: referenceItemSchema, customSectionId: z.string().optional() }),
}),
z.object({ type: z.literal("resume.sections.custom.create"), data: customSectionSchema.optional() }),
z.object({ type: z.literal("resume.sections.custom.update"), data: customSectionSchema }),
]);
@@ -644,51 +644,28 @@ export class ReactiveResumeV4JSONImporter {
})),
},
},
customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => {
const itemsHtml = section.items
customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => ({
id: section.id || sectionId,
title: section.name ?? "",
type: "experience" as const, // Default to experience type as it has the most compatible fields
columns: section.columns ?? 1,
hidden: !(section.visible ?? true),
items: section.items
.filter((item) => item.visible !== false)
.map((item) => {
const parts: string[] = [];
if (item.name) {
parts.push(`<h3>${item.name}</h3>`);
}
if (item.description) {
parts.push(`<p>${item.description}</p>`);
}
if (item.date || item.location) {
const details = [item.date, item.location].filter(Boolean).join(" • ");
if (details) parts.push(`<p><em>${details}</em></p>`);
}
if (item.summary) {
parts.push(`<div>${item.summary}</div>`);
}
if (item.keywords && item.keywords.length > 0) {
parts.push(`<p><strong>Keywords:</strong> ${item.keywords.join(", ")}</p>`);
}
if (item.url?.href) {
const label = item.url.label || item.url.href;
parts.push(`<p><a href="${item.url.href}">${label}</a></p>`);
}
return parts.length > 0 ? `<div style="margin-bottom: 1em;">${parts.join("")}</div>` : "";
})
.filter(Boolean)
.join("");
return {
id: section.id || sectionId,
title: section.name ?? "",
columns: section.columns ?? 1,
hidden: !(section.visible ?? true),
content: itemsHtml || "",
};
}),
.map((item) => ({
id: item.id || generateId(),
hidden: !item.visible,
company: item.name?.trim() || "",
position: item.description ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
},
description: item.summary ?? "",
})),
})),
metadata: {
template: (templateSchema.safeParse(v4Data.metadata.template).success
? v4Data.metadata.template
+4 -11
View File
@@ -15,13 +15,8 @@ export const printerRouter = {
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.handler(async ({ input, context }) => {
// Get resume to find the owner's userId for storage key
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.printResumeAsPDF({
id: input.id,
userId: resume.userId,
});
const url = await printerService.printResumeAsPDF(resume);
if (!context.user) {
await resumeService.statistics.increment({ id: input.id, downloads: true });
@@ -40,11 +35,9 @@ export const printerRouter = {
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.handler(async ({ input, context }) => {
const url = await printerService.getResumeScreenshot({
id: input.id,
userId: context.user.id,
});
.handler(async ({ input }) => {
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.getResumeScreenshot(resume);
return { url };
}),
+131 -83
View File
@@ -1,7 +1,9 @@
import { ORPCError } from "@orpc/server";
import type { InferSelectModel } from "drizzle-orm";
import puppeteer, { type Browser, type ConnectOptions, type Page } from "puppeteer-core";
import type { schema } from "@/integrations/drizzle";
import { printMarginTemplates } from "@/schema/templates";
import { env } from "@/utils/env";
import { generatePrinterToken } from "@/utils/printer-token";
import { resumeService } from "./resume";
import { getStorageService, uploadFile } from "./storage";
const pageDimensions = {
@@ -17,65 +19,128 @@ const pageDimensions = {
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
let pdfBrowser: Browser | null = null;
let screenshotBrowser: Browser | null = null;
async function getBrowser(type: "pdf" | "screenshot"): Promise<Browser> {
const endpoint = new URL(env.PRINTER_ENDPOINT);
const isWebSocket = endpoint.protocol.startsWith("ws");
const connectOptions: ConnectOptions = { acceptInsecureCerts: true };
if (isWebSocket) connectOptions.browserWSEndpoint = env.PRINTER_ENDPOINT;
else connectOptions.browserURL = env.PRINTER_ENDPOINT;
if (type === "screenshot") {
if (screenshotBrowser?.connected) return screenshotBrowser;
screenshotBrowser = await puppeteer.connect({ ...connectOptions, defaultViewport: { width: 794, height: 1123 } });
return screenshotBrowser;
}
if (pdfBrowser?.connected) return pdfBrowser;
pdfBrowser = await puppeteer.connect(connectOptions);
return pdfBrowser;
}
async function interceptLocalhostRequests(page: Page) {
await page.setRequestInterception(true);
page.on("request", (request) => {
const url = request.url();
if (url.includes(env.APP_URL) && env.PRINTER_APP_URL) {
const newUrl = url.replace(env.APP_URL, env.PRINTER_APP_URL);
request.continue({ url: newUrl });
return;
}
request.continue();
});
}
export const printerService = {
printResumeAsPDF: async (input: { id: string; userId: string }): Promise<string> => {
healthcheck: async (): Promise<object> => {
const headers = new Headers({ Accept: "application/json" });
const endpoint = new URL(env.PRINTER_ENDPOINT);
endpoint.protocol = endpoint.protocol.replace("ws", "http");
endpoint.pathname = "/json/version";
const response = await fetch(endpoint, { headers });
const data = await response.json();
return data;
},
chromeDebug: async (): Promise<void> => {
const browser = await getBrowser("pdf");
const page = await browser.newPage();
await page.goto("https://www.google.com");
await page.pdf({ path: `screenshot-${Date.now()}.pdf` });
await browser.disconnect();
},
printResumeAsPDF: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data">,
): Promise<string> => {
const { id, userId, data } = input;
const storageService = getStorageService();
const pdfPrefix = `uploads/${userId}/pdfs/${id}`;
// Delete any existing PDFs for this resume
const pdfPrefix = `uploads/${input.userId}/pdfs/${input.id}`;
await storageService.delete(pdfPrefix);
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const format = resume.data.metadata.page.format;
const locale = resume.data.metadata.page.locale;
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const token = generatePrinterToken(input.id);
const url = `${baseUrl}/printer/${input.id}?token=${token}`;
const format = data.metadata.page.format;
const locale = data.metadata.page.locale;
const template = data.metadata.template;
const formData = new FormData();
const cookies = [{ name: "locale", value: locale, domain }];
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
formData.append("url", url);
formData.append("marginTop", "0");
formData.append("marginLeft", "0");
formData.append("marginRight", "0");
formData.append("marginBottom", "0");
formData.append("printBackground", "true");
formData.append("skipNetworkIdleEvent", "false");
formData.append("cookies", JSON.stringify(cookies));
formData.append("paperWidth", pageDimensions[format].width);
formData.append("paperHeight", pageDimensions[format].height);
let marginX = 0;
let marginY = 0;
const headers = new Headers();
if (env.GOTENBERG_USERNAME && env.GOTENBERG_PASSWORD) {
const credentials = `${env.GOTENBERG_USERNAME}:${env.GOTENBERG_PASSWORD}`;
const encodedCredentials = btoa(credentials);
headers.set("Authorization", `Basic ${encodedCredentials}`);
if (printMarginTemplates.includes(template)) {
marginX = Math.round(data.metadata.page.marginX / 0.75);
marginY = Math.round(data.metadata.page.marginY / 0.75);
}
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/convert/url`, {
headers,
method: "POST",
body: formData,
const browser = await getBrowser("pdf");
await browser.setCookie({ name: "locale", value: locale, domain });
const page = await browser.newPage();
if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page);
await page.goto(url, { waitUntil: "networkidle0" });
const pdfBuffer = await page.pdf({
width: pageDimensions[format].width,
height: pageDimensions[format].height,
tagged: true,
waitForFonts: true,
printBackground: true,
margin: {
top: marginY,
right: marginX,
bottom: marginY,
left: marginX,
},
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
});
}
await page.close();
const pdfBuffer = await response.arrayBuffer();
// Store PDF and return URL
const result = await uploadFile({
userId: input.userId,
resumeId: input.id,
userId,
resumeId: id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
@@ -84,9 +149,13 @@ export const printerService = {
return result.url;
},
getResumeScreenshot: async (input: { id: string; userId: string }): Promise<string> => {
getResumeScreenshot: async (
input: Pick<InferSelectModel<typeof schema.resume>, "userId" | "id" | "data">,
): Promise<string> => {
const { id, userId, data } = input;
const storageService = getStorageService();
const screenshotPrefix = `uploads/${input.userId}/screenshots/${input.id}`;
const screenshotPrefix = `uploads/${userId}/screenshots/${id}`;
const existingScreenshots = await storageService.list(screenshotPrefix);
const now = Date.now();
@@ -105,59 +174,38 @@ export const printerService = {
const latest = sortedFiles[0];
const age = now - latest.timestamp;
if (age < SCREENSHOT_TTL) {
// Return URL of cached screenshot
return new URL(latest.path, env.APP_URL).toString();
}
if (age < SCREENSHOT_TTL) return new URL(latest.path, env.APP_URL).toString();
// Delete old screenshots
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
}
}
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const token = generatePrinterToken(input.id);
const url = `${baseUrl}/printer/${input.id}?token=${token}`;
const locale = data.metadata.page.locale;
const formData = new FormData();
const token = generatePrinterToken(id);
const url = `${baseUrl}/printer/${id}?token=${token}`;
formData.append("url", url);
formData.append("clip", "true");
formData.append("width", "794");
formData.append("height", "1123");
formData.append("format", "webp");
formData.append("optimizeForSpeed", "true");
formData.append("skipNetworkIdleEvent", "false");
const browser = await getBrowser("screenshot");
const headers = new Headers();
await browser.setCookie({ name: "locale", value: locale, domain });
if (env.GOTENBERG_USERNAME && env.GOTENBERG_PASSWORD) {
const credentials = `${env.GOTENBERG_USERNAME}:${env.GOTENBERG_PASSWORD}`;
const encodedCredentials = btoa(credentials);
headers.set("Authorization", `Basic ${encodedCredentials}`);
}
const page = await browser.newPage();
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/screenshot/url`, {
headers,
method: "POST",
body: formData,
});
if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page);
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
});
}
await page.goto(url, { waitUntil: "networkidle0" });
const imageBuffer = await response.arrayBuffer();
const screenshotBuffer = await page.screenshot({ type: "webp", quality: 80 });
await page.close();
// Store screenshot and return URL
const result = await uploadFile({
userId: input.userId,
resumeId: input.id,
data: new Uint8Array(imageBuffer),
userId,
resumeId: id,
data: new Uint8Array(screenshotBuffer),
contentType: "image/webp",
type: "screenshot",
});
+16 -2
View File
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { sql } from "drizzle-orm";
import { db } from "@/integrations/drizzle/client";
import { printerService } from "@/integrations/orpc/services/printer";
import { getStorageService } from "@/integrations/orpc/services/storage";
function isUnhealthy(check: unknown): boolean {
@@ -20,6 +21,7 @@ async function handler() {
timestamp: new Date().toISOString(),
uptime: `${process.uptime().toFixed(2)}s`,
database: await checkDatabase(),
printer: await checkPrinter(),
storage: await checkStorage(),
};
@@ -50,11 +52,23 @@ async function checkDatabase() {
}
}
async function checkPrinter() {
try {
const result = await printerService.healthcheck();
return { status: "healthy", ...result };
} catch (error) {
return {
status: "unhealthy",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
async function checkStorage() {
try {
const storageService = getStorageService();
const result = await storageService.healthcheck();
return result;
return await storageService.healthcheck();
} catch (error) {
return {
status: "unhealthy",
@@ -3,6 +3,7 @@ import {
ArrowsClockwiseIcon,
CircleNotchIcon,
CubeFocusIcon,
FileJsIcon,
FilePdfIcon,
type Icon,
LinkSimpleIcon,
@@ -20,7 +21,7 @@ import { Button } from "@/components/animate-ui/components/buttons/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { authClient } from "@/integrations/auth/client";
import { orpc } from "@/integrations/orpc/client";
import { downloadFromUrl, generateFilename } from "@/utils/file";
import { downloadFromUrl, downloadWithAnchor, generateFilename } from "@/utils/file";
import { cn } from "@/utils/style";
export function BuilderDock() {
@@ -49,6 +50,15 @@ export function BuilderDock() {
toast.success(t`A link to your resume has been copied to clipboard.`);
}, [publicUrl, copyToClipboard]);
const onDownloadJSON = useCallback(async () => {
if (!resume) return;
const jsonString = JSON.stringify(resume, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const filename = generateFilename(resume.data.basics.name, "json");
downloadWithAnchor(blob, filename);
}, [resume]);
const onDownloadPDF = useCallback(async () => {
if (!resume) return;
@@ -73,6 +83,7 @@ export function BuilderDock() {
<DockIcon icon={CubeFocusIcon} title={t`Center view`} onClick={() => centerView()} />
<div className="mx-1 h-8 w-px bg-border" />
<DockIcon icon={LinkSimpleIcon} title={t`Copy URL`} onClick={() => onCopyUrl()} />
<DockIcon icon={FileJsIcon} title={t`Download JSON`} onClick={() => onDownloadJSON()} />
<DockIcon
title={t`Download PDF`}
disabled={isPrinting}
@@ -8,8 +8,8 @@ import {
PencilSimpleLineIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { AnimatePresence, motion } from "motion/react";
import { useMemo } from "react";
import { AnimatePresence, Reorder } from "motion/react";
import { match } from "ts-pattern";
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,47 +24,144 @@ import {
DropdownMenuTrigger,
} from "@/components/animate-ui/components/radix/dropdown-menu";
import { useResumeStore } from "@/components/resume/store/resume";
import { Badge } from "@/components/ui/badge";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import type { CustomSection } from "@/schema/resume/data";
import { sanitizeHtml } from "@/utils/sanitize";
import type { CustomSection, CustomSectionItem as CustomSectionItemType, SectionType } from "@/schema/resume/data";
import { getSectionTitle } from "@/utils/resume/section";
import { cn } from "@/utils/style";
import { SectionBase } from "../shared/section-base";
import { SectionAddItemButton } from "../shared/section-item";
import { SectionAddItemButton, SectionItem } from "../shared/section-item";
function getItemTitle(type: SectionType, item: CustomSectionItemType): string {
return match(type)
.with("profiles", () => ("network" in item ? item.network : ""))
.with("experience", () => ("company" in item ? item.company : ""))
.with("education", () => ("school" in item ? item.school : ""))
.with("projects", () => ("name" in item ? item.name : ""))
.with("skills", () => ("name" in item ? item.name : ""))
.with("languages", () => ("language" in item ? item.language : ""))
.with("interests", () => ("name" in item ? item.name : ""))
.with("awards", () => ("title" in item ? item.title : ""))
.with("certifications", () => ("title" in item ? item.title : ""))
.with("publications", () => ("title" in item ? item.title : ""))
.with("volunteer", () => ("organization" in item ? item.organization : ""))
.with("references", () => ("name" in item ? item.name : ""))
.exhaustive();
}
function getItemSubtitle(type: SectionType, item: CustomSectionItemType): string | undefined {
return match(type)
.with("profiles", () => ("username" in item ? item.username : undefined))
.with("experience", () => ("position" in item ? item.position : undefined))
.with("education", () => ("degree" in item ? item.degree : undefined))
.with("projects", () => ("period" in item ? item.period : undefined))
.with("skills", () => ("proficiency" in item ? item.proficiency : undefined))
.with("languages", () => ("fluency" in item ? item.fluency : undefined))
.with("interests", () => undefined)
.with("awards", () => ("awarder" in item ? item.awarder : undefined))
.with("certifications", () => ("issuer" in item ? item.issuer : undefined))
.with("publications", () => ("publisher" in item ? item.publisher : undefined))
.with("volunteer", () => ("period" in item ? item.period : undefined))
.with("references", () => undefined)
.exhaustive();
}
export function CustomSectionBuilder() {
const customSections = useResumeStore((state) => state.resume.data.customSections);
return (
<SectionBase type="custom" className={cn("rounded-md border", customSections.length === 0 && "border-dashed")}>
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
<AnimatePresence>
{customSections.map((section) => (
<CustomSectionItem key={section.id} section={section} />
<CustomSectionContainer key={section.id} section={section} />
))}
</AnimatePresence>
<SectionAddItemButton type="custom">
{/* Add Custom Section Button */}
<SectionAddItemButton type="custom" variant="outline" className="rounded-md">
<Trans>Add a new custom section</Trans>
</SectionAddItemButton>
</SectionBase>
);
}
function CustomSectionItem({ section }: { section: CustomSection }) {
const confirm = useConfirm();
function CustomSectionContainer({ section }: { section: CustomSection }) {
const { openDialog } = useDialogStore();
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const sanitizedContent = useMemo(() => sanitizeHtml(section.content), [section.content]);
const onUpdate = () => {
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const onDuplicate = () => {
openDialog("resume.sections.custom.create", section);
const handleReorder = (items: CustomSectionItemType[]) => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
draft.customSections[sectionIndex].items = items;
});
};
const onToggleVisibility = () => {
return (
<div className="rounded-md border">
{/* Section Header */}
<div className="group flex select-none">
<button
type="button"
onClick={onUpdateSection}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-left transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
section.hidden && "opacity-50",
)}
>
<Badge variant="secondary" className="mb-1.5 rounded-sm">
{getSectionTitle(section.type)}
</Badge>
<span className="line-clamp-1 break-all font-medium text-base">{section.title}</span>
<span className="text-muted-foreground text-xs">
<Plural value={section.items.length} one="# item" other="# items" />
</span>
</button>
<CustomSectionDropdownMenu section={section} />
</div>
{/* Section Items */}
{section.items.length > 0 && (
<div className={cn("border-t", section.hidden && "opacity-50")}>
<Reorder.Group axis="y" values={section.items} onReorder={handleReorder}>
<AnimatePresence>
{section.items.map((item) => (
<SectionItem
key={item.id}
type={section.type}
item={item}
customSectionId={section.id}
title={getItemTitle(section.type, item)}
subtitle={getItemSubtitle(section.type, item)}
/>
))}
</AnimatePresence>
</Reorder.Group>
</div>
)}
{/* Add Item Button */}
<div className="border-t">
<SectionAddItemButton type={section.type} customSectionId={section.id}>
<Trans>Add a new item</Trans>
</SectionAddItemButton>
</div>
</div>
);
}
function CustomSectionDropdownMenu({ section }: { section: CustomSection }) {
const confirm = useConfirm();
const { openDialog } = useDialogStore();
const updateResumeData = useResumeStore((state) => state.updateResumeData);
const onToggleSectionVisibility = () => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
if (sectionIndex === -1) return;
@@ -72,6 +169,14 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
});
};
const onUpdateSection = () => {
openDialog("resume.sections.custom.update", section);
};
const onDuplicateSection = () => {
openDialog("resume.sections.custom.create", section);
};
const onSetColumns = (value: string) => {
updateResumeData((draft) => {
const sectionIndex = draft.customSections.findIndex((_section) => _section.id === section.id);
@@ -80,7 +185,7 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
});
};
const onDelete = async () => {
const onDeleteSection = async () => {
const confirmed = await confirm("Are you sure you want to delete this custom section?", {
confirmText: "Delete",
cancelText: "Cancel",
@@ -90,7 +195,6 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
updateResumeData((draft) => {
draft.customSections = draft.customSections.filter((_section) => _section.id !== section.id);
// remove from layout
draft.metadata.layout.pages = draft.metadata.layout.pages.map((page) => ({
...page,
main: page.main.filter((id) => id !== section.id),
@@ -100,84 +204,60 @@ function CustomSectionItem({ section }: { section: CustomSection }) {
};
return (
<motion.div
key={section.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="group flex select-none border-b"
>
<button
type="button"
onClick={onUpdate}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 p-4 text-left transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
section.hidden && "opacity-50",
)}
>
<div className="line-clamp-1 font-medium text-base">{section.title}</div>
<div
className="line-clamp-2 text-muted-foreground text-xs"
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is sanitized with DOMPurify
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
>
<DotsThreeVerticalIcon />
</button>
</DropdownMenuTrigger>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100"
>
<DotsThreeVerticalIcon />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onToggleSectionVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={onToggleVisibility}>
{section.hidden ? <EyeIcon /> : <EyeClosedIcon />}
{section.hidden ? <Trans>Show</Trans> : <Trans>Hide</Trans>}
</DropdownMenuItem>
<DropdownMenuItem onSelect={onUpdateSection}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onUpdate}>
<PencilSimpleLineIcon />
<Trans>Update</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDuplicateSection}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onDuplicate}>
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ColumnsIcon />
<Trans>Columns</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={column.toString()}>
<Plural value={column} one="# Column" other="# Columns" />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={section.columns.toString()} onValueChange={onSetColumns}>
{[1, 2, 3, 4, 5, 6].map((column) => (
<DropdownMenuRadioItem key={column} value={column.toString()}>
<Plural value={column} one="# Column" other="# Columns" />
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onSelect={onDeleteSection}>
<TrashSimpleIcon />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,37 +1,167 @@
import { Trans } from "@lingui/react/macro";
import {
ArrowBendUpRightIcon,
CopySimpleIcon,
DotsSixVerticalIcon,
DotsThreeVerticalIcon,
EyeClosedIcon,
EyeIcon,
FileIcon,
FolderPlusIcon,
PencilSimpleLineIcon,
PlusCircleIcon,
PlusIcon,
TrashSimpleIcon,
} from "@phosphor-icons/react";
import { Reorder, useDragControls } from "motion/react";
import { useMemo } from "react";
import { Button, type ButtonProps } from "@/components/animate-ui/components/buttons/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/animate-ui/components/radix/dropdown-menu";
import { useResumeStore } from "@/components/resume/store/resume";
import { useDialogStore } from "@/dialogs/store";
import { useConfirm } from "@/hooks/use-confirm";
import type { SectionItem as SectionItemType, SectionType } from "@/schema/resume/data";
import {
addItemToSection,
createCustomSectionWithItem,
createPageWithSection,
getCompatibleMoveTargets,
getSourceSectionTitle,
removeItemFromSource,
} from "@/utils/resume/move-item";
import { cn } from "@/utils/style";
// ============================================================================
// MoveItemSubmenu Component
// ============================================================================
type MoveItemSubmenuProps = {
type: SectionType;
item: SectionItemType;
customSectionId?: string;
};
/**
* Submenu component for moving items between sections/pages.
* Displays compatible targets grouped by page with options to:
* - Move to existing compatible section
* - Create new section on existing page
* - Create new page with new section
*/
function MoveItemSubmenu({ type, item, customSectionId }: MoveItemSubmenuProps) {
const resume = useResumeStore((state) => state.resume);
const updateResumeData = useResumeStore((state) => state.updateResumeData);
/** Compute compatible move targets grouped by page */
const moveTargets = useMemo(
() => getCompatibleMoveTargets(resume.data, type, customSectionId),
[resume.data, type, customSectionId],
);
/** Get the current section's title (used when creating new sections) */
const currentSectionTitle = useMemo(
() => getSourceSectionTitle(resume.data, type, customSectionId),
[resume.data, type, customSectionId],
);
/** Handler: Move item to an existing section */
const handleMoveToSection = (targetSectionId: string) => {
updateResumeData((draft) => {
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
if (!removedItem) return;
addItemToSection(draft, removedItem, targetSectionId, type);
});
};
/** Handler: Create a new custom section on an existing page and move the item there */
const handleNewSectionOnPage = (pageIndex: number) => {
updateResumeData((draft) => {
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
if (!removedItem) return;
createCustomSectionWithItem(draft, removedItem, type, currentSectionTitle, pageIndex);
});
};
/** Handler: Create a new page with a new custom section and move the item there */
const handleNewPage = () => {
updateResumeData((draft) => {
const removedItem = removeItemFromSource(draft, item.id, type, customSectionId);
if (!removedItem) return;
createPageWithSection(draft, removedItem, type, currentSectionTitle);
});
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<ArrowBendUpRightIcon />
<Trans>Move to</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{/* Render each page as a submenu */}
{moveTargets.map(({ pageIndex, sections }) => (
<DropdownMenuSub key={pageIndex}>
<DropdownMenuSubTrigger>
<FileIcon />
<Trans>Page {pageIndex + 1}</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{/* Existing compatible sections on this page */}
{sections.map(({ sectionId, sectionTitle }) => (
<DropdownMenuItem key={sectionId} onSelect={() => handleMoveToSection(sectionId)}>
{sectionTitle}
</DropdownMenuItem>
))}
{/* Separator if there are existing sections */}
{sections.length > 0 && <DropdownMenuSeparator />}
{/* Option to create a new section on this page */}
<DropdownMenuItem onSelect={() => handleNewSectionOnPage(pageIndex)}>
<FolderPlusIcon />
<Trans>New Section</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
))}
<DropdownMenuSeparator />
{/* Option to create a new page with a new section */}
<DropdownMenuItem onSelect={handleNewPage}>
<PlusCircleIcon />
<Trans>New Page</Trans>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
// ============================================================================
// SectionItem Component
// ============================================================================
type Props<T extends SectionItemType> = {
type: SectionType;
item: T;
title: string;
subtitle?: string;
customSectionId?: string;
};
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle }: Props<T>) {
export function SectionItem<T extends SectionItemType>({ type, item, title, subtitle, customSectionId }: Props<T>) {
const confirm = useConfirm();
const controls = useDragControls();
const { openDialog } = useDialogStore();
@@ -39,20 +169,30 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
const onToggleVisibility = () => {
updateResumeData((draft) => {
const section = draft.sections[type];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
} else {
const section = draft.sections[type];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items[index].hidden = !section.items[index].hidden;
}
});
};
const onUpdate = () => {
openDialog(`resume.sections.${type}.update`, item);
// Type assertion needed because TypeScript can't narrow the union type through template literals
openDialog(`resume.sections.${type}.update`, { item, customSectionId } as never);
};
const onDuplicate = () => {
openDialog(`resume.sections.${type}.create`, item);
// Type assertion needed because TypeScript can't narrow the union type through template literals
openDialog(`resume.sections.${type}.create`, { item, customSectionId } as never);
};
const onDelete = async () => {
@@ -64,11 +204,19 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
if (!confirmed) return;
updateResumeData((draft) => {
const section = draft.sections[type];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
} else {
const section = draft.sections[type];
if (!("items" in section)) return;
const index = section.items.findIndex((_item) => _item.id === item.id);
if (index === -1) return;
section.items.splice(index, 1);
}
});
};
@@ -84,7 +232,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
className="group relative flex h-18 select-none border-b"
>
<div
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 group-hover:opacity-100"
className="flex cursor-ns-resize touch-none items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 group-hover:opacity-100"
onPointerDown={(e) => {
e.preventDefault();
controls.start(e);
@@ -96,7 +244,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
<button
onClick={onUpdate}
className={cn(
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/20 focus:outline-none focus-visible:ring-1",
"flex flex-1 flex-col items-start justify-center space-y-0.5 pl-1.5 text-left opacity-100 transition-opacity hover:bg-secondary/40 focus:outline-none focus-visible:ring-1",
item.hidden && "opacity-50",
)}
>
@@ -106,7 +254,7 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/20 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
<button className="flex cursor-context-menu items-center px-1.5 opacity-40 transition-[background-color,opacity] hover:bg-secondary/40 focus:outline-none focus-visible:ring-1 group-hover:opacity-100">
<DotsThreeVerticalIcon />
</button>
</DropdownMenuTrigger>
@@ -131,6 +279,8 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
<CopySimpleIcon />
<Trans>Duplicate</Trans>
</DropdownMenuItem>
<MoveItemSubmenu type={type} item={item} customSectionId={customSectionId} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
@@ -147,26 +297,32 @@ export function SectionItem<T extends SectionItemType>({ type, item, title, subt
);
}
type AddButtonProps = {
type AddButtonProps = Omit<ButtonProps, "type"> & {
type: SectionType | "custom";
children: React.ReactNode;
customSectionId?: string;
};
export function SectionAddItemButton({ type, children }: AddButtonProps) {
export function SectionAddItemButton({ type, customSectionId, className, children, ...props }: AddButtonProps) {
const { openDialog } = useDialogStore();
const handleAdd = () => {
openDialog(`resume.sections.${type}.create`, undefined);
if (type === "custom") {
openDialog("resume.sections.custom.create", undefined);
} else {
openDialog(`resume.sections.${type}.create`, customSectionId ? { customSectionId } : undefined);
}
};
return (
<button
type="button"
<Button
tapScale={1}
variant="ghost"
onClick={handleAdd}
className="flex w-full items-center gap-x-2 px-3 py-4 font-medium hover:bg-secondary/20 focus:outline-none focus-visible:ring-1"
className={cn("h-12 w-full justify-start rounded-t-none", className)}
{...props}
>
<PlusIcon />
{children}
</button>
</Button>
);
}
@@ -289,7 +289,7 @@ function LayoutColumn({ pageIndex, columnId, items, disabled = false }: LayoutCo
return (
<SortableContext id={droppableId} items={items} strategy={verticalListSortingStrategy}>
<div className={cn(disabled && "opacity-50")}>
<div className={cn("space-y-1.5", disabled && "opacity-50")}>
<div className="@md:row-start-1 pl-4 font-medium text-xs">{getColumnLabel(columnId)}</div>
<div
@@ -355,7 +355,7 @@ const LayoutItemContent = forwardRef<HTMLDivElement, LayoutItemContentProps>(
data-dragging={isDragging ? "true" : undefined}
className={cn(
"group/item flex cursor-grab touch-none select-none items-center gap-x-2 rounded-md border border-border bg-background px-2 py-1.5 font-medium text-sm transition-all duration-200 ease-out",
"hover:bg-secondary/20 active:cursor-grabbing active:border-primary/60 active:bg-secondary/20",
"hover:bg-secondary/40 active:cursor-grabbing active:border-primary/60 active:bg-secondary/40",
"data-[overlay=true]:cursor-grabbing data-[overlay=true]:border-primary/60 data-[overlay=true]:bg-background",
"data-[dragging=true]:cursor-grabbing data-[dragging=true]:border-primary/60 data-[dragging=true]:bg-background",
className,
+38 -3
View File
@@ -308,11 +308,46 @@ export type SectionType = keyof z.infer<typeof sectionsSchema>;
export type SectionData<T extends SectionType = SectionType> = z.infer<typeof sectionsSchema>[T];
export type SectionItem<T extends SectionType = SectionType> = SectionData<T>["items"][number];
export const sectionTypeSchema = z.enum([
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
]);
export const customSectionItemSchema = z.union([
profileItemSchema,
experienceItemSchema,
educationItemSchema,
projectItemSchema,
skillItemSchema,
languageItemSchema,
interestItemSchema,
awardItemSchema,
certificationItemSchema,
publicationItemSchema,
volunteerItemSchema,
referenceItemSchema,
]);
export type CustomSectionItem = z.infer<typeof customSectionItemSchema>;
export const customSectionSchema = baseSectionSchema.extend({
id: z.string().describe("The unique identifier for the custom section. Usually generated as a UUID."),
content: z
.string()
.describe("The content of the custom section. This should be a HTML-formatted string. Leave blank to hide."),
type: sectionTypeSchema.describe(
"The type of items this custom section contains. Determines which item schema and form fields to use.",
),
items: z
.array(customSectionItemSchema)
.describe("The items to display in the custom section. Items follow the schema of the section type."),
});
export type CustomSection = z.infer<typeof customSectionSchema>;
-1
View File
@@ -165,7 +165,6 @@
}
.resume-preview-container {
.page-section,
.page-section .section-item {
break-inside: avoid;
}
+1 -3
View File
@@ -15,9 +15,7 @@ export const env = createEnv({
PRINTER_APP_URL: z.url({ protocol: /https?/ }).optional(),
// Printer
GOTENBERG_ENDPOINT: z.url({ protocol: /https?/ }),
GOTENBERG_USERNAME: z.string().min(1).optional(),
GOTENBERG_PASSWORD: z.string().min(1).optional(),
PRINTER_ENDPOINT: z.url({ protocol: /^(wss?|https?)$/ }),
// Database
DATABASE_URL: z.url({ protocol: /postgres(ql)?/ }),
+269
View File
@@ -0,0 +1,269 @@
import type { WritableDraft } from "immer";
import type { CustomSection, ResumeData, SectionItem, SectionType } from "@/schema/resume/data";
import { generateId } from "@/utils/string";
import { getSectionTitle as getDefaultSectionTitle } from "./section";
// ============================================================================
// Types
// ============================================================================
/** Target section that an item can be moved to */
export type MoveTargetSection = {
sectionId: string;
sectionTitle: string;
/** Whether this is a standard section (true) or custom section (false) */
isStandard: boolean;
};
/** Page with its compatible sections for the move menu */
export type MoveTargetPage = {
pageIndex: number;
sections: MoveTargetSection[];
};
// ============================================================================
// Helper Functions
// ============================================================================
/**
* Checks if a section ID belongs to a standard section.
* Standard sections have predefined keys like "experience", "education", etc.
*/
function isStandardSectionId(sectionId: string): sectionId is SectionType {
const standardSections: SectionType[] = [
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
];
return standardSections.includes(sectionId as SectionType);
}
// ============================================================================
// Public API
// ============================================================================
/**
* Gets the title of a section.
* For standard sections, returns the localized default title.
* For custom sections, returns the user-defined title.
*
* @param resumeData - The resume data object
* @param type - The section type
* @param customSectionId - The custom section ID (if applicable)
* @returns The section title
*/
export function getSourceSectionTitle(resumeData: ResumeData, type: SectionType, customSectionId?: string): string {
if (customSectionId) {
const customSection = resumeData.customSections.find((s) => s.id === customSectionId);
return customSection?.title ?? getDefaultSectionTitle(type);
}
return getDefaultSectionTitle(type);
}
/**
* Finds all compatible sections an item can be moved to.
* A section is compatible if it has the same type as the source section.
*
* @param resumeData - The resume data object
* @param sourceType - The type of the source section
* @param sourceSectionId - The ID of the source section (custom section ID or undefined for standard)
* @returns Array of pages with their compatible sections
*/
export function getCompatibleMoveTargets(
resumeData: ResumeData,
sourceType: SectionType,
sourceSectionId: string | undefined,
): MoveTargetPage[] {
const { pages } = resumeData.metadata.layout;
const result: MoveTargetPage[] = [];
for (let pageIndex = 0; pageIndex < pages.length; pageIndex++) {
const page = pages[pageIndex];
const allSectionIds = [...page.main, ...page.sidebar];
const compatibleSections: MoveTargetSection[] = [];
for (const sectionId of allSectionIds) {
// Skip the source section itself
if (sectionId === sourceSectionId || (sourceSectionId === undefined && sectionId === sourceType)) {
continue;
}
// Check if it's a standard section with matching type
if (isStandardSectionId(sectionId) && sectionId === sourceType) {
compatibleSections.push({
sectionId,
sectionTitle: getDefaultSectionTitle(sectionId),
isStandard: true,
});
continue;
}
// Check if it's a custom section with matching type
const customSection = resumeData.customSections.find((s) => s.id === sectionId);
if (customSection && customSection.type === sourceType) {
compatibleSections.push({
sectionId: customSection.id,
sectionTitle: customSection.title,
isStandard: false,
});
}
}
result.push({ pageIndex, sections: compatibleSections });
}
return result;
}
/**
* Removes an item from its source section (standard or custom).
*
* @param draft - The immer draft of resume data
* @param itemId - The ID of the item to remove
* @param type - The section type
* @param customSectionId - The custom section ID (if applicable)
* @returns The removed item, or null if not found
*/
export function removeItemFromSource(
draft: WritableDraft<ResumeData>,
itemId: string,
type: SectionType,
customSectionId?: string,
): SectionItem | null {
if (customSectionId) {
const section = draft.customSections.find((s) => s.id === customSectionId);
if (!section) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
const section = draft.sections[type];
if (!("items" in section)) return null;
const index = section.items.findIndex((item) => item.id === itemId);
if (index === -1) return null;
const [removed] = section.items.splice(index, 1);
return removed as SectionItem;
}
/**
* Adds an item to a target section.
*
* @param draft - The immer draft of resume data
* @param item - The item to add
* @param targetSectionId - The target section ID
* @param type - The section type
*/
export function addItemToSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
targetSectionId: string,
type: SectionType,
): void {
// Check if target is a standard section
if (isStandardSectionId(targetSectionId) && targetSectionId === type) {
const section = draft.sections[type];
if ("items" in section) {
section.items.push(item as never);
}
return;
}
// Otherwise, it's a custom section
const customSection = draft.customSections.find((s) => s.id === targetSectionId);
if (customSection) {
customSection.items.push(item as never);
}
}
/**
* Creates a new custom section with the given item and adds it to the specified page.
*
* @param draft - The immer draft of resume data
* @param item - The item to add to the new section
* @param type - The section type for the new custom section
* @param sectionTitle - The title for the new custom section
* @param targetPageIndex - The page index to add the section to
* @returns The ID of the newly created custom section
*/
export function createCustomSectionWithItem(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: SectionType,
sectionTitle: string,
targetPageIndex: number,
): string {
const newSectionId = generateId();
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
draft.customSections.push(newSection as WritableDraft<CustomSection>);
// Add the section to the target page's main column
const page = draft.metadata.layout.pages[targetPageIndex];
if (page) {
page.main.push(newSectionId);
}
return newSectionId;
}
/**
* Creates a new page with a custom section containing the given item.
*
* @param draft - The immer draft of resume data
* @param item - The item to add to the new section
* @param type - The section type for the new custom section
* @param sectionTitle - The title for the new custom section
*/
export function createPageWithSection(
draft: WritableDraft<ResumeData>,
item: SectionItem,
type: SectionType,
sectionTitle: string,
): void {
const newSectionId = generateId();
// Create the new custom section
const newSection: CustomSection = {
id: newSectionId,
type,
title: sectionTitle,
columns: 1,
hidden: false,
items: [item as never],
};
draft.customSections.push(newSection as WritableDraft<CustomSection>);
// Create the new page with the section in the main column
draft.metadata.layout.pages.push({
fullWidth: false,
main: [newSectionId],
sidebar: [],
});
}
+15
View File
@@ -4,9 +4,23 @@ import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import type { Plugin } from "vite";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
// Polyfill for Reflect.getMetadata() (required by @better-auth/passkey)
const REFLECT_POLYFILL = `if("function"!=typeof Reflect.getMetadata){const e=new WeakMap,t=(t,a)=>e.get(t)?.get(a),a=(t,a)=>{let n=e.get(t);n||(n=new Map,e.set(t,n));let f=n.get(a);return f||(f=new Map,n.set(a,f)),f},n=(e,a,f)=>{const c=t(a,f);if(c?.has(e))return c.get(e);const s=Object.getPrototypeOf(a);return s?n(e,s,f):void 0};Reflect.getMetadata=(e,t,a)=>n(e,t,a),Reflect.getOwnMetadata=(e,a,n)=>t(a,n)?.get(e),Reflect.defineMetadata=(e,t,n,f)=>a(n,f).set(e,t),Reflect.hasMetadata=(e,t,a)=>void 0!==n(e,t,a),Reflect.hasOwnMetadata=(e,a,n)=>t(a,n)?.has(e)??!1,Reflect.metadata=(e,t)=>(n,f)=>a(n,f).set(e,t)};`;
function reflectPolyfillPlugin(): Plugin {
return {
name: "reflect-polyfill",
renderChunk(code, chunk) {
if (chunk.fileName.includes("passkey")) return `${REFLECT_POLYFILL}\n${code}`;
return null;
},
};
}
const config = defineConfig({
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
@@ -51,6 +65,7 @@ const config = defineConfig({
},
plugins: [
reflectPolyfillPlugin(),
lingui(),
tailwindcss(),
nitro({ plugins: ["plugins/1.migrate.ts"] }),