- Use browserless over gotenberg

- Implement functionality to move items between sections or pages
- Enhance custom sections to have a `type` property
- Update the v4 importer to account for custom sections
- Update healthcheck to be a simple curl command
- Update dependencies to latest
and a lot more changes
This commit is contained in:
Amruth Pillai
2026-01-21 18:49:54 +01:00
parent b3c342b029
commit 70064be7de
54 changed files with 2153 additions and 822 deletions
+1 -8
View File
@@ -10,14 +10,7 @@ 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"
+53 -6
View File
@@ -66,8 +66,13 @@ 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 }}
labels: |
org.opencontainers.image.title=Reactive Resume
org.opencontainers.image.description=A free and open-source resume builder.
org.opencontainers.image.vendor=Amruth Pillai
org.opencontainers.image.version=${{ steps.version.outputs.version }}
- name: Build and Push by Digest
id: build
@@ -110,7 +115,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 +147,23 @@ 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"
# Check if stable release (no pre-release identifiers)
if [[ "$VERSION" =~ (alpha|beta|rc|dev|canary) ]]; then
echo "is_stable=false" >> "$GITHUB_OUTPUT"
else
echo "is_stable=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
@@ -150,11 +172,19 @@ jobs:
ghcr.io/${{ env.IMAGE }}
docker.io/${{ env.IMAGE }}
tags: |
type=sha,format=short
type=raw,value=latest
type=raw,value=latest,enable=${{ steps.semver.outputs.is_stable }}
type=raw,value=v${{ steps.version.outputs.version }}
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }},enable=${{ steps.semver.outputs.is_stable }}
type=raw,value=v${{ steps.semver.outputs.major }},enable=${{ steps.semver.outputs.is_stable }}
type=sha,prefix=sha-
labels: |
org.opencontainers.image.title=Reactive Resume
org.opencontainers.image.description=A free and open-source resume builder.
org.opencontainers.image.vendor=Amruth Pillai
org.opencontainers.image.version=${{ steps.version.outputs.version }}
- name: Create manifest list and push
id: manifest
working-directory: /tmp/digests
run: |
set -euo pipefail
@@ -162,13 +192,30 @@ jobs:
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(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
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 }}
- name: Redeploy Service
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: appleboy/ssh-action@v1
with:
key: ${{ secrets.SSH_KEY }}
+6
View File
@@ -35,6 +35,9 @@ RUN pnpm run build
# ---------- Runtime Layer ----------
FROM node:24-slim AS runtime
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 +48,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:
+10 -15
View File
@@ -19,25 +19,20 @@ 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"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
start_period: 10s
interval: 30s
timeout: 10s
retries: 3
- HEALTH=true
- MAX_CPU_PERCENT=90
- MAX_MEMORY_PERCENT=90
- QUEUED=5
- CONCURRENT=5
- TOKEN=1234567890
seaweedfs:
image: chrislusf/seaweedfs:latest
+6 -3
View File
@@ -100,9 +100,12 @@ services:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
"curl",
"-f",
"http://localhost:3000/api/health",
"||",
"exit",
"1",
]
start_period: 10s
interval: 30s
+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:**
+10 -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,14 @@ 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 | — |
| `PRINTER_TOKEN` | Printer authentication token (if using browserless with token auth) | — |
| `GOOGLE_CLIENT_ID` | Google OAuth Client ID | — |
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — |
| `GITHUB_CLIENT_ID` | GitHub OAuth Client ID | — |
@@ -198,7 +197,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.
+36 -25
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,11 @@ APP_URL="http://localhost:3000"
PRINTER_APP_URL="http://host.docker.internal:3000"
# --- Printer ---
GOTENBERG_ENDPOINT="http://gotenberg:3000"
PRINTER_ENDPOINT="http://printer:3000"
# Gotenberg Authentication (Optional)
# GOTENBERG_USERNAME=""
# GOTENBERG_PASSWORD=""
# Printer Authentication (Optional)
# If using browserless with a token, set PRINTER_TOKEN
# PRINTER_TOKEN=""
# --- Database (PostgreSQL) ---
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
@@ -150,7 +150,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 +171,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
- 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: 10s
timeout: 5s
retries: 10
@@ -201,10 +204,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 +218,10 @@ volumes:
</CodeGroup>
<Note>
**Alternative Printer Options**: If you don't want to use browserless, you can also use any headless Chrome/Chromium instance with its remote debugging port open. For example, you could run `chromium --remote-debugging-port=9222` and point `PRINTER_ENDPOINT` to that instance.
</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 +261,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 +279,16 @@ 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`.
- **`PRINTER_TOKEN`** (optional): Authentication token for the printer service. Required if your browserless instance is configured with token authentication.
<Note>
**Alternative to browserless**: You can use any headless Chrome/Chromium instance with its remote debugging port open. For example, run `chromium --remote-debugging-port=9222` and set `PRINTER_ENDPOINT` to point to that instance.
</Note>
</Accordion>
<Accordion title="Database (PostgreSQL)">
@@ -386,7 +397,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 +450,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=$PRINTER_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
- PRINTER_ENDPOINT=$PRINTER_ENDPOINT
# Optional: Set PRINTER_TOKEN if using browserless with token authentication
# - PRINTER_TOKEN=$PRINTER_TOKEN
- 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
+7 -6
View File
@@ -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.2",
"@tanstack/react-router-ssr-query": "^1.154.2",
"@tanstack/react-start": "^1.154.2",
"@tanstack/zod-adapter": "^1.154.2",
"@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",
+569 -129
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 };
}),
+126 -84
View File
@@ -1,9 +1,25 @@
import { ORPCError } from "@orpc/server";
import type { InferSelectModel } from "drizzle-orm";
import puppeteer, { type Browser, 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";
type PressureResponse = {
cpu: number;
date: number;
isAvailable: boolean;
maxConcurrent: number;
maxQueued: number;
memory: number;
message: string;
queued: number;
reason: string;
recentlyRejected: number;
running: number;
};
const pageDimensions = {
a4: {
width: "210mm",
@@ -17,65 +33,102 @@ const pageDimensions = {
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
let pdfBrowser: Browser | null = null;
let screenshotBrowser: Browser | null = null;
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<PressureResponse> => {
const printerEndpoint = env.PRINTER_ENDPOINT;
const headers = new Headers({ Accept: "application/json" });
const endpoint = new URL(printerEndpoint);
endpoint.protocol = endpoint.protocol === "wss:" ? "https:" : "http:";
endpoint.pathname = "/pressure";
const response = await fetch(endpoint, { headers });
const data = (await response.json()) as { pressure: PressureResponse };
return data.pressure;
},
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,
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
if (!pdfBrowser || !pdfBrowser.connected) {
pdfBrowser = await puppeteer.connect({
acceptInsecureCerts: true,
browserWSEndpoint: env.PRINTER_ENDPOINT,
});
}
const pdfBuffer = await response.arrayBuffer();
await pdfBrowser.setCookie({ name: "locale", value: locale, domain });
const page = await pdfBrowser.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,
},
});
await page.close();
// 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 +137,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 +162,44 @@ 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 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}`);
}
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/screenshot/url`, {
headers,
method: "POST",
body: formData,
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
if (!screenshotBrowser || !screenshotBrowser.connected) {
screenshotBrowser = await puppeteer.connect({
acceptInsecureCerts: true,
defaultViewport: { width: 794, height: 1123 },
browserWSEndpoint: env.PRINTER_ENDPOINT,
});
}
const imageBuffer = await response.arrayBuffer();
await screenshotBrowser.setCookie({ name: "locale", value: locale, domain });
const page = await screenshotBrowser.newPage();
if (env.APP_URL.includes("localhost")) await interceptLocalhostRequests(page);
await page.goto(url, { waitUntil: "networkidle0" });
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",
});
+15
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,6 +52,19 @@ 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();
@@ -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?/ }),
// 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: [],
});
}