From e65ff8680c6b5b7a9ef6e987b325af6cd5dfd4bd Mon Sep 17 00:00:00 2001 From: Gautam25Raj Date: Fri, 29 May 2026 18:00:10 +0530 Subject: [PATCH] feat: enhance resume editor settings and import functionality - Updated SettingControls component to improve layout and hint display. - Refactored SettingsSelect to use a custom select element with a dropdown icon. - Enhanced SettingsRange to visually represent range progress and display current value. - Improved SettingsColor to show selected color and its hex value. - Added resume-markdown-import service for importing resumes from markdown files. - Integrated markdown import functionality into resume service. - Updated cover letter templates to conditionally render sections based on visibility settings. - Introduced isCoverLetterSectionVisible utility to manage section visibility in cover letters. --- CONTRIBUTING.md | 91 ++-- README.md | 210 +++++---- SECURITY.md | 4 +- apps/blog-platform/package.json | 2 +- .../content/docs/contributing/index.mdx | 37 +- apps/docs-platform/package.json | 2 +- apps/server/package.json | 2 +- apps/site/package.json | 2 +- .../components/DocumentActionsMenu.tsx | 22 +- apps/studio/app/(main)/editor/layout.tsx | 2 +- apps/studio/app/globals.css | 109 +++-- .../components/dashboard/AccountMenu.tsx | 32 +- .../components/dashboard/StudioShell.tsx | 3 +- apps/studio/features/cover-letter/defaults.ts | 1 + .../cover-letter/editor/CoverLetterEditor.tsx | 24 ++ .../components/CoverLetterContentPanel.tsx | 407 ++++++++++-------- .../editor/components/CoverLetterFields.tsx | 62 +-- .../components/CoverLetterSettingsPanel.tsx | 188 +++++--- .../editor/components/CoverLetterToolbar.tsx | 38 +- .../features/cover-letter/markdown-import.ts | 122 ++++++ apps/studio/features/cover-letter/schema.ts | 16 +- apps/studio/features/cover-letter/types.ts | 4 +- .../documents/editor/DocumentEditorShell.tsx | 397 +++++++++++------ .../editor/DocumentTemplatePickerModal.tsx | 188 ++++++++ .../editor/toolbar/ToolbarActionsMenu.tsx | 26 +- .../editor/toolbar/ToolbarDownloadMenu.tsx | 4 +- .../editor/toolbar/ToolbarHeader.tsx | 9 +- .../documents/export/export-markdown.ts | 5 + .../resume/editor/EditorContentPanel.tsx | 93 +--- .../resume/editor/EditorSettingsPanel.tsx | 158 ++++--- .../features/resume/editor/ResumeEditor.tsx | 11 +- .../resume/editor/ResumePagedPreview.tsx | 115 +++++ .../features/resume/editor/ResumeToolbar.tsx | 59 ++- .../editor/content/SectionAccordion.tsx | 38 +- .../content/sections/DraggableSection.tsx | 9 +- .../content/sections/GenericCustomSection.tsx | 57 +-- .../editor/settings/AdvancedThemeSettings.tsx | 21 +- .../settings/SectionVisibilitySettings.tsx | 71 ++- .../editor/settings/SettingControls.tsx | 92 +++- .../toolbar/ToolbarSecondaryActions.tsx | 7 +- .../resume/services/resume-markdown-import.ts | 283 ++++++++++++ .../resume/services/resume-service.ts | 2 + apps/studio/package.json | 2 +- .../cover-letter/professional/pdf.tsx | 61 ++- .../cover-letter/professional/web.tsx | 43 +- apps/studio/templates/cover-letter/shared.ts | 72 +++- .../templates/cover-letter/veriworkly/pdf.tsx | 76 ++-- .../templates/cover-letter/veriworkly/web.tsx | 109 +++-- package.json | 2 +- 49 files changed, 2262 insertions(+), 1128 deletions(-) create mode 100644 apps/studio/features/cover-letter/markdown-import.ts create mode 100644 apps/studio/features/documents/editor/DocumentTemplatePickerModal.tsx create mode 100644 apps/studio/features/resume/editor/ResumePagedPreview.tsx create mode 100644 apps/studio/features/resume/services/resume-markdown-import.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2ebd59..b087aba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,11 +10,21 @@ First off, thank you for considering contributing to **VeriWorkly**! We are buil --- -## โšก Quick TL;DR Contribution Workflow +## โšก Contribution Workflow -If you just need a quick heads-up on the commands and branching protocol: +Follow these steps to contribute code or documentation to VeriWorkly: -### 1. Fork & Clone +### 1. Star the Repository ๐ŸŒŸ + +Before getting started, please **star our repository** to show your support for the project! + +### 2. Claim an Issue ๐Ÿ“‹ + +- Browse our open issues. +- If you find an issue you want to work on, comment on it expressing your interest and wait to be officially assigned by a maintainer. +- โš ๏ธ **Do not start coding or open a Pull Request before being assigned to the issue.** Unassigned PRs or PRs targeting unclaimed issues may be closed without review to avoid duplicate work. + +### 3. Fork & Clone ๐Ÿด 1. Fork the [original repository](https://github.com/VeriWorkly/veriworkly). 2. Clone your fork locally: @@ -27,77 +37,56 @@ If you just need a quick heads-up on the commands and branching protocol: git remote add upstream https://github.com/VeriWorkly/veriworkly.git ``` -### 2. Branching Policy +### 4. Branching Policy ๐ŸŒฟ -- **Base Branch**: PRs must be based on and target the **`master`** branch. -- **Update your branch** before starting: +- PRs must be based on and target the **`master`** branch. +- Keep your fork in sync and create a descriptive feature branch: ```bash git checkout master git pull upstream master git checkout -b feat/your-feature-name ``` +- **Branch Naming Convention**: + - `feat/feature-name` (new features/enhancements) + - `fix/bug-name` (bug fixes) + - `docs/doc-update` (documentation changes) + - `refactor/scope-of-work` (restructuring/refactoring code) -### 3. Local Run Commands +### 5. Local Setup & Verification ๐Ÿ› ๏ธ - Install dependencies: `npm install` - Copy environment variables: `cp .env.example .env` and `cp apps/server/.env.example apps/server/.env` -- Choose your dev flow: - - **Frontend-only (Site/Templates at port 3000)**: `npm run dev` _(No database or backend server needed)_ +- Run the app locally to test your changes: + - **Frontend-only (Site/Templates at port 3000)**: `npm run dev` - **Full-stack (All apps/databases)**: `npm run dev:all` _(Requires running `npm run db:push -w @veriworkly/server`)_ -### 4. Code Quality - -Run checks before committing: - -```bash - npm run dev:all -``` - --- -## ๐ŸŒฟ Branching Policy +## ๐Ÿ“ Pull Request Guidelines -- `master`: Active development, integration, and production branch. **Base your PRs here.** +To ensure smooth reviews and fast merges, please adhere to these PR guidelines: -### Branch Naming Convention +### 1. PR Title Naming Convention -- `feat/feature-name` -- `fix/bug-name` -- `docs/doc-update` -- `refactor/scope-of-work` +PR titles **must** follow the format: ---- - -## ๐Ÿ› ๏ธ Development Guidelines - -### 1. Architecture - -We use a **Monorepo** structure. - -- **apps/site**: Marketing landing site. -- **apps/studio**: Builder studio application. -- **apps/server**: Express API. -- **apps/docs-platform**: Documentation (Fumadocs). -- **packages/ui**: Shared Design System. +``` +[Type] [App/Component]: +``` -### 2. Coding Standards +- **Type**: `[Fix]`, `[Feature]`, `[Refactor]`, `[Docs]`, `[Chore]` +- **App/Component**: `[Studio]`, `[Server]`, `[Site]`, `[UI]`, etc. +- **Example**: `[Fix] [Studio]: hide auth-only actions in account menu for anonymous users` -- **TypeScript**: Mandatory for all new code. -- **Linting**: Run `npm run lint` before committing. -- **Formatting**: We use Prettier. Run `npm run format:write`. +### 2. Linking Issues ---- +- You **must link the issue** your PR addresses in the PR description using the hashtag format (e.g., `Fixes #123` or `Closes #123`). This allows GitHub to automatically associate and close the issue upon merge. -## ๐Ÿ“ Pull Request Process +### 3. Complete the PR Template & Checklist -1. **Create an Issue**: Discuss large changes before starting work. -2. **Submit PR**: Open a PR against the `master` branch. -3. **Checklist**: - - [ ] Lint passes (`npm run lint`) - - [ ] Prettier formatting succeeds (`npm run format:write`) - - [ ] Code builds successfully (`npm run build`) - - [ ] Tests pass (`npm test`) - - [ ] Documentation updated (if applicable) +- When opening a PR, fill out the template fully. +- Complete the checkbox checklist in the PR body by replacing `[ ]` with `[x]` for items you have done (e.g., linting, formatting, tests). +- โš ๏ธ **PRs with empty, uncompleted checklists will not be reviewed.** --- diff --git a/README.md b/README.md index 1b1eb1a..19c018e 100644 --- a/README.md +++ b/README.md @@ -1,181 +1,201 @@
- VeriWorkly Resume + VeriWorkly Resume -

VeriWorkly Resume

+
+
-

Professional, privacy-centric, and open-source resume engineering platform.

+

๐Ÿš€ VeriWorkly Resume

+ +

Professional, privacy-first, and open-source career document engineering platform.

- Main Application + โœจ Main Application ยท - Documentation + ๐Ÿ“– Documentation ยท - Official Blog + ๐Ÿ“ฐ Official Blog ยท - Product Roadmap + ๐Ÿ—บ๏ธ Product Roadmap

- Version - Stars - License + Version + Stars + License

--- -## Executive Summary +## ๐ŸŽฏ Executive Summary + +VeriWorkly is a **high-performance, privacy-centric resume building ecosystem** designed to challenge the traditional surveillance-heavy SaaS resume builder model. Operating on the **Local-First principle**, VeriWorkly stores all your career data directly in your browser. It combines a state-of-the-art Next.js frontend with a lightweight Node.js/Express backend to provide a seamless, secure, and professional document generation experience. -VeriWorkly is a **high-performance, privacy-centric resume building ecosystem** that challenges the traditional SaaS resume builder model. Unlike competitors that require accounts and store sensitive career data on remote servers, VeriWorkly operates on a **Local-First principle**, combining a state-of-the-art Next.js frontend with a robust Node.js/Express backend to provide a seamless, secure, and professional experience. +--- -The platform empowers users to: +## โœจ Key Capabilities -- **Build & Edit** professional resumes in real-time with instant visual feedback -- **Export** in multiple formats (ATS-optimized PDF, editable DOCX) with pixel-perfect accuracy -- **Manage** their career data locally without surveillance or tracking -- **Sync** securely to the cloud when they choose to collaborate or access across devices -- **Integrate** with external tools through a fully documented OpenAPI specification +- **โšก Real-Time Rendering**: Edit details and see your ATS-optimized resume update instantly with pixel-perfect visual previews. +- **๐Ÿ”’ Local-First Storage**: Your data stays on your machine. No mandatory accounts, tracking cookies, or remote server locks. +- **๐Ÿ“ฅ Universal Exports**: One-click downloads to ATS-optimized PDF and editable DOCX formats. +- **โ˜๏ธ Optional Cloud Sync**: Secure, end-to-end synchronized account support for access across multiple devices. +- **๐Ÿ”ง API Extensibility**: Fully integrated and documented OpenAPI specification to plug in external tools. -All while maintaining **100% open-source transparency** and enabling self-hosting for enterprises. +--- -## Templates +## ๐ŸŽจ Premium Templates ### Resume Templates - +
- -
- Precision ATS -
Precision ATS +
+ Precision ATS +
Precision ATS
- Executive Clarity -
Executive Clarity +
+ Executive Clarity +
Executive Clarity
### Cover Letter Templates - +
- -
- Professional -
Professional +
+ Professional +
Professional Classic
- Veriworkly Special -
Veriworkly Special +
+ Veriworkly Special +
VeriWorkly Special
-## Architecture and Technology Stack +--- -VeriWorkly utilizes a modern, type-safe monorepo architecture to ensure service isolation and scalability. +## โš™๏ธ Architecture & Tech Stack -| Component | Technology | -| :------------------- | :--------------------------------------------- | -| **Frontend** | Next.js (App Router), React 19, Tailwind CSS 4 | -| **Backend API** | Node.js, Express | -| **Data Persistence** | PostgreSQL (Prisma ORM) | -| **Rendering Engine** | react-pdf (Client-side document generation) | -| **Authentication** | Better-Auth (Passwordless OTP) | -| **State Management** | Zustand (with persistence) | +VeriWorkly uses a type-safe **monorepo** layout to ensure clean service isolation and high developer velocity. -## Repository Structure +``` +veriworkly-resume/ +โ”œโ”€โ”€ apps/ +โ”‚ โ”œโ”€โ”€ site/ # Marketing & Landing Site (Next.js) +โ”‚ โ”œโ”€โ”€ studio/ # Dynamic Builder App & Workspace (Next.js) +โ”‚ โ”œโ”€โ”€ server/ # Express API & Sync backend (NodeJS) +โ”‚ โ”œโ”€โ”€ docs-platform/ # Technical Documentation Hub (Fumadocs) +โ”‚ โ””โ”€โ”€ blog-platform/ # Official Product Blog (Next.js) +โ””โ”€โ”€ packages/ + โ””โ”€โ”€ ui/ # Shared UI Design System & Component Library +``` + +| Layer | Technologies Used | +| :------------------------ | :------------------------------------------------ | +| **Frontend Applications** | Next.js 15 (App Router), React 19, Tailwind CSS 4 | +| **Backend API** | Node.js, Express, TypeScript | +| **Data Persistence** | PostgreSQL (Prisma ORM) | +| **Rendering Pipeline** | react-pdf (Client-side high-fidelity generation) | +| **Authentication** | Better-Auth (Passwordless OTP) | +| **State Management** | Zustand (with localStorage persistence) | -The project is organized into independent applications and shared packages: +--- -- **`apps/site`**: The landing page and marketing site. -- **`apps/studio`**: The primary user interface for resume management and building. -- **`apps/server`**: Centralized API service handling auth and sync. -- **`apps/docs-platform`**: Technical and user documentation (powered by Fumadocs). -- **`apps/blog-platform`**: Official product communications and career guides. -- **`packages/ui`**: Shared design system and component library. +## ๐Ÿš€ Quick Start Guide -## Deployment and Development +### Local Development Setup -Detailed technical documentation is available at [docs.veriworkly.com](https://docs.veriworkly.com). +To run VeriWorkly locally on your system, follow these commands: -### Quick Start (Local Development) +1. **Clone the Repo & Install Dependencies** -1. **Initialize Workspace**: ```bash + git clone https://github.com/VeriWorkly/veriworkly.git + cd veriworkly-resume npm install ``` -2. **Environment Configuration**: + +2. **Configure Environment Variables** + ```bash cp .env.example .env cp apps/server/.env.example apps/server/.env ``` -3. **Database Migration**: + +3. **Deploy Local Database migrations** + ```bash npm run db:push -w @veriworkly/server ``` -4. **Launch Services**: + +4. **Launch Dev Environment** ```bash npm run dev ``` -### Quick Start (Docker) +### Running with Docker -Deploy the entire ecosystem using our optimized Docker Compose configuration: +Deploy the complete ecosystem (database, backend server, and frontend client) instantly via Docker Compose: ```bash docker compose --env-file .env.docker up -d --build ``` -## Documentation Index +--- -| Resource | Scope | -| :-------------------------------------------------------------------------------------- | :------------------------------------------- | -| [Technical Documentation](https://docs.veriworkly.com) | Architecture, API Reference, and Deployment. | -| [User Support](https://docs.veriworkly.com/docs/user-guides/creating-your-first-resume) | Guides for building and managing resumes. | -| [Local Setup Guide](README.Local.md) | Detailed manual installation instructions. | -| [Docker Deployment](README.Docker.md) | Production self-hosting instructions. | -| [Contributing Guidelines](CONTRIBUTING.md) | Standards and protocols for contributors. | +## ๐Ÿ“– Documentation Directory -## Security & Privacy +| Resource | Description | Location | +| :-------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------- | +| **Technical Documentation** | Monorepo architecture, API Reference, deployment. | [docs.veriworkly.com](https://docs.veriworkly.com) | +| **User Support & Guides** | Walkthroughs for editing, building and templates. | [User Guides Hub](https://docs.veriworkly.com/docs/user-guides) | +| **Manual Setup Guide** | Standard local environment installation. | [README.Local.md](README.Local.md) | +| **Docker Operations** | Configuration, environment variables, orchestration. | [README.Docker.md](README.Docker.md) | +| **Contributing Guide** | Repository protocols, standards, and git guidelines. | [CONTRIBUTING.md](CONTRIBUTING.md) | -### Data Protection +--- -- **Local-First**: Resume data stored locally in browser by default -- **Encryption**: All data encrypted in transit (HTTPS) and at rest (database) -- **No Tracking**: Zero analytics or tracking of user behavior -- **Open Source**: Code transparency enables community security audits +## ๐Ÿค Contributing -### Reporting Vulnerabilities +VeriWorkly is built on open-source principles, and we welcome community contributions! -Please do NOT open GitHub issues for security vulnerabilities. Instead, email info@veriworkly.com with: +> [!IMPORTANT] +> Before checking out a branch or creating a pull request, please review our full **[Contributing Guidelines](CONTRIBUTING.md)**. +> +> 1. ๐ŸŒŸ **Star the repository** to show your support. +> 2. ๐Ÿ“‹ **Claim an issue** by commenting on it and waiting to be assigned before starting work. +> 3. ๐Ÿ“ Ensure your **PR titles** follow the standard naming convention. -- Description of the vulnerability -- Steps to reproduce -- Potential impact assessment +### Ways to Help Out -For complete security policy, see [SECURITY.md](SECURITY.md) +1. **Code**: Implement new features, performance improvements, or address open issues. +2. **Design**: Build and submit new ATS-optimized resume templates. +3. **Docs**: Refine explanations, fix typos, or add code setup examples. +4. **Feedback**: File bugs or suggest future features on our product roadmap. -## Contributing +--- -VeriWorkly is an open-source project and welcomes community contributions. Please review our [Contributing Guide](CONTRIBUTING.md) before submitting Pull Requests. +## ๐Ÿ”’ Security & Privacy -### Ways to Contribute +We take security and user data privacy very seriously: -1. **Code**: Submit bug fixes, features, or performance improvements -2. **Design**: Contribute new resume templates -3. **Documentation**: Improve guides, add examples, fix typos -4. **Translation**: Help localize content to other languages -5. **Feedback**: Report bugs, suggest features on the roadmap -6. **Sponsorship**: Support the project financially +- **Local-First Architecture**: Your resumes reside locally in your browser storage. +- **Zero Behaviors Tracking**: We collect no user analytics, mouse tracking, or heatmaps. +- **Vulnerability Disclosure**: If you discover a security issue, please do **not** file a public GitHub issue. Email us privately at `info@veriworkly.com` with steps to reproduce. Read [SECURITY.md](SECURITY.md) for more info. -## License +--- -VeriWorkly is released under the **MIT License**. See [LICENSE](LICENSE) file for full details. +## ๐Ÿ“„ License -## โค๏ธ Built With Love +VeriWorkly is released under the [MIT License](LICENSE). -VeriWorkly is built by a community of developers passionate about simplifying career building and protecting user privacy. Every line of code reflects our commitment to transparency, security, and user empowerment. +--- -**Made with โค๏ธ by [VeriWorkly Team](https://veriworkly.com) and [Contributors](https://github.com/VeriWorkly/veriworkly/graphs/contributors)** +
+

Made with โค๏ธ by the VeriWorkly Team and our amazing community of contributors

+
diff --git a/SECURITY.md b/SECURITY.md index 905c125..85f8dc3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,8 +10,8 @@ We provide security updates for the latest stable release. | Version | Supported | | ------- | --------- | -| โ‰ฅ 1.0.0 | โœ… Yes | -| < 1.0.0 | โŒ No | +| โ‰ฅ 3.0.0 | โœ… Yes | +| < 3.0.0 | โŒ No | > Only the most recent major version is actively maintained. diff --git a/apps/blog-platform/package.json b/apps/blog-platform/package.json index 686f5ac..b1b4c19 100644 --- a/apps/blog-platform/package.json +++ b/apps/blog-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/blog-platform", - "version": "3.8.0", + "version": "3.10.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/docs-platform/content/docs/contributing/index.mdx b/apps/docs-platform/content/docs/contributing/index.mdx index 8ded844..41befb3 100644 --- a/apps/docs-platform/content/docs/contributing/index.mdx +++ b/apps/docs-platform/content/docs/contributing/index.mdx @@ -13,25 +13,26 @@ First off, thank you for considering contributing to **VeriWorkly**! We are buil To ensure a smooth collaboration, please follow these steps to set up your contribution environment: -1. **Fork the repository** on GitHub by clicking the **Fork** button on the [VeriWorkly Repository](https://github.com/VeriWorkly/veriworkly). -2. **Clone your personal fork** to your local machine: +1. **Star the Repository** ๐ŸŒŸ: Before getting started, please star the [VeriWorkly Repository](https://github.com/VeriWorkly/veriworkly) to show your support for this open-source project! +2. **Claim an Issue** ๐Ÿ“‹: Browse our open issues. Comment on the one you would like to work on and wait to be officially assigned by a maintainer. **Please do not start coding or open a PR until you are officially assigned to the issue** to avoid duplicate efforts. +3. **Fork the repository** on GitHub by clicking the **Fork** button on the [VeriWorkly Repository](https://github.com/VeriWorkly/veriworkly). +4. **Clone your personal fork** to your local machine: ```bash git clone https://github.com/YOUR_USERNAME/veriworkly.git cd veriworkly-resume ``` -3. **Configure Upstream Remote**: +5. **Configure Upstream Remote**: Keep your fork in sync with the main project by tracking the original repository: ```bash git remote add upstream https://github.com/VeriWorkly/veriworkly.git ``` -4. **Set up your local environment** (environment variables, dependencies) following our detailed [Local Setup Guide](/docs/getting-started/local-setup). -5. **Sync with Upstream Master Branch**: +6. **Sync with Upstream Master Branch**: Before creating a branch, always ensure your local `master` branch is up to date: ```bash git checkout master git pull upstream master ``` -6. **Create a branch** for your work, using our branching convention: +7. **Create a branch** for your work, using our branching convention: ```bash git checkout -b feat/your-feature-name ``` @@ -42,7 +43,7 @@ To ensure a smooth collaboration, please follow these steps to set up your contr ### 1. Code Contributions -We love new features and bug fixes! Check our [GitHub Issues](https://github.com/VeriWorkly/veriworkly/issues) for "good first issue" labels if you're new to the project. +We love new features and bug fixes! Check our [GitHub Issues](https://github.com/VeriWorkly/veriworkly/issues) for "good first issue" labels if you're new to the project. Remember to claim the issue first! ### 2. Design & Templates @@ -95,17 +96,19 @@ npm test ### Pull Request Process -1. **Discuss Major Changes First**: For significant enhancements, please create an issue first to discuss the design pattern with the maintainers. -2. **Push to Your Fork**: - ```bash - git push origin feat/your-feature-name +1. **PR Title Naming Convention**: + PR titles **must** follow the format: + + ``` + [Type] [App/Component]: ``` -3. **Open Pull Request**: Navigate to the VeriWorkly main repository and create a Pull Request. Ensure the target branch is set to **`master`**. -4. **Follow the PR Template**: Make sure to describe the modifications, link the relevant issue, and check all boxes in the PR template: - - [ ] Verified build succeeds locally (`npm run build`) - - [ ] Linter validation passes (`npm run lint`) - - [ ] Prettier formatting succeeds (`npm run format`) - - [ ] Local tests pass successfully (`npm test`) + + - **Type**: `[Fix]`, `[Feature]`, `[Refactor]`, `[Docs]`, `[Chore]` + - **App/Component**: `[Studio]`, `[Server]`, `[Site]`, `[UI]`, etc. + - **Example**: `[Fix] [Studio]: hide auth-only actions in account menu for anonymous users` + +2. **Link the Issue**: You **must link the issue** your PR addresses in the PR description using the hashtag format (e.g., `Fixes #123` or `Closes #123`). This automatically links and closes the issue on merge. +3. **Complete the PR Template & Checklist**: Describe the modifications and check all boxes in the PR template by changing `[ ]` to `[x]`. **PRs with empty or unchecked checklists will not be reviewed.** --- diff --git a/apps/docs-platform/package.json b/apps/docs-platform/package.json index 36569b9..28dbf6a 100644 --- a/apps/docs-platform/package.json +++ b/apps/docs-platform/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/docs-platform", - "version": "3.8.0", + "version": "3.10.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/server/package.json b/apps/server/package.json index 504d7c6..c2b1811 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/server", - "version": "3.8.0", + "version": "3.10.0", "description": "VeriWorkly Resume Backend API", "main": "dist/index.js", "type": "module", diff --git a/apps/site/package.json b/apps/site/package.json index 12e00d0..94f88b3 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -1,6 +1,6 @@ { "name": "@veriworkly/site", - "version": "3.8.0", + "version": "3.10.0", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx index 085c402..1b88fbd 100644 --- a/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx +++ b/apps/studio/app/(main)/(dashboard)/documents/components/DocumentActionsMenu.tsx @@ -134,17 +134,17 @@ export function DocumentActionsMenu({ View sync details - { - close(); - void navigator.clipboard.writeText(`${window.location.origin}${editorPath}`); - toast.success("Editor Link Copied"); - }} - > - - Copy Editor Link - + { + close(); + void navigator.clipboard.writeText(`${window.location.origin}${editorPath}`); + toast.success("Editor Link Copied"); + }} + > + + Copy Editor Link + diff --git a/apps/studio/app/(main)/editor/layout.tsx b/apps/studio/app/(main)/editor/layout.tsx index f583043..09e32b0 100644 --- a/apps/studio/app/(main)/editor/layout.tsx +++ b/apps/studio/app/(main)/editor/layout.tsx @@ -6,7 +6,7 @@ export default function EditorRouteLayout({ children }: { children: ReactNode }) return ( <> -
{children}
+
{children}
); } diff --git a/apps/studio/app/globals.css b/apps/studio/app/globals.css index 1f84b9f..b5805db 100644 --- a/apps/studio/app/globals.css +++ b/apps/studio/app/globals.css @@ -30,6 +30,56 @@ body { font-family: var(--font-geist-sans), sans-serif; } +.editor-range { + width: 100%; + height: 2.5rem; + cursor: pointer; + appearance: none; + background: transparent; +} + +.editor-range::-webkit-slider-runnable-track { + height: 0.45rem; + border-radius: 999px; + background: linear-gradient( + to right, + var(--accent) 0 var(--range-progress), + color-mix(in oklab, var(--border) 75%, transparent) var(--range-progress) 100% + ); +} + +.editor-range::-webkit-slider-thumb { + width: 1.1rem; + height: 1.1rem; + margin-top: -0.325rem; + appearance: none; + border: 3px solid var(--card); + border-radius: 999px; + background: var(--accent); + box-shadow: 0 4px 14px color-mix(in oklab, var(--foreground) 18%, transparent); +} + +.editor-range::-moz-range-track { + height: 0.45rem; + border-radius: 999px; + background: color-mix(in oklab, var(--border) 75%, transparent); +} + +.editor-range::-moz-range-progress { + height: 0.45rem; + border-radius: 999px; + background: var(--accent); +} + +.editor-range::-moz-range-thumb { + width: 0.8rem; + height: 0.8rem; + border: 3px solid var(--card); + border-radius: 999px; + background: var(--accent); + box-shadow: 0 4px 14px color-mix(in oklab, var(--foreground) 18%, transparent); +} + .resume-page-preview { position: relative; overflow: hidden; @@ -51,62 +101,3 @@ body { overflow-wrap: anywhere; word-break: break-word; } - -.resume-page-preview::before, -.resume-page-preview::after { - position: absolute; - inset: 0; - z-index: 1; - pointer-events: none; - content: ""; -} - -.resume-page-preview::before { - background: - repeating-linear-gradient( - to bottom, - transparent 0, - transparent calc(var(--resume-page-height) - 2px), - color-mix(in oklab, var(--border) 80%, transparent) calc(var(--resume-page-height) - 2px), - color-mix(in oklab, var(--border) 80%, transparent) var(--resume-page-height) - ) - left top / var(--resume-page-margin) 100% repeat-y, - repeating-linear-gradient( - to bottom, - transparent 0, - transparent calc(var(--resume-page-height) - 2px), - color-mix(in oklab, var(--border) 80%, transparent) calc(var(--resume-page-height) - 2px), - color-mix(in oklab, var(--border) 80%, transparent) var(--resume-page-height) - ) - right top / var(--resume-page-margin) 100% repeat-y; - background-repeat: repeat-y; -} - -.resume-page-preview::after { - background: - repeating-linear-gradient( - to bottom, - transparent 0, - transparent calc(var(--resume-page-height) - var(--resume-page-margin) - 1px), - color-mix(in oklab, var(--accent) 42%, transparent) - calc(var(--resume-page-height) - var(--resume-page-margin) - 1px), - color-mix(in oklab, var(--accent) 42%, transparent) - calc(var(--resume-page-height) - var(--resume-page-margin)), - transparent calc(var(--resume-page-height) - var(--resume-page-margin)), - transparent var(--resume-page-height) - ) - left top / var(--resume-page-margin) 100% repeat-y, - repeating-linear-gradient( - to bottom, - transparent 0, - transparent calc(var(--resume-page-height) - var(--resume-page-margin) - 1px), - color-mix(in oklab, var(--accent) 42%, transparent) - calc(var(--resume-page-height) - var(--resume-page-margin) - 1px), - color-mix(in oklab, var(--accent) 42%, transparent) - calc(var(--resume-page-height) - var(--resume-page-margin)), - transparent calc(var(--resume-page-height) - var(--resume-page-margin)), - transparent var(--resume-page-height) - ) - right top / var(--resume-page-margin) 100% repeat-y; - background-repeat: repeat-y; -} diff --git a/apps/studio/components/dashboard/AccountMenu.tsx b/apps/studio/components/dashboard/AccountMenu.tsx index 5d367a1..d926732 100644 --- a/apps/studio/components/dashboard/AccountMenu.tsx +++ b/apps/studio/components/dashboard/AccountMenu.tsx @@ -1,10 +1,8 @@ -"use client"; - +import { useTheme } from "next-themes"; import { useEffect, useRef, useState } from "react"; -import { ChevronDown, LogOut, Moon, Settings, Sun, User } from "lucide-react"; +import { ChevronDown, LogOut, Moon, Sun, User } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useTheme } from "next-themes"; export function AccountMenu({ collapsed, @@ -12,7 +10,6 @@ export function AccountMenu({ email, version, onProfile, - onSettings, onLogout, }: { collapsed: boolean; @@ -20,7 +17,6 @@ export function AccountMenu({ email: string; version: string; onProfile: () => void; - onSettings: () => void; onLogout: () => void; }) { const { resolvedTheme, setTheme } = useTheme(); @@ -65,6 +61,7 @@ export function AccountMenu({ {displayName.slice(0, 1).toUpperCase()} + {displayName} {email} @@ -79,14 +76,7 @@ export function AccountMenu({ onProfile(); }} /> - { - close(); - onSettings(); - }} - /> + { + onClick={() => { close(); - await onLogout(); + onLogout(); }} /> @@ -113,24 +103,26 @@ export function AccountMenu({ + ))} + + + + + + + + + onUpdateContent({ jobTitle })} + /> - onUpdateLink(index, { url })} - /> - - - - ))} - - - - - - - onUpdateContent({ jobTitle })} - /> - - onUpdateContent({ companyName })} - /> - -
onUpdateContent({ recipientName })} + label="Company" + value={content.companyName} + onChange={(companyName) => onUpdateContent({ companyName })} /> +
+ onUpdateContent({ recipientName })} + /> + + onUpdateContent({ recipientTitle })} + /> +
+ onUpdateContent({ recipientTitle })} + label="Company location" + value={content.companyLocation} + onChange={(companyLocation) => onUpdateContent({ companyLocation })} /> -
- - onUpdateContent({ companyLocation })} - /> - - onUpdateContent({ date })} /> -
- - - onUpdateContent({ subject })} - /> - - onUpdateContent({ greeting })} - /> - - onUpdateContent({ opening })} - /> - - onUpdateContent({ body })} - /> - - onUpdateContent({ highlights })} - /> - -
+ + onUpdateContent({ date })} /> + + + + + onUpdateContent({ closing })} + label="Subject" + value={content.subject} + placeholder="Application for Senior Product Engineer" + onChange={(subject) => onUpdateContent({ subject })} + /> + + onUpdateContent({ greeting })} + /> + + onUpdateContent({ opening })} + /> + + onUpdateContent({ body })} /> + onUpdateContent({ highlights })} + /> + +
+ onUpdateContent({ closing })} + /> + + onUpdateContent({ signature })} + /> +
+ onUpdateContent({ signature })} + label="Postscript" + value={content.postscript} + onChange={(postscript) => onUpdateContent({ postscript })} /> -
- - onUpdateContent({ postscript })} - /> -
+ +
); } diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx index f32afe6..3882e41 100644 --- a/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx +++ b/apps/studio/features/cover-letter/editor/components/CoverLetterFields.tsx @@ -1,5 +1,3 @@ -"use client"; - import type { ReactNode } from "react"; import { Input, TextArea } from "@veriworkly/ui"; @@ -56,67 +54,13 @@ export function TextField({ ); } -export function RangeField({ - label, - value, - min, - max, - step, - onChange, -}: { - label: string; - value: number; - min: number; - max: number; - step?: number; - onChange: (value: number) => void; -}) { - return ( - - ); -} - -export function ColorField({ - label, - value, - onChange, -}: { - label: string; - value: string; - onChange: (value: string) => void; -}) { - return ( - - ); -} - export function EditorBlock({ children, title }: { children: ReactNode; title: string }) { return (
-
-

{title}

+
+

{title}

+ {children}
); diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx index 00bb299..59b4359 100644 --- a/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx +++ b/apps/studio/features/cover-letter/editor/components/CoverLetterSettingsPanel.tsx @@ -1,16 +1,27 @@ "use client"; -import { Select } from "@veriworkly/ui"; +import { useMemo, useState } from "react"; +import type { + CoverLetterContent, + CoverLetterSectionId, + CoverLetterAppearance, +} from "@/features/cover-letter/types"; import type { BaseDocument } from "@/features/documents/core/types"; import type { FontFamilyId } from "@/features/documents/constants/fonts"; -import type { CoverLetterAppearance, CoverLetterContent } from "@/features/cover-letter/types"; +import { + SettingsColor, + SettingsRange, + SettingsSelect, +} from "@/features/resume/editor/settings/SettingControls"; +import { + DocumentTemplateSummary, + DocumentTemplatePickerModal, +} from "@/features/documents/editor/DocumentTemplatePickerModal"; import { fontOptions } from "@/features/documents/constants/fonts"; import { templateCatalogByType } from "@/features/documents/core/template-catalog"; -import { ColorField, EditorBlock, RangeField } from "./CoverLetterFields"; - interface CoverLetterSettingsPanelProps { document: BaseDocument; appearance: CoverLetterAppearance; @@ -27,100 +38,151 @@ export function CoverLetterSettingsPanel({ onUpdateDocument, onUpdateAppearance, }: CoverLetterSettingsPanelProps) { + const [templateModalOpen, setTemplateModalOpen] = useState(false); + + const activeTemplate = templateCatalogByType.COVER_LETTER.find( + (template) => template.id === document.templateId, + ); + const hiddenSections = useMemo( + () => appearance.hiddenSections ?? [], + [appearance.hiddenSections], + ); + + function updateTemplate(templateId: string) { + onUpdateDocument({ + ...document, + templateId, + updatedAt: new Date().toISOString(), + }); + } + + function setSectionVisibility(sectionId: CoverLetterSectionId, visible: boolean) { + onUpdateAppearance({ + hiddenSections: visible + ? hiddenSections.filter((id) => id !== sectionId) + : Array.from(new Set([...hiddenSections, sectionId])), + }); + } + return ( -
- - - - - - - - +
+

Design controls

+

Template, spacing, typography, and visibility.

+
+ + setTemplateModalOpen(true)} + /> + +
+ + onUpdateAppearance({ fontFamily: event.target.value as FontFamilyId }) + } + > + {fontOptions.map((font) => ( + + ))} + + + onUpdateAppearance({ pageMargin })} + onChange={(event) => onUpdateAppearance({ pageMargin: Number(event.target.value) })} /> - onUpdateAppearance({ paragraphSpacing })} + onChange={(event) => onUpdateAppearance({ paragraphSpacing: Number(event.target.value) })} /> - onUpdateAppearance({ lineHeight })} + onChange={(event) => onUpdateAppearance({ lineHeight: Number(event.target.value) })} /> - +
- - + onUpdateAppearance({ accentColor })} + onChange={(event) => onUpdateAppearance({ accentColor: event.target.value })} /> - onUpdateAppearance({ sidebarColor })} + onChange={(event) => onUpdateAppearance({ sidebarColor: event.target.value })} /> - onUpdateAppearance({ pageColor })} + onChange={(event) => onUpdateAppearance({ pageColor: event.target.value })} /> - onUpdateAppearance({ textColor })} + onChange={(event) => onUpdateAppearance({ textColor: event.target.value })} /> - +
+ +
+
+

Section visibility

+

Show or hide cover letter blocks.

+
+ +
+ {COVER_LETTER_SECTIONS.map((section) => ( + + ))} +
+
+ + setTemplateModalOpen(false)} + open={templateModalOpen} + templates={templateCatalogByType.COVER_LETTER} + /> ); } + +const COVER_LETTER_SECTIONS: Array<{ id: CoverLetterSectionId; label: string }> = [ + { id: "profile", label: "Profile" }, + { id: "links", label: "Links" }, + { id: "target", label: "Target" }, + { id: "letter", label: "Letter" }, +]; diff --git a/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx b/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx index f44536e..953c4cb 100644 --- a/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx +++ b/apps/studio/features/cover-letter/editor/components/CoverLetterToolbar.tsx @@ -2,8 +2,8 @@ import Link from "next/link"; import { useRef, useState } from "react"; -import { FileSearch } from "lucide-react"; import { useRouter } from "next/navigation"; +import { Eye, FileSearch, Save } from "lucide-react"; import { Button } from "@veriworkly/ui"; @@ -23,6 +23,7 @@ interface CoverLetterToolbarProps { message: string; onDelete: () => void; onImportJson: (file: File | undefined) => Promise; + onImportMarkdown: (file: File | undefined) => Promise; onOpenShare: () => void; onSave: () => void; onSetMessage: (message: string) => void; @@ -37,6 +38,7 @@ export function CoverLetterToolbar({ message, onDelete, onImportJson, + onImportMarkdown, onOpenShare, onSave, onSetMessage, @@ -44,7 +46,8 @@ export function CoverLetterToolbar({ }: CoverLetterToolbarProps) { const router = useRouter(); - const fileInputRef = useRef(null); + const jsonInputRef = useRef(null); + const markdownInputRef = useRef(null); const [activeDownload, setActiveDownload] = useState(null); async function download(format: ExportFormat) { @@ -61,16 +64,16 @@ export function CoverLetterToolbar({ } return ( -
+
router.push("/documents")} /> -
+
{process.env.NODE_ENV === "development" ? ( - - { void onImportJson(event.target.files?.[0]).finally(() => { @@ -106,6 +112,18 @@ export function CoverLetterToolbar({ }} /> + { + void onImportMarkdown(event.target.files?.[0]).finally(() => { + event.currentTarget.value = ""; + }); + }} + /> + download("pdf")} @@ -119,8 +137,8 @@ export function CoverLetterToolbar({ void download("json")} - onImport={() => fileInputRef.current?.click()} + onImportJson={() => jsonInputRef.current?.click()} + onImportMarkdown={() => markdownInputRef.current?.click()} onReset={() => { const reset = createDefaultCoverLetter(document.id); onUpdateDocument({ ...reset, updatedAt: new Date().toISOString() }, { flush: true }); diff --git a/apps/studio/features/cover-letter/markdown-import.ts b/apps/studio/features/cover-letter/markdown-import.ts new file mode 100644 index 0000000..37f6ca3 --- /dev/null +++ b/apps/studio/features/cover-letter/markdown-import.ts @@ -0,0 +1,122 @@ +"use client"; + +import type { CoverLetterContent } from "@/features/cover-letter/types"; + +function cleanMarkdown(value: string): string { + return value + .replace(/\*\*(.*?)\*\*/g, "$1") + .replace(/^#{1,6}\s+/, "") + .trim(); +} + +function isBullet(line: string) { + return /^[-*]\s+/.test(line.trim()); +} + +function splitBlocks(markdown: string) { + return markdown + .split(/\n{2,}/) + .map((block) => block.trim()) + .filter(Boolean); +} + +function splitContact(value: string) { + return value + .split("|") + .map((item) => item.trim()) + .filter(Boolean); +} + +export function parseCoverLetterMarkdown( + markdown: string, + currentContent: CoverLetterContent, +): CoverLetterContent { + const blocks = splitBlocks(markdown); + const [nameBlock = "", contactBlock = "", dateBlock = "", recipientBlock = "", ...letterBlocks] = + blocks; + + const contact = splitContact(contactBlock); + const subjectIndex = letterBlocks.findIndex((block) => + cleanMarkdown(block).toLowerCase().startsWith("subject:"), + ); + + const subject = + subjectIndex >= 0 + ? cleanMarkdown(letterBlocks.splice(subjectIndex, 1)[0]).replace(/^Subject:\s*/i, "") + : currentContent.subject; + + const greetingIndex = letterBlocks.findIndex( + (block) => /,$/.test(block) && /^Dear\s+/i.test(block), + ); + const greeting = + greetingIndex >= 0 + ? cleanMarkdown(letterBlocks.splice(greetingIndex, 1)[0]) + : currentContent.greeting; + + const signatureIndex = letterBlocks.findIndex((block) => { + const clean = cleanMarkdown(block); + return clean === cleanMarkdown(nameBlock) || clean === currentContent.signature; + }); + + const tailBlocks = signatureIndex >= 0 ? letterBlocks.splice(signatureIndex) : []; + const signature = tailBlocks[0] ? cleanMarkdown(tailBlocks[0]) : cleanMarkdown(nameBlock); + const postscriptBlock = tailBlocks.find((block) => /^P\.?S\.?\s+/i.test(block)); + const postscript = postscriptBlock + ? cleanMarkdown(postscriptBlock).replace(/^P\.?S\.?\s*/i, "") + : currentContent.postscript; + + const closing = + signatureIndex > 0 + ? cleanMarkdown(letterBlocks.splice(signatureIndex - 1, 1)[0]) + : currentContent.closing; + const highlightBlocks = letterBlocks.filter((block) => block.split(/\r?\n/).some(isBullet)); + const highlights = highlightBlocks + .flatMap((block) => block.split(/\r?\n/)) + .filter(isBullet) + .map((line) => line.replace(/^[-*]\s+/, "").trim()) + .filter(Boolean) + .map((line) => `- ${line}`) + .join("\n"); + + const proseBlocks = letterBlocks.filter((block) => !block.split(/\r?\n/).some(isBullet)); + const opening = proseBlocks[0] ? cleanMarkdown(proseBlocks[0]) : currentContent.opening; + const body = proseBlocks.slice(1).map(cleanMarkdown).filter(Boolean).join("\n\n"); + + const recipientLines = recipientBlock.split(/\r?\n/).map(cleanMarkdown).filter(Boolean); + + return { + ...currentContent, + senderName: cleanMarkdown(nameBlock) || currentContent.senderName, + senderTitle: contact[0] ?? currentContent.senderTitle, + senderEmail: contact[1] ?? currentContent.senderEmail, + senderPhone: contact[2] ?? currentContent.senderPhone, + senderLocation: contact[3] ?? currentContent.senderLocation, + senderWebsite: contact[4] ?? currentContent.senderWebsite, + date: cleanMarkdown(dateBlock) || currentContent.date, + recipientName: recipientLines[0] ?? currentContent.recipientName, + recipientTitle: recipientLines[1] ?? currentContent.recipientTitle, + companyName: recipientLines[2] ?? currentContent.companyName, + companyLocation: recipientLines[3] ?? currentContent.companyLocation, + subject, + greeting, + opening, + body: body || currentContent.body, + highlights: highlights || currentContent.highlights, + closing, + signature, + postscript, + }; +} + +export async function importCoverLetterMarkdownFile( + file: File, + currentContent: CoverLetterContent, +) { + const markdown = await file.text(); + + if (!markdown.trim()) { + throw new Error("Invalid cover letter markdown"); + } + + return parseCoverLetterMarkdown(markdown, currentContent); +} diff --git a/apps/studio/features/cover-letter/schema.ts b/apps/studio/features/cover-letter/schema.ts index af80d82..2244541 100644 --- a/apps/studio/features/cover-letter/schema.ts +++ b/apps/studio/features/cover-letter/schema.ts @@ -1,7 +1,6 @@ -import type { CoverLetterContent } from "./types"; -import type { ResumeLinkDisplayMode, ResumeLinkItem, ResumeLinkType } from "@/types/resume"; - import type { BaseDocument } from "@/features/documents/core/types"; +import type { CoverLetterContent, CoverLetterSectionId } from "./types"; +import type { ResumeLinkDisplayMode, ResumeLinkItem, ResumeLinkType } from "@/types/resume"; import { normalizeFontFamilyId } from "@/features/documents/constants/fonts"; @@ -29,6 +28,16 @@ const LINK_TYPES: ResumeLinkType[] = [ "custom", ]; +const COVER_LETTER_SECTION_IDS: CoverLetterSectionId[] = ["profile", "links", "target", "letter"]; + +function parseHiddenSections(value: unknown): CoverLetterSectionId[] { + if (!Array.isArray(value)) return []; + + return value.filter((item): item is CoverLetterSectionId => + COVER_LETTER_SECTION_IDS.includes(item as CoverLetterSectionId), + ); +} + function parseLinks(value: unknown): CoverLetterContent["links"] { if (!isRecord(value)) return { displayMode: "icon-username", items: [] }; const displayMode = @@ -124,6 +133,7 @@ export function parseCoverLetterDocument(input: unknown): BaseDocument("editor"); - const [activePanel, setActivePanel] = useState(defaultPanel); + const [settingsOpen, setSettingsOpen] = useState(true); + const [contentOpen, setContentOpen] = useState(defaultPanel !== "settings"); + + const [dragStart, setDragStart] = useState<{ + pointerId: number; + x: number; + y: number; + panX: number; + panY: number; + } | null>(null); + + const [zoom, setZoom] = useState(78); + const [pan, setPan] = useState({ x: 0, y: 0 }); + + const [activeTab, setActiveTab] = useState( + defaultPanel === "settings" ? "settings" : "content", + ); + + const previewTransform = useMemo( + () => ({ + transform: `translate3d(${pan.x}px, ${pan.y}px, 0) scale(${zoom / 100})`, + }), + [pan.x, pan.y, zoom], + ); + + function updateZoom(nextZoom: number) { + setZoom(Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, nextZoom))); + } + + function resetCanvas() { + setZoom(78); + setPan({ x: 0, y: 0 }); + } + + function handlePointerDown(event: PointerEvent) { + if (event.button !== 0) return; + + event.currentTarget.setPointerCapture(event.pointerId); + setDragStart({ + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + panX: pan.x, + panY: pan.y, + }); + } + + function handlePointerMove(event: PointerEvent) { + if (!dragStart || dragStart.pointerId !== event.pointerId) return; + + setPan({ + x: dragStart.panX + event.clientX - dragStart.x, + y: dragStart.panY + event.clientY - dragStart.y, + }); + } + + function handlePointerEnd(event: PointerEvent) { + if (dragStart?.pointerId === event.pointerId) { + setDragStart(null); + } + } return ( -
-
{toolbar}
+
+
+ {toolbar} +
{modals} -
- +
+ setActiveTab("content")} + /> - + /> + + setActiveTab("settings")} + />
- {panelOpen ? ( -
- -
-
- setActivePanel("content")} - /> - - setActivePanel("settings")} - /> -
- - -
+ {contentOpen ? ( + setContentOpen(false)} + > + {contentPanel} + + ) : null} -
- {activePanel === "content" ? contentPanel : settingsPanel} -
-
-
- ) : ( -
- -

- Panels -

- - - - -
-
- )} - - -
-
-

- Live Preview -

-

{previewTitle}

-
+
+ {!contentOpen ? ( + setContentOpen(true)} + > + + + ) : null} -
-
setSettingsOpen(true)} > -
+ + + ) : null} + +
+ + updateZoom(zoom - ZOOM_STEP)}> + + + +
+ {zoom}% +
+ + updateZoom(zoom + ZOOM_STEP)}> + + + + updateZoom(78)}> + + + + + + +
+ +
+ + Drag canvas + + {previewTitle} +
+ +
+
+
+
{preview}
- + + + {settingsOpen ? ( + setSettingsOpen(false)} + className={activeTab === "settings" ? "flex" : "hidden md:flex"} + > + {settingsPanel} + + ) : null}
); } -function PanelTabButton({ +function EditorRail({ + children, + className, + label, + onClose, + side, +}: { + children: ReactNode; + className?: string; + label: string; + onClose: () => void; + side: "left" | "right"; +}) { + const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose; + + return ( + + ); +} + +function IconToolButton({ + children, + label, + onClick, +}: { + children: ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function MobileTabButton({ active, label, onClick, @@ -191,15 +336,13 @@ function PanelTabButton({ onClick: () => void; }) { return ( - + ); } diff --git a/apps/studio/features/documents/editor/DocumentTemplatePickerModal.tsx b/apps/studio/features/documents/editor/DocumentTemplatePickerModal.tsx new file mode 100644 index 0000000..05bb4a2 --- /dev/null +++ b/apps/studio/features/documents/editor/DocumentTemplatePickerModal.tsx @@ -0,0 +1,188 @@ +/* eslint-disable @next/next/no-img-element */ + +import { Check, LayoutTemplate, X } from "lucide-react"; + +import type { TemplateMeta } from "@/features/documents/core/types"; + +import { Button, Modal } from "@veriworkly/ui"; + +interface DocumentTemplatePickerModalProps { + activeTemplateId: string; + description?: string; + onChange: (templateId: string) => void; + onClose: () => void; + open: boolean; + templates: TemplateMeta[]; + title?: string; +} + +export function DocumentTemplateSummary({ + activeTemplate, + onOpen, +}: { + activeTemplate: TemplateMeta | undefined; + onOpen: () => void; +}) { + return ( +
+
+
+

Template

+ +

+ {activeTemplate?.name || "Choose a document layout"} +

+
+ + +
+ + {activeTemplate ? ( + + ) : null} +
+ ); +} + +export function DocumentTemplatePickerModal({ + activeTemplateId, + description = "Choose a layout for this document.", + onChange, + onClose, + open, + templates, + title = "Choose template", +}: DocumentTemplatePickerModalProps) { + return ( + + +
+
+ +
+ +
+ + {title} + + +

{description}

+
+ + +
+ + +
+ {templates.map((template) => { + const active = template.id === activeTemplateId; + + return ( + + ); + })} +
+
+ + + + +
+
+ ); +} diff --git a/apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx b/apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx index 9e0fd15..701a094 100644 --- a/apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx +++ b/apps/studio/features/documents/editor/toolbar/ToolbarActionsMenu.tsx @@ -3,9 +3,9 @@ import { Trash2, Share2, - Download, RotateCcw, Settings2, + FileCode2, ChevronDown, FolderInput, } from "lucide-react"; @@ -14,16 +14,16 @@ import { Button, Menu, MenuItem, MenuSeparator } from "@veriworkly/ui"; interface ToolbarActionsMenuProps { onDelete: () => void; - onExport: () => void; - onImport: () => void; + onImportJson: () => void; + onImportMarkdown: () => void; onReset: () => void; onShare: () => void; } const ToolbarActionsMenu = ({ onDelete, - onExport, - onImport, + onImportJson, + onImportMarkdown, onReset, onShare, }: ToolbarActionsMenuProps) => { @@ -32,13 +32,13 @@ const ToolbarActionsMenu = ({ panelClassName="min-w-52" trigger={({ menuId, open, toggle }) => ( )} diff --git a/apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx b/apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx index 67dacbb..c802159 100644 --- a/apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx +++ b/apps/studio/features/documents/editor/toolbar/ToolbarHeader.tsx @@ -12,20 +12,21 @@ interface ToolbarHeaderProps { const ToolbarHeader = ({ message, title = "Document Editor", onBack }: ToolbarHeaderProps) => { return ( -
+
-
-

{title}

-

{message}

+
+

{title}

+

{message}

); diff --git a/apps/studio/features/documents/export/export-markdown.ts b/apps/studio/features/documents/export/export-markdown.ts index d7945d7..bc3b570 100644 --- a/apps/studio/features/documents/export/export-markdown.ts +++ b/apps/studio/features/documents/export/export-markdown.ts @@ -24,6 +24,7 @@ function buildMarkdown(resume: ResumeData): string { const visibleSections = getVisibleSectionMap(resume); const role = safeText(resume.basics.role); + const headline = safeText(resume.basics.headline); const name = safeText(resume.basics.fullName) || "Your Name"; parts.push(`# ${name}`); @@ -32,6 +33,10 @@ function buildMarkdown(resume: ResumeData): string { parts.push(`_${role}_`); } + if (headline) { + parts.push(headline); + } + const contact = [ safeText(resume.basics.email), safeText(resume.basics.phone), diff --git a/apps/studio/features/resume/editor/EditorContentPanel.tsx b/apps/studio/features/resume/editor/EditorContentPanel.tsx index 93e97f2..6083caa 100644 --- a/apps/studio/features/resume/editor/EditorContentPanel.tsx +++ b/apps/studio/features/resume/editor/EditorContentPanel.tsx @@ -1,8 +1,6 @@ "use client"; -import type { DragEvent } from "react"; - -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; import type { ResumeSectionId } from "@/types/resume"; @@ -27,9 +25,7 @@ import CertificationsSection from "./content/sections/CertificationsSection"; const EditorContentPanel = memo(function EditorContentPanel() { const sections = useResumeStore((state) => state.resume.sections); - const reorderSections = useResumeStore((state) => state.reorderSections); - const draggedSectionIdRef = useRef(null); const [openSectionId, setOpenSectionId] = useState("basics"); const sortedSections = useMemo( @@ -41,51 +37,19 @@ const EditorContentPanel = memo(function EditorContentPanel() { setOpenSectionId((currentSectionId) => (currentSectionId === sectionId ? null : sectionId)); }, []); - const handleDragStart = useCallback((sectionId: ResumeSectionId) => { - draggedSectionIdRef.current = sectionId; - }, []); - - const handleDrop = useCallback( - (sectionId: ResumeSectionId, sectionIndex: number) => { - const draggedSectionId = draggedSectionIdRef.current; - - if (draggedSectionId && draggedSectionId !== sectionId) { - const draggedIndex = sections.findIndex((item) => item.id === draggedSectionId); - - if (draggedIndex !== -1) { - reorderSections(draggedIndex, sectionIndex); - } - } - - draggedSectionIdRef.current = null; - }, - [reorderSections, sections], - ); - - const handleDragEnd = useCallback(() => { - draggedSectionIdRef.current = null; - }, []); - return ( -
-
-

- Resume Content -

- -

Content editor

+
+
+

Content editor

+

Edit resume sections. Reorder them in visibility.

-
- {sortedSections.map((section, sectionIndex) => ( +
+ {sortedSections.map((section) => ( ))} @@ -96,56 +60,21 @@ const EditorContentPanel = memo(function EditorContentPanel() { interface EditorSectionItemProps { id: ResumeSectionId; - index: number; isOpen: boolean; - onDragEnd: () => void; - onDragStart: (sectionId: ResumeSectionId) => void; - onDrop: (sectionId: ResumeSectionId, sectionIndex: number) => void; onToggle: (sectionId: ResumeSectionId) => void; } const EditorSectionItem = memo(function EditorSectionItem({ id, - index, isOpen, - onDragEnd, - onDragStart, - onDrop, onToggle, }: EditorSectionItemProps) { - const handleDragStart = useCallback( - (event: DragEvent) => { - onDragStart(id); - - if (event.dataTransfer) { - event.dataTransfer.effectAllowed = "move"; - } - }, - [id, onDragStart], - ); - - const handleDragOver = useCallback((event: DragEvent) => { - event.preventDefault(); - - if (event.dataTransfer) { - event.dataTransfer.dropEffect = "move"; - } - }, []); - - const handleDrop = useCallback( - (event: DragEvent) => { - event.preventDefault(); - onDrop(id, index); - }, - [id, index, onDrop], - ); - const sectionProps = { isOpen, - onDragEnd, - onDragOver: handleDragOver, - onDragStart: handleDragStart, - onDrop: handleDrop, + onDragEnd: () => undefined, + onDragOver: () => undefined, + onDragStart: () => undefined, + onDrop: () => undefined, onToggle, }; diff --git a/apps/studio/features/resume/editor/EditorSettingsPanel.tsx b/apps/studio/features/resume/editor/EditorSettingsPanel.tsx index 1fd0172..32f3965 100644 --- a/apps/studio/features/resume/editor/EditorSettingsPanel.tsx +++ b/apps/studio/features/resume/editor/EditorSettingsPanel.tsx @@ -4,7 +4,11 @@ import { memo, useState } from "react"; import type { FontFamilyId } from "@/features/documents/constants/fonts"; -import { templateSummaries } from "@/config/templates"; +import { templateCatalogByType } from "@/features/documents/core/template-catalog"; +import { + DocumentTemplatePickerModal, + DocumentTemplateSummary, +} from "@/features/documents/editor/DocumentTemplatePickerModal"; import AdvancedThemeSettings from "./settings/AdvancedThemeSettings"; import SectionVisibilitySettings from "./settings/SectionVisibilitySettings"; @@ -15,96 +19,108 @@ import { useResumeStore } from "@/features/resume/store/resume-store"; import { defaultResume } from "@/features/resume/constants/default-resume"; const EditorSettingsPanel = memo(function EditorSettingsPanel() { + const [advancedOpen, setAdvancedOpen] = useState(false); + const [templateModalOpen, setTemplateModalOpen] = useState(false); + const sections = useResumeStore((state) => state.resume.sections); const templateId = useResumeStore((state) => state.resume.templateId); const customization = useResumeStore((state) => state.resume.customization); - const setSectionVisibility = useResumeStore((state) => state.setSectionVisibility); + const setTemplateId = useResumeStore((state) => state.setTemplateId); + const reorderSections = useResumeStore((state) => state.reorderSections); const updateCustomization = useResumeStore((state) => state.updateCustomization); + const setSectionVisibility = useResumeStore((state) => state.setSectionVisibility); - const [advancedOpen, setAdvancedOpen] = useState(false); + const selectedTemplate = templateCatalogByType.RESUME.find( + (template) => template.id === templateId, + ); return ( -
-
-

- Styles & Settings -

- -

Design controls

+
+
+

Design controls

+

Template, spacing, typography, and visibility.

- setTemplateId(event.target.value)} - > - {templateSummaries.map((template) => ( - - ))} - - - - updateCustomization({ - fontFamily: event.target.value as FontFamilyId, - }) - } - value={customization.fontFamily} - > - {fontOptions.map((font) => ( - - ))} - - - - updateCustomization({ - sectionSpacing: Number(event.target.value), - }) - } - value={customization.sectionSpacing} + setTemplateModalOpen(true)} /> - - updateCustomization({ - pagePadding: Number(event.target.value), - }) - } - value={customization.pagePadding} - /> - - - updateCustomization({ - accentColor: event.target.value, - }) - } - /> +
+ + updateCustomization({ + fontFamily: event.target.value as FontFamilyId, + }) + } + > + {fontOptions.map((font) => ( + + ))} + + + + updateCustomization({ + sectionSpacing: Number(event.target.value), + }) + } + /> + + + updateCustomization({ + pagePadding: Number(event.target.value), + }) + } + /> + + + updateCustomization({ + accentColor: event.target.value, + }) + } + /> +
updateCustomization({ ...defaultResume.customization })} onToggleOpen={() => setAdvancedOpen((isOpen) => !isOpen)} + onResetThemeDefaults={() => updateCustomization({ ...defaultResume.customization })} + /> + + - + setTemplateModalOpen(false)} + />
); }); diff --git a/apps/studio/features/resume/editor/ResumeEditor.tsx b/apps/studio/features/resume/editor/ResumeEditor.tsx index b1d6b09..e7cd8c2 100644 --- a/apps/studio/features/resume/editor/ResumeEditor.tsx +++ b/apps/studio/features/resume/editor/ResumeEditor.tsx @@ -25,6 +25,7 @@ import ResumeToolbar from "./ResumeToolbar"; import ResumeEditorModals from "./ResumeEditorModals"; import EditorContentPanel from "./EditorContentPanel"; import EditorSettingsPanel from "./EditorSettingsPanel"; +import { ResumePagedPreview } from "./ResumePagedPreview"; import { useUserStore } from "@/store/useUserStore"; @@ -51,8 +52,6 @@ const ResumeEditor = ({ documentId }: ResumeEditorProps) => { const resumePreviewId = `resume-preview-${resume.id}`; - const stagePaddingClass = resume.customization.pagePadding === 0 ? "p-0" : "p-3 md:p-6"; - useEffect(() => { let cancelled = false; @@ -148,7 +147,11 @@ const ResumeEditor = ({ documentId }: ResumeEditorProps) => { const preview = templateComponent ? (() => { const TemplateComponent = templateComponent; - return ; + return ( + + + + ); })() : null; @@ -175,7 +178,7 @@ const ResumeEditor = ({ documentId }: ResumeEditorProps) => { preview={preview} previewId={resumePreviewId} previewTitle={deferredResume.basics.fullName || "Untitled Resume"} - previewStageClassName={stagePaddingClass} + previewStageClassName="p-0" settingsLabel="Style settings" /> ); diff --git a/apps/studio/features/resume/editor/ResumePagedPreview.tsx b/apps/studio/features/resume/editor/ResumePagedPreview.tsx new file mode 100644 index 0000000..206b54a --- /dev/null +++ b/apps/studio/features/resume/editor/ResumePagedPreview.tsx @@ -0,0 +1,115 @@ +"use client"; + +import type { CSSProperties, ReactNode } from "react"; + +import { useLayoutEffect, useRef, useState } from "react"; + +import { + RESUME_PAGE_HEIGHT_PX, + RESUME_PAGE_WIDTH_PX, +} from "@/features/resume/constants/resume-layout"; + +interface ResumePreviewPage { + content: string; +} + +export function ResumePagedPreview({ children }: { children: ReactNode }) { + const measureRef = useRef(null); + const [pages, setPages] = useState([]); + const [pageStyle, setPageStyle] = useState({}); + + useLayoutEffect(() => { + const frame = window.requestAnimationFrame(() => { + const container = measureRef.current?.querySelector( + "#resume-container", + ) as HTMLElement | null; + + if (!container) { + setPages([]); + return; + } + + const computed = window.getComputedStyle(container); + const paddingTop = Number.parseFloat(computed.paddingTop) || 0; + const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0; + const usableHeight = RESUME_PAGE_HEIGHT_PX - paddingTop - paddingBottom; + const nextPages: ResumePreviewPage[] = []; + let current: string[] = []; + let usedHeight = 0; + + Array.from(container.children).forEach((child) => { + const element = child as HTMLElement; + const childStyle = window.getComputedStyle(element); + const blockHeight = + element.getBoundingClientRect().height + + (Number.parseFloat(childStyle.marginTop) || 0) + + (Number.parseFloat(childStyle.marginBottom) || 0); + + if (current.length > 0 && usedHeight + blockHeight > usableHeight) { + nextPages.push({ content: current.join("") }); + current = []; + usedHeight = 0; + } + + current.push(element.outerHTML); + usedHeight += blockHeight; + }); + + if (current.length > 0) { + nextPages.push({ content: current.join("") }); + } + + const nextPageStyle = { + backgroundColor: computed.backgroundColor, + color: computed.color, + fontFamily: computed.fontFamily, + fontSize: computed.fontSize, + lineHeight: computed.lineHeight, + padding: computed.padding, + } satisfies CSSProperties; + const resolvedPages = nextPages.length > 0 ? nextPages : [{ content: container.innerHTML }]; + const pageKey = resolvedPages.map((page) => page.content).join(""); + const styleKey = JSON.stringify(nextPageStyle); + + setPageStyle((currentStyle) => + JSON.stringify(currentStyle) === styleKey ? currentStyle : nextPageStyle, + ); + setPages((currentPages) => + currentPages.map((page) => page.content).join("") === pageKey + ? currentPages + : resolvedPages, + ); + }); + + return () => window.cancelAnimationFrame(frame); + }, [children]); + + return ( +
+ + +
+ {pages.map((page, index) => ( +
+ ))} +
+
+ ); +} diff --git a/apps/studio/features/resume/editor/ResumeToolbar.tsx b/apps/studio/features/resume/editor/ResumeToolbar.tsx index 52dc382..09c1786 100644 --- a/apps/studio/features/resume/editor/ResumeToolbar.tsx +++ b/apps/studio/features/resume/editor/ResumeToolbar.tsx @@ -15,7 +15,11 @@ import ToolbarSecondaryActions from "@/features/resume/editor/toolbar/ToolbarSec import { useResumeStore } from "@/features/resume/store/resume-store"; import { getDocumentEditorPath } from "@/features/documents/core/routes"; -import { saveResume, importResumeFromFile } from "@/features/resume/services/resume-service"; +import { + saveResume, + importResumeFromFile, + importResumeFromMarkdownFile, +} from "@/features/resume/services/resume-service"; interface ToolbarProps { resumeId: string; @@ -27,7 +31,8 @@ interface ToolbarProps { const ResumeToolbar = ({ resumeId, resumePreviewId, onOpenShare, onOpenDelete }: ToolbarProps) => { const router = useRouter(); - const fileInputRef = useRef(null); + const jsonInputRef = useRef(null); + const markdownInputRef = useRef(null); const resume = useResumeStore((state) => state.resume); const resetResume = useResumeStore((state) => state.resetResume); @@ -72,13 +77,33 @@ const ResumeToolbar = ({ resumeId, resumePreviewId, onOpenShare, onOpenDelete }: } } + async function onImportMarkdown(file: File | undefined) { + if (!file) return; + + try { + const importedResume = await importResumeFromMarkdownFile(file, resume); + const saveResult = saveResume(importedResume); + + if (!saveResult.ok) { + setMessage(getSaveFailureMessage(saveResult.reason)); + return; + } + + setResume(importedResume); + router.push(getDocumentEditorPath("RESUME", importedResume.id)); + setMessage("Markdown imported successfully"); + } catch { + setMessage("Import failed. Please use a valid Markdown file"); + } + } + return ( -
+
router.push("/")} /> -
+
{process.env.NODE_ENV === "development" ? ( - - {isOpen ?
{children}
: null} + {isOpen ?
{children}
: null}
); }; diff --git a/apps/studio/features/resume/editor/content/sections/DraggableSection.tsx b/apps/studio/features/resume/editor/content/sections/DraggableSection.tsx index bb4ddab..9881ab0 100644 --- a/apps/studio/features/resume/editor/content/sections/DraggableSection.tsx +++ b/apps/studio/features/resume/editor/content/sections/DraggableSection.tsx @@ -2,8 +2,8 @@ import type { ReactNode } from "react"; -import type { ResumeSectionId } from "@/types/resume"; import type { SectionDnDHandlers } from "./section-types"; +import type { ResumeSectionId } from "@/types/resume"; import SectionAccordion from "../SectionAccordion"; @@ -20,22 +20,17 @@ const DraggableSection = ({ id, isOpen, label, - onDragEnd, onDragOver, - onDragStart, onDrop, onToggle, }: DraggableSectionProps) => { return (
onToggle(nextId as ResumeSectionId)} > {children} diff --git a/apps/studio/features/resume/editor/content/sections/GenericCustomSection.tsx b/apps/studio/features/resume/editor/content/sections/GenericCustomSection.tsx index 770c396..545ce84 100644 --- a/apps/studio/features/resume/editor/content/sections/GenericCustomSection.tsx +++ b/apps/studio/features/resume/editor/content/sections/GenericCustomSection.tsx @@ -66,33 +66,42 @@ export default function GenericCustomSection({ onDragOver={onDragOver} onDragStart={onDragStart} > -
+
{section.items.length ? ( - - ) : null} +
+ - + - + +
+ ) : ( + + )}
{activeItem ? ( diff --git a/apps/studio/features/resume/editor/settings/AdvancedThemeSettings.tsx b/apps/studio/features/resume/editor/settings/AdvancedThemeSettings.tsx index 74fcc39..7ec93ee 100644 --- a/apps/studio/features/resume/editor/settings/AdvancedThemeSettings.tsx +++ b/apps/studio/features/resume/editor/settings/AdvancedThemeSettings.tsx @@ -2,6 +2,8 @@ import type { ResumeCustomization } from "@/types/resume"; +import { ChevronDown, RotateCcw } from "lucide-react"; + import { Button } from "@veriworkly/ui"; import { SettingsColor, SettingsRange } from "./SettingControls"; @@ -22,18 +24,17 @@ const AdvancedThemeSettings = ({ onUpdateCustomization, }: AdvancedThemeSettingsProps) => { return ( -
+
-
+

Advanced Theme

-

- Full color control for resume surface and typography. -

+

Fine tune surfaces, borders, and text.

-
@@ -99,7 +100,13 @@ const AdvancedThemeSettings = ({ />
-
diff --git a/apps/studio/features/resume/editor/settings/SectionVisibilitySettings.tsx b/apps/studio/features/resume/editor/settings/SectionVisibilitySettings.tsx index 7cc4c2f..16f284f 100644 --- a/apps/studio/features/resume/editor/settings/SectionVisibilitySettings.tsx +++ b/apps/studio/features/resume/editor/settings/SectionVisibilitySettings.tsx @@ -2,33 +2,66 @@ import type { ResumeSectionId, ResumeSection } from "@/types/resume"; +import { ArrowDown, ArrowUp } from "lucide-react"; + interface SectionVisibilitySettingsProps { + onMove: (fromIndex: number, toIndex: number) => void; onToggle: (sectionId: ResumeSectionId, visible: boolean) => void; sections: ResumeSection[]; } -const SectionVisibilitySettings = ({ onToggle, sections }: SectionVisibilitySettingsProps) => { +const SectionVisibilitySettings = ({ + onMove, + onToggle, + sections, +}: SectionVisibilitySettingsProps) => { + const sortedSections = sections.slice().sort((left, right) => left.order - right.order); + return ( -
-

- Section visibility -

- -
- {sections.map((section) => ( -
diff --git a/apps/studio/features/resume/editor/settings/SettingControls.tsx b/apps/studio/features/resume/editor/settings/SettingControls.tsx index bd1c7b1..fc3d967 100644 --- a/apps/studio/features/resume/editor/settings/SettingControls.tsx +++ b/apps/studio/features/resume/editor/settings/SettingControls.tsx @@ -1,13 +1,28 @@ "use client"; -import type { ReactNode, InputHTMLAttributes, SelectHTMLAttributes } from "react"; +import type { CSSProperties, InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react"; -import { Select } from "@veriworkly/ui"; +import { ChevronDown } from "lucide-react"; -export function SettingsField({ children, label }: { children: ReactNode; label: string }) { +import { cn } from "@/lib/utils"; + +export function SettingsField({ + children, + className, + hint, + label, +}: { + children: ReactNode; + className?: string; + hint?: string; + label: string; +}) { return ( -