mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-01 01:04:29 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa823e1296 | ||
|
|
de89ab957b | ||
|
|
96920495d0 | ||
|
|
92044de6b8 | ||
|
|
8e8ef300ec | ||
|
|
982220430e | ||
|
|
3f55c24e36 | ||
|
|
0c65612368 | ||
|
|
3071b7dd37 | ||
|
|
5daac24e73 | ||
|
|
bcd2828305 | ||
|
|
b5f4e9af46 | ||
|
|
92f6a6a16b | ||
|
|
019a20d027 | ||
|
|
1378feaf6a | ||
|
|
22221e6fae | ||
|
|
d773ff5391 | ||
|
|
be84f0cca6 | ||
|
|
1419c16d04 | ||
|
|
89f209014e | ||
|
|
21aec46763 | ||
|
|
333b5e50c4 | ||
|
|
e94550b7b2 |
@@ -0,0 +1 @@
|
||||
locales/*.po linguist-generated=true
|
||||
@@ -2,8 +2,6 @@ name: Build Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags: [ "v*.*.*" ]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -204,4 +202,14 @@ jobs:
|
||||
- name: Inspect image
|
||||
run: |
|
||||
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 }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Redeploy Stack
|
||||
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
|
||||
+5
-1
@@ -13,4 +13,8 @@ node_modules
|
||||
!.env.example
|
||||
public/sw.js
|
||||
scripts/**/*.json
|
||||
public/workbox-*.js
|
||||
public/workbox-*.js
|
||||
|
||||
# Compiled translation files
|
||||
/locales/*.js
|
||||
!/locales/*.po
|
||||
Vendored
+3
-3
@@ -10,12 +10,12 @@
|
||||
"*.css": "tailwindcss"
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"locales/*.po": true,
|
||||
// "locales/*.po": true,
|
||||
"pnpm-lock.yaml": true,
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"locales/*.po": true,
|
||||
// "locales/*.po": true,
|
||||
"pnpm-lock.yaml": true,
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
@@ -23,7 +23,7 @@
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.sourceLanguage": "en-US",
|
||||
"search.exclude": {
|
||||
"locales/*.po": true,
|
||||
// "locales/*.po": true,
|
||||
"pnpm-lock.yaml": true,
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Reactive Resume is a free, open-source resume builder built with TanStack Start (React 19 + Vite), using ORPC for type-safe RPC APIs, Drizzle ORM with PostgreSQL, and Better Auth for authentication.
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start development server (runs on port 3000)
|
||||
pnpm dev
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start production server
|
||||
pnpm start
|
||||
|
||||
# Linting (uses Biome)
|
||||
pnpm lint
|
||||
|
||||
# Type checking
|
||||
pnpm typecheck
|
||||
|
||||
# Database operations
|
||||
pnpm db:generate # Generate migration files
|
||||
pnpm db:migrate # Run migrations
|
||||
pnpm db:push # Push schema changes directly
|
||||
pnpm db:studio # Open Drizzle Studio
|
||||
|
||||
# Extract i18n strings for translation
|
||||
pnpm lingui:extract
|
||||
|
||||
# Find unused exports
|
||||
pnpm knip
|
||||
```
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
1. Copy `.env.example` to `.env` and configure environment variables
|
||||
2. Start required services: `docker compose -f compose.dev.yml up -d`
|
||||
- PostgreSQL (port 5432)
|
||||
- Browserless/Chromium for PDF generation (port 4000)
|
||||
- SeaweedFS for S3-compatible storage (port 8333)
|
||||
- Mailpit for email testing (ports 1025, 8025)
|
||||
- Adminer for DB management (port 8080)
|
||||
3. Run `pnpm dev`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
- `src/routes/` - TanStack Router file-based routing
|
||||
- `src/integrations/` - External service integrations (auth, database, ORPC, AI, email)
|
||||
- `src/integrations/orpc/router` - oRPC server routers
|
||||
- `src/integrations/orpc/services` - oRPC server services
|
||||
- `src/components/` - React components organized by feature
|
||||
- `src/schema/` - Zod schemas for validation
|
||||
- `plugins/` - Nitro server plugins (eg. auto-migration on startup)
|
||||
- `migrations/` - Drizzle database migrations
|
||||
- `locales/` - i18n translation files (managed by Lingui)
|
||||
|
||||
### Key Integrations (`src/integrations/`)
|
||||
|
||||
- **auth/** - Better Auth configuration and client
|
||||
- **drizzle/** - Database schema and client (PostgreSQL)
|
||||
- **orpc/** - Type-safe RPC router with procedures for ai, auth, flags, printer, resume, statistics, storage
|
||||
- **query/** - TanStack Query client configuration
|
||||
- **ai/** - AI provider integrations (OpenAI, Anthropic, Google Gemini, Ollama)
|
||||
|
||||
### Resume Data Model
|
||||
|
||||
The resume schema is defined in `src/schema/resume/data.ts`. Key concepts:
|
||||
- **ResumeData** - Complete resume data including basics, sections, customSections, metadata
|
||||
- **Sections** - Built-in sections (profiles, experience, education, skills, etc.)
|
||||
- **CustomSections** - User-created sections that follow one of the built-in section types
|
||||
- **Metadata** - Template, layout, typography, design settings, custom CSS
|
||||
|
||||
### Resume Templates
|
||||
|
||||
Templates are React components in `src/components/resume/templates/`. Each template (azurill, bronzor, chikorita, etc.) renders the resume data with different visual styles. Templates use shared components from `src/components/resume/shared/`.
|
||||
|
||||
### Database Schema
|
||||
|
||||
Defined in `src/integrations/drizzle/schema.ts`:
|
||||
- `user`, `session`, `account`, `verification`, `twoFactor`, `passkey`, `apikey` - Better Auth tables
|
||||
- `resume` - Stores Resume Data as JSONB (defined in `src/schema/resume/data.ts`)
|
||||
- `resumeStatistics` - Views/Download for Resume Tracking
|
||||
|
||||
### Routing
|
||||
|
||||
Uses TanStack Router with file-based routing. Key routes:
|
||||
- `/_home/` - Public landing pages
|
||||
- `/auth/` - Authentication flows
|
||||
- `/dashboard/` - User dashboard and resume management
|
||||
- `/builder/$resumeId/` - Resume editor
|
||||
- `/printer/$resumeId/` - PDF rendering endpoint
|
||||
- `/api/` - Public API endpoints
|
||||
|
||||
### State Management
|
||||
|
||||
- **Zustand** - Client-side state (resume editor state in `src/components/resume/store/`)
|
||||
- **TanStack Query** - Server state and caching (configured via ORPC integration)
|
||||
|
||||
## Code Style
|
||||
|
||||
- Uses Biome for linting and formatting
|
||||
- Tab indentation, double quotes, 120 character line width
|
||||
- Path alias: `@/` maps to `src/`
|
||||
- Tailwind CSS v4 with sorted class names (enforced by Biome)
|
||||
- Uses `cn()` utility for conditional class names
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Key variables (see `.env.example` for full list):
|
||||
- `APP_URL` - Application URL
|
||||
- `DATABASE_URL` - PostgreSQL connection string
|
||||
- `AUTH_SECRET` - Secret for authentication
|
||||
- `PRINTER_ENDPOINT` - WebSocket endpoint for PDF printer service
|
||||
- `S3_*` - S3-compatible storage configuration
|
||||
- `FLAG_*` - Feature flags
|
||||
+1
-1
@@ -60,4 +60,4 @@ 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"]
|
||||
ENTRYPOINT ["node", ".output/server/index.mjs"]
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="Stars" />
|
||||
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
|
||||
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
|
||||
<a href="https://discord.gg/hzwkZbyvUW"><img src="https://img.shields.io/discord/1173518977851473940?label=Discord&style=flat-square&logo=discord" alt="Discord" /></a>
|
||||
<a href="https://discord.gg/EE8yFqW4"><img src="https://img.shields.io/discord/1173518977851473940?label=Discord&style=flat-square&logo=discord" alt="Discord" /></a>
|
||||
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
|
||||
<a href="https://opencollective.com/reactive-resume"><img src="https://img.shields.io/opencollective/all/reactive-resume?style=flat-square" alt="Open Collective" /></a>
|
||||
</p>
|
||||
@@ -225,6 +225,16 @@ Other ways to support:
|
||||
- Improve documentation
|
||||
- Help with translations
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#amruthpillai/reactive-resume&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make open-source thrive. Whether fixing a typo or adding a feature, all contributions are welcome.
|
||||
|
||||
+7
-1
@@ -8,7 +8,13 @@
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!**/webfontlist.json", "!**/docs/spec.json", "!**/src/routeTree.gen.ts"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/webfontlist.json",
|
||||
"!**/schema/schema.json",
|
||||
"!**/docs/spec.json",
|
||||
"!**/src/routeTree.gen.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"lineWidth": 120,
|
||||
|
||||
@@ -25,7 +25,6 @@ services:
|
||||
- HEALTH=true
|
||||
- CONCURRENT=20
|
||||
- TOKEN=1234567890
|
||||
- EXTERNAL=http://localhost:4000
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "curl", "-f", "http://localhost:3000/pressure?token=1234567890"]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
description: "List of all notable changes and updates to Reactive Resume"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.0.3" description="25th January 2026">
|
||||
- Allow the user to override the default endpoint for all AI providers, not just Ollama. Also display the default endpoint for each provider in the AI settings page.
|
||||
- Updated the chip input component, to allow the user to add, edit, remove or reorder keywords for skills and other sections.
|
||||
- Improved RTL support across the app, thanks to @obreo for the contribution. [(link)](https://github.com/amruthpillai/reactive-resume/pull/2583)
|
||||
- Updates to the translation configuration to remove line numbers from the translation files, as this was causing unnecessary diffs.
|
||||
- Updated the video on the homepage to be lighter and faster to load, while still maintaining the same quality.
|
||||
- Increased the screenshot TTL for resumes, to avoid regenerating screenshots unnecessarily unless the resume has been updated recently (in the last hour).
|
||||
- Update dependencies and translations to the latest versions.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.2" description="24th January 2026">
|
||||
- Added an agent skill `skills/resume-builder` for agentic AI assistants to build resumes for Reactive Resume through conversational AI.
|
||||
- Added a new guide on how to fit content on a page, to avoid issues when exporting to PDF. [(link)](/guides/fitting-content-on-a-page)
|
||||
- Display an alert when the content is too tall for a page, to help the user fit the content on a page.
|
||||
- Fix an issue with the Ditgar template, where the page was not respecting the `fullWidth` setting.
|
||||
- Updated the JSON Schema to conform to a proper format.
|
||||
- Updated the Discord Server invite link to a new one.
|
||||
- Updated dependencies to the latest versions.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.1" description="23th January 2026">
|
||||
- Updated translations from Crowdin.
|
||||
- Added a Community Spotlight section to the documentation.
|
||||
- Remove `-r require-metadata` from the Dockerfile as it was not needed.
|
||||
- Fixed inconsistencies in the docker compose examples in the documentation.
|
||||
- Fixed an issue with usernames not allowing hyphens in them.
|
||||
- Fixed issues with the printer service, when using the `getResumeScreenshot` or `printResumeAsPDF` endpoints.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.0" description="22th January 2026">
|
||||
This has been a major overhaul from the previous version of Reactive Resume. The app has been completely redesigned and rebuilt from scratch, to be more intuitive and user-friendly.
|
||||
|
||||
**Here are some of the key changes from the previous version:**
|
||||
- 2 new templates: _Ditgar_ and _Lapras_
|
||||
- Authentication via Passkeys
|
||||
- New user interface and refreshed design
|
||||
- API Access and Reference Documentation
|
||||
- AI Integration with OpenAI, Google, Anthropic, and Ollama
|
||||
- A better font selector, with real-time preview of the font
|
||||
- More comprehensive documentation, with guides for specific features
|
||||
- An improved templates gallery, with clear overview of each template
|
||||
- Ability to import resumes from PDF or DOCX (requires AI Integration)
|
||||
- Reliable server infrastructure, with improved performance and scalability
|
||||
- Ability to move items between sections and pages, for better organization
|
||||
- A more powerful CSS Editor, with better autocomplete and syntax highlighting
|
||||
- Extended Custom Sections to include a type, for better customization and organization
|
||||
- Ability to choose the kind of icons to display for level indicators (stars, circles, custom icons, etc.)
|
||||
- Ability to resize how wide/narrow the sidebar should be on the resume, or convert a page to be full width
|
||||
|
||||
There's still a lot more that I'm forgetting, but I'm sure you'll find out soon enough as you explore the new version. I hope you enjoy building your resume now, better than ever. If you have any feedback, please feel free to [contact me](https://amruthpillai.com/#contact) or [open an issue](https://github.com/amruthpillai/reactive-resume/issues) on GitHub.
|
||||
</Update>
|
||||
+9
-6
@@ -27,16 +27,12 @@
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
"icon": "book",
|
||||
"tab": "Documentation",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Getting Started",
|
||||
"pages": [
|
||||
"getting-started/index",
|
||||
"getting-started/quickstart",
|
||||
"guides/accessing-the-previous-version",
|
||||
"getting-started/changelog"
|
||||
]
|
||||
"pages": ["getting-started/index", "getting-started/quickstart", "guides/accessing-the-previous-version"]
|
||||
},
|
||||
{
|
||||
"group": "Account",
|
||||
@@ -53,6 +49,7 @@
|
||||
"guides/creating-your-first-resume",
|
||||
"guides/choosing-a-template",
|
||||
"guides/moving-items-between-sections",
|
||||
"guides/fitting-content-on-a-page",
|
||||
"guides/exporting-your-resume",
|
||||
"guides/sharing-your-resume-publicly",
|
||||
"guides/using-private-notes",
|
||||
@@ -86,6 +83,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"icon": "clock",
|
||||
"tab": "Changelog",
|
||||
"pages": ["changelog/index"]
|
||||
},
|
||||
{
|
||||
"icon": "code",
|
||||
"tab": "API Reference",
|
||||
"openapi": "/spec.json"
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
title: "Changelog"
|
||||
description: "List of all notable changes and updates to Reactive Resume"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.0.0" description="26th January 2026">
|
||||
This has been a major overhaul from the previous version of Reactive Resume. The app has been completely redesigned and rebuilt from scratch, to be more intuitive and user-friendly.
|
||||
|
||||
**Here are some of the key changes from the previous version:**
|
||||
- 2 new templates: _Ditgar_ and _Lapras_
|
||||
- Authentication via Passkeys
|
||||
- New user interface and refreshed design
|
||||
- API Access and Reference Documentation
|
||||
- AI Integration with OpenAI, Google, Anthropic, and Ollama
|
||||
- A better font selector, with real-time preview of the font
|
||||
- More comprehensive documentation, with guides for specific features
|
||||
- An improved templates gallery, with clear overview of each template
|
||||
- Ability to import resumes from PDF or DOCX (requires AI Integration)
|
||||
- Reliable server infrastructure, with improved performance and scalability
|
||||
- Ability to move items between sections and pages, for better organization
|
||||
- A more powerful CSS Editor, with better autocomplete and syntax highlighting
|
||||
- Extended Custom Sections to include a type, for better customization and organization
|
||||
- Ability to choose the kind of icons to display for level indicators (stars, circles, custom icons, etc.)
|
||||
- Ability to resize how wide/narrow the sidebar should be on the resume, or convert a page to be full width
|
||||
|
||||
There's still a lot more that I'm forgetting, but I'm sure you'll find out soon enough as you explore the new version. I hope you enjoy building your resume now, better than ever. If you have any feedback, please feel free to [contact me](https://amruthpillai.com/#contact) or [open an issue](https://github.com/amruthpillai/reactive-resume/issues) on GitHub.
|
||||
</Update>
|
||||
@@ -104,7 +104,7 @@ Reactive Resume is built with modern web technologies:
|
||||
<Card title="Reddit" icon="reddit" href="https://reddit.com/r/reactiveresume">
|
||||
Join our Reddit community to get help and connect with other users.
|
||||
</Card>
|
||||
<Card title="Discord" icon="discord" href="https://discord.gg/hzwkZbyvUW">
|
||||
<Card title="Discord" icon="discord" href="https://discord.gg/EE8yFqW4">
|
||||
Join our Discord server to get help and connect with other users.
|
||||
</Card>
|
||||
<Card title="Sponsor" icon="heart" href="https://opencollective.com/reactive-resume">
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: "Fitting content on a page"
|
||||
description: "Learn how to reorganize and format your resume content to fit within a single page, whether you're using A4 or Letter format."
|
||||
---
|
||||
|
||||
When your resume content overflows the page, Reactive Resume displays a warning just below the page. This guide explains how to reorganize and adjust your content so everything fits within your chosen page format.
|
||||
|
||||
<Frame caption="Screenshot of the overflow warning message in the resume builder">
|
||||
<img src="/images/guides/fitting-content-on-a-page/screenshot-1.webp" alt="Screenshot of the overflow warning message in the resume builder" />
|
||||
</Frame>
|
||||
|
||||
<Warning>
|
||||
While Reactive Resume supports multi-page resumes, each page has a fixed height based on your chosen format (A4 or Letter). If a single page's content exceeds this height, parts of your resume may be rendered improperly when printed or exported.
|
||||
</Warning>
|
||||
|
||||
## Quick fixes
|
||||
|
||||
Here are several ways to fit your content within a page, ordered from content changes to layout adjustments.
|
||||
|
||||
### 1. Shorten text blocks
|
||||
|
||||
Long paragraphs take up space without adding proportional value. Review each section and cut ruthlessly:
|
||||
|
||||
- **Use bullet points** instead of paragraphs. Bullets are easier to scan and take less vertical space.
|
||||
- **Remove filler words.** "Was responsible for managing" becomes "Managed."
|
||||
- **Focus on impact.** Keep measurable achievements; cut generic descriptions.
|
||||
- **Limit bullets per entry.** Three to five bullets per job is usually enough.
|
||||
|
||||
<Tip>
|
||||
Read each bullet point and ask: "Does this help me get an interview?" If not, cut it.
|
||||
</Tip>
|
||||
|
||||
### 2. Use multi-column layouts
|
||||
|
||||
Some sections work better in multiple columns, especially lists of short items.
|
||||
|
||||
In the left sidebar, find the section you want to adjust, click on the section heading (not an item), and change the **Columns** setting.
|
||||
|
||||
<Frame caption="Screenshot of the columns setting for a section">
|
||||
<img src="/images/guides/fitting-content-on-a-page/screenshot-2.webp" alt="Screenshot of the columns setting for a section" />
|
||||
</Frame>
|
||||
|
||||
Good candidates for multi-column layouts:
|
||||
|
||||
| Section | Recommended Columns |
|
||||
|---------|---------------------|
|
||||
| Skills | > 2 columns |
|
||||
| Languages | > 3 columns |
|
||||
| Interests | > 2 columns |
|
||||
| Profiles | > 3 columns |
|
||||
| Certifications (if brief) | > 2 columns |
|
||||
|
||||
<Info>
|
||||
Multi-column layouts work best for sections with short, uniform items. Sections with long descriptions (like Experience or Projects) usually work better in a single column.
|
||||
</Info>
|
||||
|
||||
### 3. Move items to another page
|
||||
|
||||
If you have more content than fits on one page, move less important items to page two. This keeps your first page focused on your most relevant experience.
|
||||
|
||||
Use the **Move to** feature to relocate items:
|
||||
|
||||
1. Open the item's dropdown menu (three-dot icon)
|
||||
2. Hover over **Move to**
|
||||
3. Select the destination page
|
||||
|
||||
For detailed instructions, see [Moving items between sections](/guides/moving-items-between-sections).
|
||||
|
||||
<Tip>
|
||||
Keep your most recent and relevant experience on page one. Move older positions or less critical sections (like older projects or volunteer work) to subsequent pages.
|
||||
</Tip>
|
||||
|
||||
### 4. Adjust layout and design settings
|
||||
|
||||
The right sidebar contains settings that control how much space your content uses. Small adjustments here can make a big difference.
|
||||
|
||||
Open the right sidebar and explore these options:
|
||||
|
||||
| Setting | Where to find it | Effect |
|
||||
|---------|------------------|--------|
|
||||
| **Font size** | Typography | Smaller fonts fit more text per line and per page |
|
||||
| **Line height** | Typography | Tighter line spacing reduces vertical space |
|
||||
| **Margins** | Page | Smaller margins give you more usable area |
|
||||
| **Section gaps** | Page | Reducing gaps between sections saves space |
|
||||
| **Sidebar width** | Layout | Adjusting the sidebar ratio can balance content better |
|
||||
| **Picture size** | Picture (left sidebar) | A smaller photo leaves more room for text |
|
||||
|
||||
<Info>
|
||||
**Font size is the most effective setting.** Reducing body font from 11pt to 10.5pt (or even 10pt) can free up significant space while remaining readable. The editor supports 0.1pt increments, so you can fine-tune precisely.
|
||||
</Info>
|
||||
|
||||
### 5. Hide less important sections
|
||||
|
||||
If you're still short on space, consider hiding sections that aren't essential for your target role:
|
||||
|
||||
- **Interests** — Nice to have, but rarely a deciding factor
|
||||
- **References** — "Available upon request" is assumed; you don't need to list them
|
||||
- **Older certifications** — Keep only those relevant to the job
|
||||
- **Volunteer work** — Include only if it strengthens your application
|
||||
|
||||
To hide a section, click on the section heading in the left sidebar and toggle the **Hidden** switch.
|
||||
|
||||
## Finding the right balance
|
||||
|
||||
Start with content changes (steps 1-3) before adjusting design settings (steps 4-5). The best resumes fit their content naturally rather than forcing everything into a cramped layout.
|
||||
|
||||
Try this order:
|
||||
|
||||
1. Cut unnecessary text first
|
||||
2. Reorganize with columns where appropriate
|
||||
3. Move secondary content to page two if needed
|
||||
4. Fine-tune font size and spacing last
|
||||
|
||||
<Tip>
|
||||
Use the live preview to see changes as you make them. Small adjustments add up—reducing font size by 0.5pt combined with slightly smaller margins can recover enough space for several lines of content.
|
||||
</Tip>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Content still overflows after trying everything
|
||||
|
||||
If you've tried all the above and content still overflows:
|
||||
|
||||
- **Re-evaluate what's essential.** Every item should earn its place. Cut aggressively.
|
||||
- **Try a different template.** Some templates are more space-efficient than others.
|
||||
|
||||
### The preview looks different from the PDF
|
||||
|
||||
The PDF export matches the preview exactly. If they appear different, try:
|
||||
|
||||
- Refreshing the page
|
||||
- Checking that all fonts have loaded
|
||||
- Ensuring your browser zoom is at 100%
|
||||
|
||||
### I made the font too small and now it's hard to read
|
||||
|
||||
Resume fonts should stay between 9pt and 12pt for body text. If you've gone below 9pt to fit content, you're trying to include too much. Go back to step 1 and cut more content instead.
|
||||
+774
-256
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -62,6 +62,9 @@ export default defineConfig({
|
||||
"zu-ZA": "en-US",
|
||||
default: "en-US",
|
||||
},
|
||||
formatOptions: {
|
||||
lineNumbers: false,
|
||||
},
|
||||
catalogs: [
|
||||
{
|
||||
path: "<rootDir>/locales/{locale}",
|
||||
|
||||
Generated
+908
-864
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1456
-1406
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1126
-1076
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+1497
-1447
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+916
-866
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1503
-1453
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1472
-1422
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+903
-853
File diff suppressed because it is too large
Load Diff
Generated
+1499
-1449
File diff suppressed because it is too large
Load Diff
Generated
+1504
-1454
File diff suppressed because it is too large
Load Diff
Generated
+902
-852
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "reactive-resume",
|
||||
"description": "Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"version": "5.0.1",
|
||||
"version": "5.0.3",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316",
|
||||
@@ -56,16 +56,16 @@
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.10",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-router": "^1.156.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.156.0",
|
||||
"@tanstack/react-start": "^1.156.0",
|
||||
"@tanstack/zod-adapter": "^1.156.0",
|
||||
"@tiptap/extension-highlight": "^3.17.0",
|
||||
"@tiptap/extension-table": "^3.17.0",
|
||||
"@tiptap/extension-text-align": "^3.17.0",
|
||||
"@tiptap/pm": "^3.17.0",
|
||||
"@tiptap/react": "^3.17.0",
|
||||
"@tiptap/starter-kit": "^3.17.0",
|
||||
"@tanstack/react-router": "^1.157.10",
|
||||
"@tanstack/react-router-ssr-query": "^1.157.10",
|
||||
"@tanstack/react-start": "^1.157.10",
|
||||
"@tanstack/zod-adapter": "^1.157.10",
|
||||
"@tiptap/extension-highlight": "^3.17.1",
|
||||
"@tiptap/extension-table": "^3.17.1",
|
||||
"@tiptap/extension-text-align": "^3.17.1",
|
||||
"@tiptap/pm": "^3.17.1",
|
||||
"@tiptap/react": "^3.17.1",
|
||||
"@tiptap/starter-kit": "^3.17.1",
|
||||
"ai": "^6.0.49",
|
||||
"ai-sdk-ollama": "^3.3.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
@@ -91,7 +91,7 @@
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-hotkeys-hook": "^5.2.3",
|
||||
"react-resizable-panels": "^4.4.2",
|
||||
"react-resizable-panels": "^4.5.2",
|
||||
"react-window": "^2.2.5",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
|
||||
Generated
+302
-302
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 385 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,128 @@
|
||||
---
|
||||
name: resume-builder
|
||||
description: Generate professional resumes that conform to the Reactive Resume schema. Use when the user wants to create, build, or generate a resume through conversational AI, or asks about resume structure, sections, or content. This skill guides the agent to ask clarifying questions, avoid hallucination, and produce valid JSON output for https://rxresu.me.
|
||||
---
|
||||
|
||||
# Resume Builder for Reactive Resume
|
||||
|
||||
Build professional resumes through conversational AI for [Reactive Resume](https://rxresu.me), a free and open-source resume builder.
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Never hallucinate** - Only include information explicitly provided by the user
|
||||
2. **Ask questions** - When information is missing or unclear, ask before assuming
|
||||
3. **Be concise** - Use clear, direct language; avoid filler words
|
||||
4. **Validate output** - Ensure all generated JSON conforms to the schema
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Gather Basic Information
|
||||
|
||||
Ask for essential details first, unless the user has already provided them:
|
||||
|
||||
- Full name
|
||||
- Professional headline/title
|
||||
- Email address
|
||||
- Phone number
|
||||
- Location (city, state/country)
|
||||
- Website (optional)
|
||||
|
||||
### Step 2: Collect Section Content
|
||||
|
||||
For each section the user wants to include, gather specific details. Never invent dates, company names, or achievements.
|
||||
|
||||
**Experience**: company, position, location, period (e.g., "Jan 2020 - Present"), description of responsibilities/achievements
|
||||
|
||||
**Education**: school, degree, area of study, grade (optional), location, period
|
||||
|
||||
**Skills**: name, proficiency level (Beginner/Intermediate/Advanced/Expert), keywords
|
||||
|
||||
**Projects**: name, period, website (optional), description
|
||||
|
||||
**Other sections**: languages, certifications, awards, publications, volunteer work, interests, references
|
||||
|
||||
### Step 3: Configure Layout and Design
|
||||
|
||||
Ask about preferences:
|
||||
|
||||
- Template preference (13 available: azurill, bronzor, chikorita, ditto, ditgar, gengar, glalie, kakuna, lapras, leafish, onyx, pikachu, rhyhorn)
|
||||
- Page format: A4 or Letter
|
||||
- Which sections to include and their order
|
||||
|
||||
### Step 4: Generate Valid JSON
|
||||
|
||||
Output must conform to the Reactive Resume schema. See [references/schema.md](references/schema.md) for the complete schema structure.
|
||||
|
||||
Key requirements:
|
||||
- All item `id` fields must be valid UUIDs
|
||||
- Description fields accept HTML-formatted strings
|
||||
- Website fields require both `url` and `label` properties
|
||||
- Colors use `rgba(r, g, b, a)` format
|
||||
- Fonts must be available on Google Fonts
|
||||
|
||||
## Resume Writing Tips
|
||||
|
||||
Share these tips when helping users craft their resume content:
|
||||
|
||||
### Content Guidelines
|
||||
|
||||
- **Lead with impact**: Start bullet points with action verbs (Led, Developed, Increased, Managed)
|
||||
- **Quantify achievements**: Use numbers when possible ("Increased sales by 25%", "Managed team of 8")
|
||||
- **Tailor to the role**: Emphasize relevant experience for the target position
|
||||
- **Be specific**: Replace vague terms with concrete examples
|
||||
- **Keep it concise**: 1-2 pages maximum for most professionals
|
||||
|
||||
### Section Order Recommendations
|
||||
|
||||
For most professionals:
|
||||
1. Summary (if experienced)
|
||||
2. Experience
|
||||
3. Education
|
||||
4. Skills
|
||||
5. Projects (if relevant)
|
||||
6. Certifications/Awards
|
||||
|
||||
For students/recent graduates:
|
||||
1. Education
|
||||
2. Projects
|
||||
3. Skills
|
||||
4. Experience (if any)
|
||||
5. Activities/Volunteer
|
||||
|
||||
### Common Mistakes to Avoid
|
||||
|
||||
- Including personal pronouns ("I", "my")
|
||||
- Using passive voice
|
||||
- Listing job duties instead of achievements
|
||||
- Including irrelevant personal information
|
||||
- Inconsistent date formatting
|
||||
|
||||
## Output Format
|
||||
|
||||
When generating the resume, output a complete JSON object that conforms to the Reactive Resume schema. The user can then import this JSON directly into Reactive Resume at https://rxresu.me.
|
||||
|
||||
Example minimal structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"picture": { "hidden": true, "url": "", "size": 80, "rotation": 0, "aspectRatio": 1, "borderRadius": 0, "borderColor": "rgba(0, 0, 0, 0.5)", "borderWidth": 0, "shadowColor": "rgba(0, 0, 0, 0.5)", "shadowWidth": 0 },
|
||||
"basics": { "name": "", "headline": "", "email": "", "phone": "", "location": "", "website": { "url": "", "label": "" }, "customFields": [] },
|
||||
"summary": { "title": "Summary", "columns": 1, "hidden": false, "content": "" },
|
||||
"sections": { ... },
|
||||
"customSections": [],
|
||||
"metadata": { "template": "onyx", "layout": { ... }, ... }
|
||||
}
|
||||
```
|
||||
|
||||
For the complete schema, see [references/schema.md](references/schema.md).
|
||||
|
||||
## Asking Good Questions
|
||||
|
||||
When information is missing, ask specific questions:
|
||||
|
||||
- "What was your job title at [Company]?"
|
||||
- "What dates did you work there? (e.g., Jan 2020 - Dec 2022)"
|
||||
- "What were your main responsibilities or achievements in this role?"
|
||||
- "Do you have a specific target role or industry in mind?"
|
||||
|
||||
Avoid compound questions. Ask one thing at a time for clarity.
|
||||
@@ -0,0 +1,365 @@
|
||||
# Reactive Resume Schema Reference
|
||||
|
||||
The complete JSON Schema is available at: https://rxresu.me/schema.json
|
||||
|
||||
This reference provides key structural information for generating valid resume data.
|
||||
|
||||
## Top-Level Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"picture": { ... },
|
||||
"basics": { ... },
|
||||
"summary": { ... },
|
||||
"sections": { ... },
|
||||
"customSections": [ ... ],
|
||||
"metadata": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Picture Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"hidden": false,
|
||||
"url": "https://example.com/photo.jpg",
|
||||
"size": 80,
|
||||
"rotation": 0,
|
||||
"aspectRatio": 1,
|
||||
"borderRadius": 0,
|
||||
"borderColor": "rgba(0, 0, 0, 0.5)",
|
||||
"borderWidth": 0,
|
||||
"shadowColor": "rgba(0, 0, 0, 0.5)",
|
||||
"shadowWidth": 0
|
||||
}
|
||||
```
|
||||
|
||||
## Basics
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "John Doe",
|
||||
"headline": "Senior Software Engineer",
|
||||
"email": "john@example.com",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"location": "San Francisco, CA",
|
||||
"website": { "url": "https://johndoe.com", "label": "Portfolio" },
|
||||
"customFields": []
|
||||
}
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Summary",
|
||||
"columns": 1,
|
||||
"hidden": false,
|
||||
"content": "<p>HTML-formatted summary content here.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
## Sections
|
||||
|
||||
All sections share a common structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Section Title",
|
||||
"columns": 1,
|
||||
"hidden": false,
|
||||
"items": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### Experience Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"company": "Acme Corp",
|
||||
"position": "Software Engineer",
|
||||
"location": "San Francisco, CA",
|
||||
"period": "Jan 2020 - Present",
|
||||
"website": { "url": "https://acme.com", "label": "" },
|
||||
"description": "<ul><li>Built scalable microservices</li><li>Led team of 5 engineers</li></ul>"
|
||||
}
|
||||
```
|
||||
|
||||
### Education Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"school": "Stanford University",
|
||||
"degree": "Bachelor of Science",
|
||||
"area": "Computer Science",
|
||||
"grade": "3.8 GPA",
|
||||
"location": "Stanford, CA",
|
||||
"period": "2012 - 2016",
|
||||
"website": { "url": "", "label": "" },
|
||||
"description": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Skills Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"icon": "",
|
||||
"name": "JavaScript",
|
||||
"proficiency": "Expert",
|
||||
"level": 5,
|
||||
"keywords": ["React", "Node.js", "TypeScript"]
|
||||
}
|
||||
```
|
||||
|
||||
Level: 0-5 (0 hides the visual indicator)
|
||||
|
||||
### Project Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"name": "Open Source Project",
|
||||
"period": "2023 - Present",
|
||||
"website": { "url": "https://github.com/user/project", "label": "GitHub" },
|
||||
"description": "<p>Description of the project and your contributions.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
### Language Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"language": "English",
|
||||
"fluency": "Native",
|
||||
"level": 5
|
||||
}
|
||||
```
|
||||
|
||||
Fluency examples: Native, Fluent, Conversational, Basic, or CEFR levels (A1-C2)
|
||||
|
||||
### Certification Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"title": "AWS Solutions Architect",
|
||||
"issuer": "Amazon Web Services",
|
||||
"date": "March 2023",
|
||||
"website": { "url": "https://aws.amazon.com/certification/", "label": "Verify" },
|
||||
"description": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Award Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"title": "Employee of the Year",
|
||||
"awarder": "Acme Corp",
|
||||
"date": "2022",
|
||||
"website": { "url": "", "label": "" },
|
||||
"description": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Publication Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"title": "Research Paper Title",
|
||||
"publisher": "IEEE",
|
||||
"date": "2021",
|
||||
"website": { "url": "https://doi.org/...", "label": "DOI" },
|
||||
"description": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Volunteer Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"organization": "Local Food Bank",
|
||||
"location": "San Francisco, CA",
|
||||
"period": "2020 - Present",
|
||||
"website": { "url": "", "label": "" },
|
||||
"description": "<p>Volunteer activities and impact.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
### Interest Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"icon": "",
|
||||
"name": "Photography",
|
||||
"keywords": ["Landscape", "Portrait", "Street"]
|
||||
}
|
||||
```
|
||||
|
||||
### Reference Items
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"name": "Jane Smith",
|
||||
"position": "Engineering Manager at Acme Corp",
|
||||
"website": { "url": "https://linkedin.com/in/janesmith", "label": "LinkedIn" },
|
||||
"phone": "+1 (555) 987-6543",
|
||||
"description": "<p>Optional testimonial or quote.</p>"
|
||||
}
|
||||
```
|
||||
|
||||
### Profile Items (Social Links)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"hidden": false,
|
||||
"icon": "linkedin-logo",
|
||||
"network": "LinkedIn",
|
||||
"username": "johndoe",
|
||||
"website": { "url": "https://linkedin.com/in/johndoe", "label": "" }
|
||||
}
|
||||
```
|
||||
|
||||
Icons use @phosphor-icons/web names. Common icons: `linkedin-logo`, `github-logo`, `twitter-logo`, `globe`
|
||||
|
||||
## Metadata
|
||||
|
||||
### Template Options
|
||||
|
||||
Available templates: `azurill`, `bronzor`, `chikorita`, `ditto`, `ditgar`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `onyx`, `pikachu`, `rhyhorn`
|
||||
|
||||
### Layout Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"sidebarWidth": 35,
|
||||
"pages": [
|
||||
{
|
||||
"fullWidth": false,
|
||||
"main": ["profiles", "summary", "experience", "education", "projects"],
|
||||
"sidebar": ["skills", "languages", "certifications", "interests"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Section IDs for layout: `profiles`, `experience`, `education`, `projects`, `skills`, `languages`, `interests`, `awards`, `certifications`, `publications`, `volunteer`, `references`, `summary`
|
||||
|
||||
### Page Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"gapX": 4,
|
||||
"gapY": 6,
|
||||
"marginX": 14,
|
||||
"marginY": 12,
|
||||
"format": "a4",
|
||||
"locale": "en-US",
|
||||
"hideIcons": false
|
||||
}
|
||||
```
|
||||
|
||||
Format options: `a4`, `letter`
|
||||
|
||||
### Design Settings
|
||||
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"primary": "rgba(220, 38, 38, 1)",
|
||||
"text": "rgba(0, 0, 0, 1)",
|
||||
"background": "rgba(255, 255, 255, 1)"
|
||||
},
|
||||
"level": {
|
||||
"icon": "star",
|
||||
"type": "circle"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Level types: `hidden`, `circle`, `square`, `rectangle`, `rectangle-full`, `progress-bar`, `icon`
|
||||
|
||||
### Typography
|
||||
|
||||
```json
|
||||
{
|
||||
"body": {
|
||||
"fontFamily": "IBM Plex Serif",
|
||||
"fontWeights": ["400", "500"],
|
||||
"fontSize": 10,
|
||||
"lineHeight": 1.5
|
||||
},
|
||||
"heading": {
|
||||
"fontFamily": "IBM Plex Serif",
|
||||
"fontWeights": ["600"],
|
||||
"fontSize": 14,
|
||||
"lineHeight": 1.5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Font family must be available on Google Fonts. Font weights: 100-900.
|
||||
|
||||
### CSS Customization
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": false,
|
||||
"value": ""
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Sections
|
||||
|
||||
Custom sections extend the base section structure with additional fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"title": "Custom Section Title",
|
||||
"columns": 1,
|
||||
"hidden": false,
|
||||
"type": "experience",
|
||||
"items": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
The `type` field determines the item schema. Valid types: `profiles`, `experience`, `education`, `projects`, `skills`, `languages`, `interests`, `awards`, `certifications`, `publications`, `volunteer`, `references`
|
||||
|
||||
## UUID Generation
|
||||
|
||||
All `id` fields require valid UUIDs version 7. If available, use 'v7()' from the 'uuid' package to generate a valid UUID. Otherwise, fallback to generating a valid UUID v4 using crypto.randomUUID() from 'node' or the equivalent.
|
||||
|
||||
## HTML Content
|
||||
|
||||
Fields marked as "HTML-formatted string" accept basic HTML:
|
||||
- `<p>` for paragraphs
|
||||
- `<ul><li>` for bullet lists
|
||||
- `<ol><li>` for numbered lists
|
||||
- `<strong>` for bold
|
||||
- `<em>` for italic
|
||||
- `<a href="...">` for links
|
||||
|
||||
Keep formatting minimal for best rendering across templates.
|
||||
@@ -33,10 +33,10 @@ export const Spotlight = ({
|
||||
<motion.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute top-0 left-0 z-40 h-svh w-svw"
|
||||
className="pointer-events-none absolute start-0 top-0 z-40 h-svh w-svw"
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
className="absolute start-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
@@ -46,7 +46,7 @@ export const Spotlight = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 origin-top-left"
|
||||
className="absolute start-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
@@ -56,7 +56,7 @@ export const Spotlight = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 left-0 origin-top-left"
|
||||
className="absolute start-0 top-0 origin-top-left"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
@@ -68,7 +68,7 @@ export const Spotlight = ({
|
||||
|
||||
<motion.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
className="pointer-events-none absolute top-0 right-0 z-40 h-svh w-svw"
|
||||
className="pointer-events-none absolute end-0 top-0 z-40 h-svh w-svw"
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
@@ -77,7 +77,7 @@ export const Spotlight = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-0"
|
||||
className="absolute end-0 top-0"
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
@@ -87,7 +87,7 @@ export const Spotlight = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 right-0 origin-top-right"
|
||||
className="absolute end-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
@@ -97,7 +97,7 @@ export const Spotlight = ({
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 right-0 origin-top-right"
|
||||
className="absolute end-0 top-0 origin-top-right"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: `${smallWidth}px`,
|
||||
|
||||
@@ -1,8 +1,88 @@
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { PencilSimpleIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { motion, Reorder, useDragControls } from "motion/react";
|
||||
import * as React from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||
import { cn } from "@/utils/style";
|
||||
import { Kbd } from "../ui/kbd";
|
||||
|
||||
const RETURN_KEY = "Enter";
|
||||
const COMMA_KEY = ",";
|
||||
|
||||
type ChipItemProps = {
|
||||
chip: string;
|
||||
index: number;
|
||||
isEditing: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
};
|
||||
|
||||
function ChipItem({ chip, index, isEditing, onEdit, onRemove }: ChipItemProps) {
|
||||
const controls = useDragControls();
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={chip}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
initial={{ opacity: 1, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"flex h-7 cursor-grab select-none items-center gap-0 overflow-hidden px-3 active:cursor-grabbing",
|
||||
isEditing && "border-primary ring-1 ring-primary",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
controls.start(e);
|
||||
}}
|
||||
>
|
||||
<span className="max-w-32 truncate">{chip}</span>
|
||||
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ width: isHovered ? 40 : 0, marginInlineStart: isHovered ? 8 : 0, opacity: isHovered ? 1 : 0 }}
|
||||
transition={{ duration: 0.15, ease: "linear" }}
|
||||
className="flex shrink-0 items-center gap-x-1 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Edit ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-secondary hover:text-foreground focus:outline-none"
|
||||
>
|
||||
<PencilSimpleIcon className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
}}
|
||||
className="rounded-sm p-0.5 hover:bg-destructive/10 hover:text-destructive focus:outline-none"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</motion.div>
|
||||
</Badge>
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = Omit<React.ComponentProps<"div">, "value" | "onChange"> & {
|
||||
value?: string[];
|
||||
@@ -18,6 +98,7 @@ export function ChipInput({ value, defaultValue = [], onChange, className, ...pr
|
||||
});
|
||||
|
||||
const [input, setInput] = React.useState("");
|
||||
const [editingIndex, setEditingIndex] = React.useState<number | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const addChip = React.useCallback(
|
||||
@@ -30,18 +111,82 @@ export function ChipInput({ value, defaultValue = [], onChange, className, ...pr
|
||||
[chips, setChips],
|
||||
);
|
||||
|
||||
const updateChip = React.useCallback(
|
||||
(index: number, newValue: string) => {
|
||||
const trimmed = newValue.trim();
|
||||
if (!trimmed || index < 0 || index >= chips.length) return;
|
||||
|
||||
// Check if the new value already exists at a different index
|
||||
const existingIndex = chips.findIndex((c, i) => c === trimmed && i !== index);
|
||||
if (existingIndex !== -1) return;
|
||||
|
||||
const newChips = [...chips];
|
||||
newChips[index] = trimmed;
|
||||
setChips(newChips);
|
||||
},
|
||||
[chips, setChips],
|
||||
);
|
||||
|
||||
const removeChip = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index < 0 || index >= chips.length) return;
|
||||
const newChips = chips.slice(0, index).concat(chips.slice(index + 1));
|
||||
setChips(Array.from(new Set(newChips)));
|
||||
setChips(newChips);
|
||||
|
||||
// If we were editing this chip, clear edit mode
|
||||
if (editingIndex === index) {
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (editingIndex !== null && editingIndex > index) {
|
||||
// Adjust editing index if we removed a chip before it
|
||||
setEditingIndex(editingIndex - 1);
|
||||
}
|
||||
},
|
||||
[chips, setChips],
|
||||
[chips, setChips, editingIndex],
|
||||
);
|
||||
|
||||
const handleEdit = React.useCallback(
|
||||
(index: number) => {
|
||||
setEditingIndex(index);
|
||||
setInput(chips[index]);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
[chips],
|
||||
);
|
||||
|
||||
const handleReorder = React.useCallback(
|
||||
(newOrder: string[]) => {
|
||||
// Update editingIndex to follow the chip being edited
|
||||
if (editingIndex !== null) {
|
||||
const editingChip = chips[editingIndex];
|
||||
const newIndex = newOrder.indexOf(editingChip);
|
||||
if (newIndex !== -1 && newIndex !== editingIndex) {
|
||||
setEditingIndex(newIndex);
|
||||
}
|
||||
}
|
||||
setChips(newOrder);
|
||||
},
|
||||
[chips, editingIndex, setChips],
|
||||
);
|
||||
|
||||
const handleInputChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
|
||||
// When editing, don't split on comma - allow full text editing
|
||||
if (editingIndex !== null) {
|
||||
if (newValue.includes(",")) {
|
||||
// Save current edit
|
||||
updateChip(editingIndex, newValue.replace(",", ""));
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else {
|
||||
setInput(newValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When adding new chips, split on comma
|
||||
if (newValue.includes(",")) {
|
||||
const parts = newValue.split(",");
|
||||
parts.slice(0, -1).forEach(addChip);
|
||||
@@ -50,74 +195,69 @@ export function ChipInput({ value, defaultValue = [], onChange, className, ...pr
|
||||
setInput(newValue);
|
||||
}
|
||||
},
|
||||
[addChip],
|
||||
[addChip, editingIndex, updateChip],
|
||||
);
|
||||
|
||||
const removeLastChip = React.useCallback(() => {
|
||||
if (chips.length === 0) return;
|
||||
const newChips = chips.slice(0, -1);
|
||||
setChips(newChips);
|
||||
}, [chips, setChips]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ((e.key === "Enter" || e.key === ",") && input.trim() !== "") {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault();
|
||||
addChip(input);
|
||||
|
||||
if (editingIndex !== null) {
|
||||
// Save edit
|
||||
if (input.trim()) {
|
||||
updateChip(editingIndex, input);
|
||||
}
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (input.trim()) {
|
||||
// Add new chip
|
||||
addChip(input);
|
||||
setInput("");
|
||||
}
|
||||
} else if (e.key === "Escape" && editingIndex !== null) {
|
||||
// Cancel edit
|
||||
setEditingIndex(null);
|
||||
setInput("");
|
||||
} else if (e.key === "Backspace" && input === "") {
|
||||
removeLastChip();
|
||||
}
|
||||
},
|
||||
[input, addChip, removeLastChip],
|
||||
[input, addChip, editingIndex, updateChip],
|
||||
);
|
||||
|
||||
const handleWrapperClick = React.useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={-1}
|
||||
onClick={handleWrapperClick}
|
||||
className={cn(
|
||||
"flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border bg-background px-3 py-1.5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50",
|
||||
className,
|
||||
<div className={cn("flex flex-col gap-2", className)} {...props}>
|
||||
{chips.length > 0 && (
|
||||
<Reorder.Group axis="x" values={chips} onReorder={handleReorder} className="flex flex-wrap gap-1.5">
|
||||
{chips.map((chip, idx) => (
|
||||
<ChipItem
|
||||
key={`${chip}-${idx}`}
|
||||
chip={chip}
|
||||
index={idx}
|
||||
isEditing={editingIndex === idx}
|
||||
onEdit={handleEdit}
|
||||
onRemove={removeChip}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{chips.map((chip, idx) => (
|
||||
<div key={chip + idx} className="relative">
|
||||
<Badge variant="outline" className="flex select-none items-center gap-1 pr-1 pl-2">
|
||||
<span>{chip}</span>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Remove ${chip}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeChip(idx);
|
||||
}}
|
||||
className="ml-0.5 hover:text-destructive focus:outline-none"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
aria-label="Add chip"
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
className="min-w-0 grow border-none bg-transparent outline-none focus:outline-none focus:ring-0"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
autoComplete="off"
|
||||
aria-label={editingIndex !== null ? "Edit keyword" : "Add keyword"}
|
||||
placeholder={editingIndex !== null ? "Editing keyword..." : "Add a keyword..."}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Press <Kbd>{RETURN_KEY}</Kbd> or <Kbd>{COMMA_KEY}</Kbd> to add or save the current keyword.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.tiptap_content {
|
||||
--list-margin: 0.25rem;
|
||||
--table-border-color: var(--page-text-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
:where(p) {
|
||||
margin-top: 0.5rem;
|
||||
@@ -30,14 +32,15 @@
|
||||
}
|
||||
|
||||
:where(li) {
|
||||
margin-inline-start: 1rem;
|
||||
margin-top: var(--list-margin);
|
||||
margin-bottom: var(--list-margin);
|
||||
margin-left: 1rem;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
:where(li ul li, li ol li) {
|
||||
margin: var(--list-margin) 1rem;
|
||||
margin-block: var(--list-margin);
|
||||
margin-inline: 1rem;
|
||||
}
|
||||
|
||||
:where(mark) {
|
||||
@@ -54,7 +57,7 @@
|
||||
|
||||
:where(th) {
|
||||
font-weight: inherit;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
:where(th, td) {
|
||||
@@ -62,6 +65,56 @@
|
||||
border-right: 1px solid var(--table-border-color);
|
||||
border-bottom: 1px solid var(--table-border-color);
|
||||
}
|
||||
|
||||
/* RTL Support: Apply RTL direction when text is right-aligned */
|
||||
p[style*="text-align: right"],
|
||||
h1[style*="text-align: right"],
|
||||
h2[style*="text-align: right"],
|
||||
h3[style*="text-align: right"],
|
||||
h4[style*="text-align: right"],
|
||||
h5[style*="text-align: right"],
|
||||
h6[style*="text-align: right"],
|
||||
li[style*="text-align: right"],
|
||||
ul[style*="text-align: right"],
|
||||
ol[style*="text-align: right"] {
|
||||
direction: rtl;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* LTR Support: Apply LTR direction when text is left-aligned */
|
||||
p[style*="text-align: left"],
|
||||
h1[style*="text-align: left"],
|
||||
h2[style*="text-align: left"],
|
||||
h3[style*="text-align: left"],
|
||||
h4[style*="text-align: left"],
|
||||
h5[style*="text-align: left"],
|
||||
h6[style*="text-align: left"],
|
||||
li[style*="text-align: left"],
|
||||
ul[style*="text-align: left"],
|
||||
ol[style*="text-align: left"] {
|
||||
direction: ltr;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* When list items are RTL, make bullets/numbers appear on the right */
|
||||
li[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Parent list inherits direction from its items */
|
||||
ul:has(li[style*="text-align: right"]),
|
||||
ol:has(li[style*="text-align: right"]) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
ul:has(li[style*="text-align: left"]),
|
||||
ol:has(li[style*="text-align: left"]) {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
||||
.editor_content {
|
||||
@@ -70,6 +123,8 @@
|
||||
font-weight: var(--page-body-font-weight);
|
||||
line-height: var(--page-body-line-height);
|
||||
color: var(--page-text-color);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
[class*="dark"] & {
|
||||
--table-border-color: var(--color-foreground);
|
||||
|
||||
@@ -88,7 +88,7 @@ const extensions = [
|
||||
class: "rounded-md px-0.5 py-px",
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
|
||||
TableKit.configure(),
|
||||
];
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function URLInput({ value, onChange, ...props }: Props) {
|
||||
|
||||
<InputGroupInput
|
||||
value={urlValue}
|
||||
className={cn(props.className, "pl-0!")}
|
||||
className={cn(props.className, "ps-0!")}
|
||||
onChange={handleUrlChange}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -11,8 +11,8 @@ export function BreakpointIndicator({ position = "bottom-right" }: BreakpointInd
|
||||
const isBottom = position.includes("bottom");
|
||||
|
||||
const top = isTop ? "top-0" : "bottom-0";
|
||||
const right = isRight ? "right-0" : "left-0";
|
||||
const left = isLeft ? "left-0" : "right-0";
|
||||
const right = isRight ? "end-0" : "start-0";
|
||||
const left = isLeft ? "start-0" : "end-0";
|
||||
const bottom = isBottom ? "bottom-0" : "top-0";
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,4 +66,54 @@
|
||||
small {
|
||||
font-size: calc(var(--page-body-font-size) * 0.9pt);
|
||||
}
|
||||
|
||||
/* RTL Support: Apply RTL direction when text is right-aligned */
|
||||
p[style*="text-align: right"],
|
||||
h1[style*="text-align: right"],
|
||||
h2[style*="text-align: right"],
|
||||
h3[style*="text-align: right"],
|
||||
h4[style*="text-align: right"],
|
||||
h5[style*="text-align: right"],
|
||||
h6[style*="text-align: right"],
|
||||
li[style*="text-align: right"],
|
||||
ul[style*="text-align: right"],
|
||||
ol[style*="text-align: right"] {
|
||||
direction: rtl;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* LTR Support: Apply LTR direction when text is left-aligned */
|
||||
p[style*="text-align: left"],
|
||||
h1[style*="text-align: left"],
|
||||
h2[style*="text-align: left"],
|
||||
h3[style*="text-align: left"],
|
||||
h4[style*="text-align: left"],
|
||||
h5[style*="text-align: left"],
|
||||
h6[style*="text-align: left"],
|
||||
li[style*="text-align: left"],
|
||||
ul[style*="text-align: left"],
|
||||
ol[style*="text-align: left"] {
|
||||
direction: ltr;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* When list items are RTL, make bullets/numbers appear on the right */
|
||||
li[style*="text-align: right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Parent list inherits direction from its items */
|
||||
ul:has(li[style*="text-align: right"]),
|
||||
ol:has(li[style*="text-align: right"]) {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
ul:has(li[style*="text-align: left"]),
|
||||
ol:has(li[style*="text-align: left"]) {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { IconContext, type IconProps } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { ArrowRightIcon, IconContext, type IconProps, WarningIcon } from "@phosphor-icons/react";
|
||||
import { type RefObject, useMemo, useRef, useState } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { useResizeObserver } from "usehooks-ts";
|
||||
import type z from "zod";
|
||||
import { pageDimensionsAsPixels } from "@/schema/page";
|
||||
import type { pageLayoutSchema } from "@/schema/resume/data";
|
||||
import type { Template } from "@/schema/templates";
|
||||
import { sanitizeCss } from "@/utils/sanitize";
|
||||
import { cn } from "@/utils/style";
|
||||
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
|
||||
import { useCSSVariables } from "./hooks/use-css-variables";
|
||||
import { useWebfonts } from "./hooks/use-webfonts";
|
||||
import styles from "./preview.module.css";
|
||||
@@ -27,11 +32,6 @@ export type ExtendedIconProps = IconProps & {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
type Props = React.ComponentProps<"div"> & {
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
const CSS_RULE_SPLIT_PATTERN = /\n(?=\s*[.#a-zA-Z])/;
|
||||
const CSS_SELECTOR_PATTERN = /^([^{]+)(\{)/;
|
||||
|
||||
@@ -53,13 +53,17 @@ function getTemplateComponent(template: Template) {
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...props }: Props) => {
|
||||
type Props = React.ComponentProps<"div"> & {
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
export const ResumePreview = ({ showPageNumbers = false, pageClassName, className, ...props }: Props) => {
|
||||
const picture = useResumeStore((state) => state.resume.data.picture);
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
|
||||
useWebfonts(metadata.typography);
|
||||
const style = useCSSVariables({ picture, metadata });
|
||||
const totalNumberOfPages = metadata.layout.pages.length;
|
||||
|
||||
const iconProps = useMemo<ExtendedIconProps>(() => {
|
||||
return {
|
||||
@@ -94,36 +98,91 @@ export const ResumePreview = ({ showPageNumbers, pageClassName, className, ...pr
|
||||
return scoped;
|
||||
}, [metadata.css.enabled, metadata.css.value]);
|
||||
|
||||
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
|
||||
|
||||
return (
|
||||
<IconContext.Provider value={iconProps}>
|
||||
{/** biome-ignore lint/security/noDangerouslySetInnerHtml: CSS is sanitized with sanitizeCss */}
|
||||
{scopedCSS && <style dangerouslySetInnerHTML={{ __html: scopedCSS }} />}
|
||||
|
||||
<div style={style} className={cn("resume-preview-container", className)} {...props}>
|
||||
{metadata.layout.pages.map((pageLayout, pageIndex) => {
|
||||
const pageNumber = pageIndex + 1;
|
||||
|
||||
return (
|
||||
<div key={pageIndex} data-page-index={pageIndex} className="relative">
|
||||
{showPageNumbers && totalNumberOfPages > 1 && (
|
||||
<div className="absolute -top-6 left-0">
|
||||
<span className="font-medium text-foreground text-xs">
|
||||
<Trans>
|
||||
Page {pageNumber} of {totalNumberOfPages}
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
|
||||
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{metadata.layout.pages.map((pageLayout, pageIndex) => (
|
||||
<PageContainer
|
||||
key={pageIndex}
|
||||
pageIndex={pageIndex}
|
||||
pageLayout={pageLayout}
|
||||
pageClassName={pageClassName}
|
||||
showPageNumbers={showPageNumbers}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</IconContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
type PageContainerProps = {
|
||||
pageIndex: number;
|
||||
pageLayout: z.infer<typeof pageLayoutSchema>;
|
||||
pageClassName?: string;
|
||||
showPageNumbers?: boolean;
|
||||
};
|
||||
|
||||
function PageContainer({ pageIndex, pageLayout, pageClassName, showPageNumbers = false }: PageContainerProps) {
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const [pageHeight, setPageHeight] = useState<number>(0);
|
||||
|
||||
const metadata = useResumeStore((state) => state.resume.data.metadata);
|
||||
|
||||
const pageNumber = useMemo(() => pageIndex + 1, [pageIndex]);
|
||||
const maxPageHeight = useMemo(() => pageDimensionsAsPixels[metadata.page.format].height, [metadata.page.format]);
|
||||
const totalNumberOfPages = useMemo(() => metadata.layout.pages.length, [metadata.layout.pages]);
|
||||
const TemplateComponent = useMemo(() => getTemplateComponent(metadata.template), [metadata.template]);
|
||||
|
||||
useResizeObserver({
|
||||
ref: pageRef as RefObject<HTMLDivElement>,
|
||||
onResize: (size) => {
|
||||
if (!size.height) return;
|
||||
setPageHeight(size.height);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-page-index={pageIndex} className="relative">
|
||||
{showPageNumbers && totalNumberOfPages > 1 && (
|
||||
<div className="absolute start-0 -top-6 print:hidden">
|
||||
<span className="font-medium text-foreground text-xs">
|
||||
<Trans>
|
||||
Page {pageNumber} of {totalNumberOfPages}
|
||||
</Trans>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={pageRef} className={cn(`page page-${pageIndex}`, styles.page, pageClassName)}>
|
||||
<TemplateComponent pageIndex={pageIndex} pageLayout={pageLayout} />
|
||||
</div>
|
||||
|
||||
{pageHeight > maxPageHeight && (
|
||||
<div className="absolute start-0 top-full mt-4 print:hidden">
|
||||
<a
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
className="group/link"
|
||||
href="https://docs.rxresu.me/guides/fitting-content-on-a-page"
|
||||
>
|
||||
<Alert className="max-w-sm text-yellow-600">
|
||||
<WarningIcon color="currentColor" />
|
||||
<AlertTitle>
|
||||
<Trans>
|
||||
The content is too tall for this page, this may cause undesirable results when exporting to PDF.
|
||||
</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-xs underline-offset-2 group-hover/link:underline">
|
||||
<Trans>Learn more about how to fit content on a page</Trans>
|
||||
<ArrowRightIcon color="currentColor" className="ms-1 inline size-3" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ export function AwardsItem({ className, ...item }: AwardsItemProps) {
|
||||
{/* Header */}
|
||||
<div className="section-item-header awards-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title awards-item-title">{item.title}</strong>
|
||||
<span className="section-item-metadata awards-item-date text-right">{item.date}</span>
|
||||
<span className="section-item-metadata awards-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata awards-item-awarder">{item.awarder}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,13 +13,13 @@ export function CertificationsItem({ className, ...item }: CertificationsItemPro
|
||||
{/* Header */}
|
||||
<div className="section-item-header certifications-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title certifications-item-title">{item.title}</strong>
|
||||
<span className="section-item-metadata certifications-item-date text-right">{item.date}</span>
|
||||
<span className="section-item-metadata certifications-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata certifications-item-issuer">{item.issuer}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,17 +13,17 @@ export function EducationItem({ className, ...item }: EducationItemProps) {
|
||||
{/* Header */}
|
||||
<div className="section-item-header education-item-header mb-2">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title education-item-title">{item.school}</strong>
|
||||
<span className="section-item-metadata education-item-degree-grade text-right">
|
||||
<span className="section-item-metadata education-item-degree-grade shrink-0 text-end">
|
||||
{[item.degree, item.grade].filter(Boolean).join(" • ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata education-item-area">{item.area}</span>
|
||||
<span className="section-item-metadata education-item-location-period text-right">
|
||||
<span className="section-item-metadata education-item-location-period shrink-0 text-end">
|
||||
{[item.location, item.period].filter(Boolean).join(" • ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -13,15 +13,15 @@ export function ExperienceItem({ className, ...item }: ExperienceItemProps) {
|
||||
{/* Header */}
|
||||
<div className="section-item-header experience-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title experience-item-title">{item.company}</strong>
|
||||
<span className="section-item-metadata experience-item-location text-right">{item.location}</span>
|
||||
<span className="section-item-metadata experience-item-location shrink-0 text-end">{item.location}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata experience-item-position">{item.position}</span>
|
||||
<span className="section-item-metadata experience-item-period text-right">{item.period}</span>
|
||||
<span className="section-item-metadata experience-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,13 +10,15 @@ export function InterestsItem({ className, ...item }: InterestsItemProps) {
|
||||
return (
|
||||
<div className={cn("interests-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<div className="section-item-header interests-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon interests-item-icon" />
|
||||
<strong className="section-item-title interests-item-name">{item.name}</strong>
|
||||
</div>
|
||||
|
||||
{/* Keywords */}
|
||||
<span className="section-item-keywords interests-item-keywords opacity-80">{item.keywords.join(", ")}</span>
|
||||
<span className="section-item-keywords interests-item-keywords inline-block opacity-80">
|
||||
{item.keywords.join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function LanguagesItem({ className, ...item }: LanguagesItemProps) {
|
||||
return (
|
||||
<div className={cn("languages-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header flex flex-col">
|
||||
<div className="section-item-header languages-item-header flex flex-col">
|
||||
{/* Row 1 */}
|
||||
<strong className="section-item-title languages-item-name">{item.language}</strong>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ProfilesItem({ className, ...item }: ProfilesItemProps) {
|
||||
return (
|
||||
<div className={cn("profiles-item", className)}>
|
||||
{/* Header */}
|
||||
<div className="section-item-header flex items-center gap-x-1.5">
|
||||
<div className="section-item-header profiles-item-header flex items-center gap-x-1.5">
|
||||
<PageIcon icon={item.icon} className="section-item-icon profiles-item-icon" />
|
||||
<strong className="section-item-title profiles-item-network">{item.network}</strong>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,9 @@ export function ProjectsItem({ className, ...item }: ProjectsItemProps) {
|
||||
{/* Header */}
|
||||
<div className="section-item-header projects-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title projects-item-title">{item.name}</strong>
|
||||
<span className="section-item-metadata projects-item-period text-right">{item.period}</span>
|
||||
<span className="section-item-metadata projects-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ export function PublicationsItem({ className, ...item }: PublicationsItemProps)
|
||||
{/* Header */}
|
||||
<div className="section-item-header publications-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title publications-item-title">{item.title}</strong>
|
||||
<span className="section-item-metadata publications-item-date text-right">{item.date}</span>
|
||||
<span className="section-item-metadata publications-item-date shrink-0 text-end">{item.date}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata publications-item-publisher">{item.publisher}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,12 +13,12 @@ export function ReferencesItem({ className, ...item }: ReferencesItemProps) {
|
||||
{/* Header */}
|
||||
<div className="section-item-header references-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title references-item-name">{item.name}</strong>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata references-item-position">{item.position}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,9 +31,7 @@ export function ReferencesItem({ className, ...item }: ReferencesItemProps) {
|
||||
{/* Footer */}
|
||||
<div className="section-item-footer references-item-footer flex flex-col">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="section-item-metadata references-item-phone">{item.phone}</span>
|
||||
</div>
|
||||
<span className="section-item-metadata references-item-phone inline-block">{item.phone}</span>
|
||||
|
||||
{/* Row 2 */}
|
||||
<PageLink
|
||||
|
||||
@@ -17,14 +17,12 @@ export function SkillsItem({ className, ...item }: SkillsItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Proficiency */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="section-item-metadata skills-item-proficiency">{item.proficiency}</span>
|
||||
</div>
|
||||
<span className="section-item-metadata skills-item-proficiency inline-block">{item.proficiency}</span>
|
||||
|
||||
{/* Keywords */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="section-item-keywords skills-item-keywords opacity-80">{item.keywords.join(", ")}</span>
|
||||
</div>
|
||||
<span className="section-item-keywords skills-item-keywords inline-block opacity-80">
|
||||
{item.keywords.join(", ")}
|
||||
</span>
|
||||
|
||||
{/* Level */}
|
||||
<PageLevel level={item.level} className="section-item-level skills-item-level" />
|
||||
|
||||
@@ -13,13 +13,13 @@ export function VolunteerItem({ className, ...item }: VolunteerItemProps) {
|
||||
{/* Header */}
|
||||
<div className="section-item-header volunteer-item-header">
|
||||
{/* Row 1 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<strong className="section-item-title volunteer-item-title">{item.organization}</strong>
|
||||
<span className="section-item-metadata volunteer-item-period text-right">{item.period}</span>
|
||||
<span className="section-item-metadata volunteer-item-period shrink-0 text-end">{item.period}</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-start justify-between gap-x-2">
|
||||
<span className="section-item-metadata volunteer-item-location">{item.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { create } from "zustand/react";
|
||||
import { orpc, type RouterOutput } from "@/integrations/orpc/client";
|
||||
import type { ResumeData } from "@/schema/resume/data";
|
||||
|
||||
type Resume = Omit<RouterOutput["resume"]["getById"], "hasPassword">;
|
||||
type Resume = Pick<RouterOutput["resume"]["getByIdForPrinter"], "id" | "name" | "slug" | "tags" | "data" | "isLocked">;
|
||||
|
||||
type ResumeStoreState = {
|
||||
resume: Resume;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EnvelopeIcon, GlobeIcon, MapPinIcon, PhoneIcon } from "@phosphor-icons/react";
|
||||
import { isRTL } from "@/utils/locale";
|
||||
import { cn } from "@/utils/style";
|
||||
import { getSectionComponent } from "../shared/get-section-component";
|
||||
import { PageIcon } from "../shared/page-icon";
|
||||
@@ -7,61 +8,68 @@ import { PagePicture } from "../shared/page-picture";
|
||||
import { useResumeStore } from "../store/resume";
|
||||
import type { TemplateProps } from "./types";
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Heading Decoration in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:px-4",
|
||||
"group-data-[layout=sidebar]:[&>h6]:relative",
|
||||
"group-data-[layout=sidebar]:[&>h6]:inline-flex",
|
||||
"group-data-[layout=sidebar]:[&>h6]:items-center",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:left-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border-(--page-primary-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:right-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border-(--page-primary-color)",
|
||||
|
||||
// Section in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
|
||||
// Section in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:relative",
|
||||
"group-data-[layout=main]:[&>.section-content]:ml-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:pl-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-l",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-(--page-primary-color)",
|
||||
|
||||
// Timeline Marker in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:after:content-['']",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:absolute",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:top-5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:left-0",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:size-2.5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-x-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-y-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:rounded-full",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border-(--page-primary-color)",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:bg-(--page-background-color)",
|
||||
);
|
||||
|
||||
/**
|
||||
* Template: Azurill
|
||||
*/
|
||||
export function AzurillTemplate({ pageIndex, pageLayout }: TemplateProps) {
|
||||
const isFirstPage = pageIndex === 0;
|
||||
const { main, sidebar, fullWidth } = pageLayout;
|
||||
const locale = useResumeStore((state) => state.resume.data.metadata.page.locale);
|
||||
const rtlDirection = isRTL(locale);
|
||||
|
||||
const sectionClassName = cn(
|
||||
// Heading Decoration in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&>h6]:px-4",
|
||||
"group-data-[layout=sidebar]:[&>h6]:relative",
|
||||
"group-data-[layout=sidebar]:[&>h6]:inline-flex",
|
||||
"group-data-[layout=sidebar]:[&>h6]:items-center",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:start-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:before:border-(--page-primary-color)",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:content-['']",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:absolute",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:end-0",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:rounded-full",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:size-2",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border",
|
||||
"group-data-[layout=sidebar]:[&>h6]:after:border-(--page-primary-color)",
|
||||
|
||||
// Section in Sidebar Layout
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:flex-col",
|
||||
"group-data-[layout=sidebar]:[&_.section-item-header>div]:items-start",
|
||||
|
||||
// Section in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:relative",
|
||||
"group-data-[layout=main]:[&>.section-content]:ms-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:ps-4",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-s",
|
||||
"group-data-[layout=main]:[&>.section-content]:border-(--page-primary-color)",
|
||||
|
||||
// Timeline Marker in Main Layout
|
||||
"group-data-[layout=main]:[&>.section-content]:after:content-['']",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:absolute",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:top-5",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:start-0",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:size-2.5",
|
||||
rtlDirection
|
||||
? "group-data-[layout=main]:[&>.section-content]:after:[transform:translate(50%,-50%)]"
|
||||
: "group-data-[layout=main]:[&>.section-content]:after:translate-x-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:translate-y-[-50%]",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:rounded-full",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:border-(--page-primary-color)",
|
||||
"group-data-[layout=main]:[&>.section-content]:after:bg-(--page-background-color)",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) pt-(--page-margin-y) print:p-0">
|
||||
<div
|
||||
className="template-azurill page-content space-y-(--page-gap-y) px-(--page-margin-x) py-(--page-margin-y) print:p-0"
|
||||
style={{ direction: rtlDirection ? "rtl" : "ltr" }}
|
||||
>
|
||||
{isFirstPage && <Header />}
|
||||
|
||||
<div className="flex gap-x-(--page-gap-x)">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user