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