mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-06-22 04:11:55 +10:00
📦 v5.0.15 - https://docs.rxresu.me/changelog
This commit is contained in:
@@ -30,6 +30,10 @@ GOOGLE_CLIENT_SECRET=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Social Auth (LinkedIn, optional)
|
||||
LINKEDIN_CLIENT_ID=""
|
||||
LINKEDIN_CLIENT_SECRET=""
|
||||
|
||||
# Custom OAuth Provider (optional)
|
||||
OAUTH_PROVIDER_NAME=""
|
||||
OAUTH_CLIENT_ID=""
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Overview
|
||||
|
||||
Reactive Resume is a single-package full-stack TypeScript app (not a monorepo) built with [TanStack Start](https://tanstack.com/start/latest/docs/framework/react/overview) (React, Vite, Nitro). It serves both frontend and API on port 3000.
|
||||
@@ -130,4 +128,4 @@ Copy `.env.example` to `.env` if not present. Key notes for local dev:
|
||||
## Review Checklist for Agents
|
||||
|
||||
- [ ] Run `vp install` after pulling remote changes and before getting started.
|
||||
- [ ] Run `vp check` and `vp test` to validate changes.
|
||||
- [ ] Run `pnpm lint:fix`, `pnpm fmt:fix`, `pnpm typecheck` and `vp test` to validate changes.
|
||||
|
||||
@@ -4,6 +4,37 @@ description: "List of all notable changes and updates to Reactive Resume"
|
||||
rss: true
|
||||
---
|
||||
|
||||
<Update label="v5.0.15" description="1st April 2026">
|
||||
## Features & Improvements
|
||||
- Added **LinkedIn sign-in** support for self-hosted instances, including sign-in on the auth page and account linking in settings.
|
||||
- Improved the sign-in experience by showing loading placeholders while social login providers are being fetched.
|
||||
- Resume builder panel sizes are now persisted more reliably, making the layout feel more consistent between sessions.
|
||||
- Added clearer labels for resume sorting and filtering controls on the dashboard.
|
||||
|
||||
## Fixes
|
||||
- Improved autosave reliability in the resume builder:
|
||||
- Unsaved edits are flushed before page unload.
|
||||
- You now get a clear persistent warning if changes fail to save (for example, due to network issues).
|
||||
- Strengthened authentication defaults by increasing the minimum password length requirement to 8 characters.
|
||||
- Improved reliability around resume deletion and server-side error handling for create/update operations.
|
||||
- Fixed a rare language mix-up during printing/export when multiple print jobs run at the same time, so each PDF/screenshot now reliably uses the correct locale.
|
||||
- Improved print stability under heavy usage by reusing in-progress PDF and screenshot generation requests for the same resume instead of starting duplicate jobs.
|
||||
- Hardened printer token signing and token/session/API-key verification paths for better security.
|
||||
- Improved resilience in AI tailoring output parsing and JSON Resume import normalization.
|
||||
- Printer service now provides descriptive error messages ("Failed to generate PDF" / "Failed to capture screenshot") instead of generic internal server errors.
|
||||
- Job descriptions in the job detail sheet now render HTML formatting (headings, lists, bold text) instead of showing raw tags.
|
||||
- Fixed the "Tailor Resume" button being incorrectly disabled when AI is not configured — the dialog already supports plain resume duplication as a fallback.
|
||||
- Fixed the tailor resume flow to open the resume builder in a new tab while keeping the job listing open for reference, instead of navigating away and auto-opening the application page.
|
||||
- Apply option links in the job detail sheet are now validated before rendering, preventing malformed or potentially unsafe URLs.
|
||||
- Improved job card loading skeletons to match the actual card layout (logo, title, badges) for a smoother loading experience.
|
||||
- Centered the "Configure Job Search" empty state on the page.
|
||||
- Extracted the `JobCard` component into its own file for better code organization.
|
||||
|
||||
## Docs & Localization
|
||||
- Updated self-hosting and environment variable documentation to include LinkedIn OAuth configuration.
|
||||
- Synced translations across locales for the latest authentication and save-status messaging.
|
||||
</Update>
|
||||
|
||||
<Update label="v5.0.14" description="24th March 2026">
|
||||
## Features
|
||||
- Implemented OAuth 2.1 support for MCP authentication, thanks to @5queezer. [#2829](https://github.com/amruthpillai/reactive-resume/pull/2829)
|
||||
|
||||
@@ -138,6 +138,8 @@ Here's a complete list of environment variables you can configure:
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth Client ID | — |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | — |
|
||||
| `LINKEDIN_CLIENT_ID` | LinkedIn OAuth Client ID | — |
|
||||
| `LINKEDIN_CLIENT_SECRET` | LinkedIn OAuth Client Secret | — |
|
||||
| `OAUTH_PROVIDER_NAME` | Custom OAuth Provider Name | — |
|
||||
| `OAUTH_CLIENT_ID` | Custom OAuth Client ID | — |
|
||||
| `OAUTH_CLIENT_SECRET` | Custom OAuth Client Secret | — |
|
||||
|
||||
@@ -73,6 +73,10 @@ GOOGLE_CLIENT_SECRET=""
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
|
||||
# Social Auth (LinkedIn, optional)
|
||||
LINKEDIN_CLIENT_ID=""
|
||||
LINKEDIN_CLIENT_SECRET=""
|
||||
|
||||
# Custom OAuth Provider
|
||||
OAUTH_PROVIDER_NAME=""
|
||||
OAUTH_CLIENT_ID=""
|
||||
@@ -338,6 +342,8 @@ openssl rand -hex 32
|
||||
|
||||
**`GITHUB_CLIENT_ID`** / **`GITHUB_CLIENT_SECRET`** (optional): Enables GitHub sign-in.
|
||||
|
||||
**`LINKEDIN_CLIENT_ID`** / **`LINKEDIN_CLIENT_SECRET`** (optional): Enables LinkedIn sign-in.
|
||||
|
||||
**Custom OAuth provider** (optional):
|
||||
- **`OAUTH_PROVIDER_NAME`**: Display name in the UI
|
||||
- **`OAUTH_CLIENT_ID`** / **`OAUTH_CLIENT_SECRET`**: Required for any custom OAuth provider
|
||||
@@ -375,7 +381,7 @@ openssl rand -hex 32
|
||||
<Accordion title="Feature Flags">
|
||||
- **`FLAG_DEBUG_PRINTER`**: Bypasses the printer-only access restriction (useful when debugging `/printer/{resumeId}`). Recommended: keep `"false"` in production.
|
||||
- **`FLAG_DISABLE_SIGNUPS`**: Disables new signups (web app and server). Useful for private instances.
|
||||
- **`FLAG_DISABLE_EMAIL_AUTH`**: Disables email/password login entirely. Also disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. Useful when only SSO is required.
|
||||
- **`FLAG_DISABLE_EMAIL_AUTH`**: Disables email/password login entirely. Also disables email verification, forgot password, and reset password flows. Users can still sign up via social auth (Google/GitHub/LinkedIn/Custom OAuth), unless FLAG_DISABLE_SIGNUPS is also set to true. Useful when only SSO is required.
|
||||
- **`FLAG_DISABLE_IMAGE_PROCESSING`**: Disables image processing. This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -405,6 +405,8 @@ services:
|
||||
- GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET
|
||||
- GITHUB_CLIENT_ID=$GITHUB_CLIENT_ID
|
||||
- GITHUB_CLIENT_SECRET=$GITHUB_CLIENT_SECRET
|
||||
- LINKEDIN_CLIENT_ID=$LINKEDIN_CLIENT_ID
|
||||
- LINKEDIN_CLIENT_SECRET=$LINKEDIN_CLIENT_SECRET
|
||||
- SMTP_HOST=$SMTP_HOST
|
||||
- SMTP_PORT=$SMTP_PORT
|
||||
- SMTP_USER=$SMTP_USER
|
||||
|
||||
@@ -253,6 +253,8 @@ AUTH_SECRET="your-32-byte-hex-secret"
|
||||
# GOOGLE_CLIENT_SECRET=""
|
||||
# GITHUB_CLIENT_ID=""
|
||||
# GITHUB_CLIENT_SECRET=""
|
||||
# LINKEDIN_CLIENT_ID=""
|
||||
# LINKEDIN_CLIENT_SECRET=""
|
||||
|
||||
# Custom OAuth Provider (e.g., Authentik)
|
||||
OAUTH_PROVIDER_NAME="Company SSO"
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Jou data word plaaslik gestoor"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Jou data word veilig gestoor en nooit met derde partye gedeel nie. Jy kan ook Reactive Resume self-host op jou eie bedieners vir volledige beheer oor jou data."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Jou wagwoord is suksesvol herstel. Jy kan nou met jou nuwe wagwoord aanmeld."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zoem uit"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zoeloe"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "መረጃዎ በአካባቢዎ መሣሪያ ላይ ይቀመጣል"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "መረጃዎ በደህና ይከማቻል እና ከሶስተኛ ወገኖች ጋር አይተካፈልም። ደግሞም በራስዎ ሰርቨር ላይ Reactive Resumeን በመጫን በመረጃዎ ላይ ሙሉ ቁጥጥር ማግኘት ትችላላችሁ።"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "የይለፍ ቃልዎ በተሳካ ሁኔታ ዳግም ተጀምሯል። አሁን በአዲሱ የይለፍ ቃል መግባት ትችላላችሁ።"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "አጉር"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ዙሉ"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "تُخزَّن بياناتك محليًا"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "تُخزَّن بياناتك بأمان ولا تتم مشاركتها أبدًا مع أطراف ثالثة. يمكنك أيضًا استضافة Reactive Resume ذاتيًا على خوادمك الخاصة للتحكم الكامل في بياناتك."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "تمت إعادة تعيين كلمة المرور الخاصة بك بنجاح. يمكنك الآن تسجيل الدخول باستخدام كلمة المرور الجديدة."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "تصغير"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "الزولو"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Məlumatlarınız lokal olaraq saxlanılır"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Məlumatlarınız təhlükəsiz şəkildə saxlanılır və heç vaxt üçüncü tərəflərlə paylaşılmır. Məlumatlarınıza tam nəzarət üçün Reactive Resume‑ni öz serverlərinizdə də host edə bilərsiniz."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Parolunuz uğurla sıfırlandı. İndi yeni parolunuzla daxil ola bilərsiniz."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Uzaqlaşdır"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Вашите данни се съхраняват локално"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Вашите данни се съхраняват сигурно и никога не се споделят с трети страни. Можете също да хоствате Reactive Resume самостоятелно на свои сървъри за пълен контрол върху данните си."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Паролата ви беше нулирана успешно. Сега можете да влезете с новата си парола."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Намаляване"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Зулуски"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "আপনার ডেটা স্থানীয়ভাবে সং
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "আপনার ডেটা নিরাপদভাবে সংরক্ষিত হয় এবং কখনোই তৃতীয় পক্ষের সঙ্গে শেয়ার করা হয় না। চাইলে আপনি নিজের সার্ভারে Reactive Resume নিজে হোস্ট করেও ডেটার পূর্ণ নিয়ন্ত্রণ রাখতে পারেন।"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "আপনার পাসওয়ার্ড সফলভাবে রিসেট হয়েছে। এখন আপনি নতুন পাসওয়ার্ড দিয়ে সাইন ইন করতে পারবেন।"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "জুম আউট"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "জুলু"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Les dades es desen localment"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Les dades s’emmagatzemen de manera segura i mai no es comparteixen amb tercers. També pots autoallotjar Reactive Resume als teus propis servidors per tenir un control total sobre les dades."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "La contrasenya s’ha restablert correctament. Ara ja pots iniciar sessió amb la contrasenya nova."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Allunya"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulú"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Vaše data jsou ukládána lokálně"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Vaše data jsou ukládána bezpečně a nikdy nejsou sdílena s třetími stranami. Reactive Resume si můžete také hostovat sami na vlastních serverech a mít tak nad svými daty plnou kontrolu."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Vaše heslo bylo úspěšně obnoveno. Nyní se můžete přihlásit pomocí nového hesla."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Oddálit"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Dine data gemmes lokalt"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Dine data lagres sikkert og deles aldrig med tredjeparter. Du kan også selv-hoste Reactive Resume på dine egne servere for fuld kontrol over dine data."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Din adgangskode er blevet nulstillet. Du kan nu logge ind med din nye adgangskode."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zoom ud"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Deine Daten werden lokal gespeichert"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Deine Daten werden sicher gespeichert und niemals an Dritte weitergegeben. Du kannst Reactive Resume außerdem auf deinen eigenen Servern selbst hosten, um die vollständige Kontrolle über deine Daten zu behalten."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich nun mit Ihrem neuen Passwort anmelden."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Herauszoomen"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Τα δεδομένα σας αποθηκεύονται τοπικά"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Τα δεδομένα σας αποθηκεύονται με ασφάλεια και δεν κοινοποιούνται ποτέ σε τρίτους. Μπορείτε επίσης να φιλοξενήσετε μόνοι σας το Reactive Resume στους δικούς σας διακομιστές για πλήρη έλεγχο των δεδομένων σας."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Ο κωδικός πρόσβασής σας επαναφέρθηκε με επιτυχία. Τώρα μπορείτε να συνδεθείτε με τον νέο σας κωδικό πρόσβασης."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Σμίκρυνση"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Ζουλού"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Your data is stored locally"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Your password has been reset successfully. You can now sign in with your new password."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zoom out"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
@@ -3181,6 +3181,10 @@ msgstr "Your data is stored locally"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Your password has been reset successfully. You can now sign in with your new password."
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Tus datos se almacenan localmente"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Tus datos se almacenan de forma segura y nunca se comparten con terceros. También puedes auto alojar Reactive Resume en tus propios servidores para tener un control total sobre tus datos."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Tu contraseña se ha restablecido correctamente. Ahora puedes iniciar sesión con tu nueva contraseña."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Alejar"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulú"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "دادههای شما بهصورت محلی ذخیره میشو
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "دادههای شما بهطور ایمن ذخیره شده و هرگز با اشخاص ثالث به اشتراک گذاشته نمیشوند. همچنین میتوانید Reactive Resume را روی سرورهای خود میزبانی کنید تا کنترل کامل بر دادههای خود داشته باشید."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "گذرواژهٔ شما با موفقیت تنظیم مجدد شد. اکنون میتوانید با گذرواژهٔ جدید خود وارد شوید."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "کوچکنمایی"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "زولو"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Tietosi tallennetaan paikallisesti"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Tietosi tallennetaan turvallisesti eikä niitä koskaan jaeta kolmansille osapuolille. Voit myös itseisännöidä Reactive Resumen omilla palvelimillasi täydellisen tietokontrollin takaamiseksi."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Salasanasi on palautettu onnistuneesti. Voit nyt kirjautua sisään uudella salasanallasi."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Loitonna"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Vos données sont stockées localement"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Vos données sont stockées en toute sécurité et ne sont jamais partagées avec des tiers. Vous pouvez également héberger Reactive Resume sur vos propres serveurs pour un contrôle total de vos données."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Votre mot de passe a été réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zoom arrière"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zoulou"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "הנתונים שלך מאוחסנים באופן מקומי"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "הנתונים שלך מאוחסנים בצורה מאובטחת ולעולם אינם משותפים עם צדדים שלישיים. אפשר גם לארח את Reactive Resume בשרתים שלך כדי לשלוט לחלוטין בנתונים שלך."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "הסיסמה שלך אופסה בהצלחה. כעת אפשר להתחבר עם הסיסמה החדשה שלך."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "התרחקות"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "זולו"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "आपका डेटा लोकल रूप से संग्र
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "आपका डेटा सुरक्षित रूप से संग्रहीत है और कभी भी थर्ड पार्टियों के साथ साझा नहीं किया जाता। आप अपने सर्वर्स पर Reactive Resume को स्व‑होस्ट भी कर सकते हैं ताकि अपने डेटा पर पूर्ण नियंत्रण रख सकें।"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "आपका पासवर्ड सफलतापूर्वक रीसेट कर दिया गया है। अब आप अपने नए पासवर्ड से साइन इन कर सकते हैं।"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "ज़ूम आउट"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ज़ुलु"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Az adataid helyben tárolódnak"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Az adataid biztonságosan tároljuk, és soha nem osztjuk meg harmadik felekkel. A Reactive Resume‑t saját szervereiden is hosztolhatod, hogy teljes kontrollod legyen az adataid felett."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "A jelszavadat sikeresen visszaállítottuk. Mostantól az új jelszóval jelentkezhetsz be."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Kicsinyítés"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Data Anda disimpan secara lokal"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Data Anda disimpan dengan aman dan tidak pernah dibagikan dengan pihak ketiga. Anda juga dapat self-host Reactive Resume di server Anda sendiri untuk kontrol penuh atas data Anda."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Kata sandi Anda telah berhasil diatur ulang. Anda sekarang dapat masuk dengan kata sandi baru Anda."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Perkecil"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "I tuoi dati vengono memorizzati localmente"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "I tuoi dati sono archiviati in modo sicuro e non vengono mai condivisi con terze parti. Puoi anche fare self-host di Reactive Resume sui tuoi server per avere il pieno controllo dei tuoi dati."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "La tua password è stata reimpostata correttamente. Ora puoi accedere con la tua nuova password."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Rimpicciolisci"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "あなたのデータはローカルに保存されます。"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "あなたのデータは安全に保存され、第三者と共有されることはありません。また、完全にデータを管理したい場合は、Reactive Resume を自分のサーバーにセルフホストすることもできます。"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "パスワードを正常にリセットしました。新しいパスワードでサインインできます。"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "ズームアウト"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ズールー語"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "ទិន្នន័យរបស់អ្នកត្រូវ
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "ទិន្នន័យរបស់អ្នកត្រូវបានរក្សាទុកដោយសុវត្ថិភាព ហើយមិនដែលត្រូវបានចែករំលែកជាមួយភាគីទីបីទេ។ អ្នកក៏អាចដំឡើង Reactive Resume លើម៉ាស៊ីនបម្រើផ្ទាល់ខ្លួនរបស់អ្នក ដើម្បីមានសិទ្ធિត្រួតត្រាលើទិន្នន័យរបស់អ្នកដល់ខ្ពស់បំផុតផងដែរ។"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "ពាក្យសម្ងាត់របស់អ្នកត្រូវបានកំណត់ឡើងវិញដោយជោគជ័យ។ ឥឡូវអ្នកអាចចូលដោយប្រើពាក្យសម្ងាត់ថ្មីរបស់អ្នកបានហើយ។"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "បង្រួម"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "ನಿಮ್ಮ ಡೇಟಾವನ್ನು ಸ್ಥಳೀಯವಾಗ
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "ನಿಮ್ಮ ಡೇಟಾವನ್ನು ಸುರಕ್ಷಿತವಾಗಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ ಮತ್ತು ಎಂದಿಗೂ ತೃತೀಯಪಕ್ಷಗಳೊಂದಿಗೆ ಹಂಚಿಕೊಳ್ಳಲಾಗುವುದಿಲ್ಲ. ನಿಮ್ಮ ಡೇಟಾದ ಮೇಲೆ ಸಂಪೂರ್ಣ ನಿಯಂತ್ರಣಕ್ಕಾಗಿ ನಿಮ್ಮದೇ ಸರ್ವರ್ಗಳಲ್ಲಿ Reactive Resume ಅನ್ನು ಸ್ವಯಂ-ಹೋಸ್ಟ್ ಮಾಡಬಹುದು."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "ನಿಮ್ಮ ಪಾಸ್ವರ್ಡ್ ಅನ್ನು ಯಶಸ್ವಿಯಾಗಿ ಮರುಹೊಂದಿಸಲಾಗಿದೆ. ಈಗ ನೀವು ನಿಮ್ಮ ಹೊಸ ಪಾಸ್ವರ್ಡ್ ಬಳಸಿಕೊಂಡು ಸೈನ್ ಇನ್ ಮಾಡಬಹುದು."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "ಗಾತ್ರ ಕುಗ್ಗಿಸಿ"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ಜೂಲೂ"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "데이터는 브라우저에 로컬로 저장됩니다"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "데이터는 안전하게 저장되며 제3자와 공유되지 않습니다. 또한 Reactive Resume를 직접 서버에 호스팅해 데이터에 대한 완전한 제어권을 가질 수 있습니다."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "비밀번호가 성공적으로 재설정되었습니다. 이제 새 비밀번호로 로그인할 수 있습니다."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "축소"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "줄루어"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Jūsų duomenys saugomi vietoje"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Jūsų duomenys saugiai laikomi ir niekada nebendrinami su trečiosiomis šalimis. Taip pat galite savarankiškai talpinti „Reactive Resume“ savo serveriuose ir visiškai kontroliuoti savo duomenis."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Jūsų slaptažodis sėkmingai atstatytas. Dabar galite prisijungti naudodami naują slaptažodį."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Tolinti"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulų"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Jūsu dati tiek glabāti lokāli"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Jūsu dati tiek glabāti droši un nekad netiek kopīgoti ar trešajām pusēm. Reactive Resume varat arī pašhostēt uz saviem serveriem, lai pilnībā kontrolētu savus datus."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Jūsu parole ir veiksmīgi atiestatīta. Tagad varat pieteikties ar jauno paroli."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Tālināt"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "നിങ്ങളുടെ ഡാറ്റ നിങ്ങളുടെ
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "നിങ്ങളുടെ ഡാറ്റ സുരക്ഷിതമായി സൂക്ഷിക്കപ്പെടുന്നു, ഒരിക്കലും മൂന്നാം കക്ഷികളുമായി പങ്കിടുന്നില്ല. നിങ്ങളുടെ ഡാറ്റയ്ക്ക് പൂർണ്ണ നിയന്ത്രണം നേടാൻ Reactive Resume നിങ്ങളുടെ സ്വന്തം സെർവറുകളിൽ സെൽഫ്‑ഹോസ്റ്റ് ചെയ്യാനും നിങ്ങൾക്ക് കഴിയും."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "നിങ്ങളുടെ പാസ്വേഡ് വിജയകരമായി റീസെറ്റ് ചെയ്തിട്ടുണ്ട്. ഇനി നിങ്ങൾക്ക് പുതിയ പാസ്വേഡ് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാം."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "സൂം ഔട്ട് ചെയ്യുക"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "സൂളു"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "तुमचा डेटा लोकली साठवला जा
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "तुमचा डेटा सुरक्षितपणे साठवला जातो आणि तृतीय पक्षांसोबत कधीही शेअर केला जात नाही. तुम्ही पूर्ण डेटा कंट्रोलसाठी Reactive Resume स्वतःच्या सर्व्हरवरही होस्ट करू शकता."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "तुमचा पासवर्ड यशस्वीरित्या रिसेट केला गेला आहे. तुम्ही आता नवीन पासवर्डने साइन इन करू शकता."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "बाहेर झूम करा"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "झुलू"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Data anda disimpan secara setempat"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Data anda disimpan dengan selamat dan tidak pernah dikongsi dengan pihak ketiga. Anda juga boleh menghoskan sendiri Reactive Resume pada pelayan anda untuk kawalan penuh ke atas data anda."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Kata laluan anda telah berjaya ditetapkan semula. Anda kini boleh log masuk dengan kata laluan baharu anda."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zum keluar"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "तपाईंको डाटा स्थानीय रूपमा
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "तपाईंको डाटा सुरक्षित रूपमा भण्डारण गरिएको छ र तेस्रो पक्षसँग कहिल्यै सेयर गरिँदैन। तपाईंले चाहनु भएमा आफ्नै सर्भरमा Reactive Resume लाई self-host गरेर आफ्नो डाटामा पूर्ण नियन्त्रण राख्न सक्नुहुन्छ।"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "तपाईंको पासवर्ड सफलतापूर्वक रिसेट भयो। अब नयाँ पासवर्ड प्रयोग गरेर साइन इन गर्न सक्नुहुन्छ।"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "जूम आउट"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "जुलु"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Uw gegevens worden lokaal opgeslagen"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Uw gegevens worden veilig opgeslagen en nooit met derden gedeeld. U kunt Reactive Resume ook zelf op uw eigen servers hosten voor volledige controle over uw gegevens."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Uw wachtwoord is succesvol gereset. U kunt nu met uw nieuwe wachtwoord inloggen."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Uitzoomen"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Dataene dine lagres lokalt"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Dataene dine lagres sikkert og deles aldri med tredjeparter. Du kan også selv-hoste Reactive Resume på egne servere for full kontroll over dataene dine."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Passordet ditt er tilbakestilt. Du kan nå logge inn med det nye passordet ditt."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zoom ut"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "ଆପଣଙ୍କ ତଥ୍ୟ ସ୍ଥାନୀୟ ଭାବରେ
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "ଆପଣଙ୍କ ତଥ୍ୟ ସୁରକ୍ଷିତ ଭାବରେ ସଞ୍ଚୟ ହୋଇଛି ଏବଂ କେବେ ମଧ୍ୟ ତୃତୀୟ ପକ୍ଷ ସହ ସେୟାର ହୋଇନାହିଁ। ଆପଣ ଆପଣଙ୍କ ତଥ୍ୟ ଉପରେ ପୂର୍ଣ୍ଣ ନିୟନ୍ତ୍ରଣ ପାଇଁ ଆପଣଙ୍କ ନିଜ ସର୍ଭରଗୁଡିକରେ Reactive Resume କୁ ସ୍ୱୟଂ‑ହୋଷ୍ଟ କରିପାରିବେ।"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "ଆପଣଙ୍କ ପାସୱାର୍ଡ ସଫଳତାର ସହ ପୁନଃ ସେଟ୍ କରାଯାଇଛି। ଆପଣ ଏବେ ନୂତନ ପାସୱାର୍ଡ ସହ ସାଇନ୍ ଇନ୍ କରିପାରିବେ।"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "ଜୁମ୍ ଆଉଟ୍ କରନ୍ତୁ"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ଜୁଲୁ"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Twoje dane są przechowywane lokalnie"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Twoje dane są przechowywane w bezpieczny sposób i nigdy nie są udostępniane podmiotom trzecim. Możesz też samodzielnie hostować Reactive Resume na własnych serwerach, aby mieć pełną kontrolę nad swoimi danymi."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Twoje hasło zostało pomyślnie zresetowane. Możesz teraz zalogować się przy użyciu nowego hasła."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Pomniejsz"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Seus dados são armazenados localmente"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Seus dados são armazenados com segurança e nunca são compartilhados com terceiros. Você também pode auto-hospedar o Reactive Resume em seus próprios servidores para ter controle total sobre seus dados."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Sua senha foi redefinida com sucesso. Agora você pode entrar com sua nova senha."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Diminuir zoom"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Os seus dados são armazenados localmente"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Os seus dados são armazenados em segurança e nunca são partilhados com terceiros. Também pode auto-hospedar o Reactive Resume nos seus próprios servidores para ter controlo total sobre os seus dados."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "A sua senha foi reposta com sucesso. Já pode iniciar sessão com a sua nova senha."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Afastar"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Datele dvs. sunt stocate local"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Datele dvs. sunt stocate în siguranță și nu sunt niciodată partajate cu terți. De asemenea, puteți auto-găzdui Reactive Resume pe propriile servere pentru control complet asupra datelor."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Parola a fost resetată cu succes. Vă puteți autentifica acum cu noua parolă."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Micșorează"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Ваши данные хранятся локально"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Ваши данные надежно хранятся и никогда не передаются третьим лицам. Вы также можете развернуть Reactive Resume на своих серверах, чтобы полностью контролировать свои данные."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Ваш пароль был успешно сброшен. Теперь вы можете войти с новым паролем."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Уменьшить"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Зулу"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Tvoje dáta sú uložené lokálne"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Tvoje dáta sú bezpečne uložené a nikdy sa nezdieľajú s tretími stranami. Reactive Resume si môžeš tiež hostovať na vlastných serveroch a mať nad dátami úplnú kontrolu."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Tvoje heslo bolo úspešne obnovené. Teraz sa môžeš prihlásiť s novým heslom."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Oddialiť"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Vaši podatki so shranjeni lokalno"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Vaši podatki so varno shranjeni in se nikoli ne delijo s tretjimi osebami. Reactive Življenjepis lahko tudi gostite sami na svojih strežnikih za popoln nadzor nad svojimi podatki."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Vaše geslo je bilo uspešno ponastavljeno. Zdaj se lahko prijavite z novim geslom."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Oddalji"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zuluščina"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Të dhënat tuaja ruhen lokalisht"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Të dhënat tuaja ruhen në mënyrë të sigurt dhe nuk ndahen kurrë me palë të treta. Ju gjithashtu mund ta self-host-oni Reactive Resume në serverët tuaj për kontroll të plotë mbi të dhënat tuaja."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Fjalëkalimi juaj u rivendos me sukses. Tani mund të hyni me fjalëkalimin tuaj të ri."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zvogëlo"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Ваши подаци се чувају локално"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Ваши подаци се чувају безбедно и никада се не деле са трећим странама. Такође можете самостално хостовати Реактивни Резиме на сопственим серверима за потпуну контролу над подацима."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Ваша лозинка је успешно ресетована. Сада можете да се пријавите новом лозинком."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Умањи"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Зулу"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Dina data lagras lokalt"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Dina data lagras säkert och delas aldrig med tredje part. Du kan också själv hosta Reactive Resume på dina egna servrar för full kontroll över dina data."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Ditt lösenord har återställts. Du kan nu logga in med ditt nya lösenord."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Zooma ut"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "உங்கள் தரவு உங்கள் உலாவியி
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "உங்கள் தரவு பாதுகாப்பாக சேமிக்கப்படுகிறது மற்றும் மூன்றாம் தரப்புகளுடன் ஒருபோதும் பகிரப்படாது. உங்கள் தரவு மீது முழு கட்டுப்பாட்டிற்காக Reactive Resume-ஐ உங்கள் சொந்த சேவையகங்களில் தானே ஹோஸ்ட் செய்யவும் முடியும்."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "உங்கள் கடவுச்சொல் வெற்றிகரமாக மீட்டமைக்கப்பட்டுள்ளது. இப்போது உங்கள் புதிய கடவுச்சொல்லைப் பயன்படுத்தி உள்நுழையலாம்."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "சிறிதாக்கு"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ஜூலு"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "మీ డేటా లోకల్గా నిల్వ చేయ
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "మీ డేటా భద్రంగా నిల్వ చేయబడుతుంది, మూడవ పక్షాలతో ఎప్పుడూ పంచుకోబడదు. మీ డేటాపై పూర్తి నియంత్రణ కోసం మీరు Reactive Resumeను మీ సొంత సర్వర్లపై హోస్ట్ చేసుకోవచ్చు."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "మీ పాస్వర్డ్ విజయవంతంగా రీసెట్ చేయబడింది. ఇప్పుడు మీ కొత్త పాస్వర్డ్తో సైన్ ఇన్ చేయవచ్చు."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "జూమ్ అవుట్ చేయండి"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "జులు"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "ข้อมูลของคุณถูกจัดเก็บไว
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "ข้อมูลของคุณถูกจัดเก็บไว้อย่างปลอดภัยและจะไม่ถูกแชร์กับบุคคลที่สาม คุณยังสามารถโฮสต์ Reactive Resume ด้วยตัวเองบนเซิร์ฟเวอร์ของคุณเพื่อควบคุมข้อมูลได้อย่างสมบูรณ์."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "รหัสผ่านของคุณถูกรีเซ็ตเรียบร้อยแล้ว ตอนนี้คุณสามารถเข้าสู่ระบบด้วยรหัสผ่านใหม่ของคุณได้."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "ซูมออก"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "ซูลู"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Verileriniz yerel olarak depolanıyor"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Verileriniz güvenli bir şekilde saklanır ve asla üçüncü partilerle paylaşılmaz. Kendi sunucularınızda Reactive Resume'u barındırarak verileriniz üzerinde tam kontrol sahibi olabilirsiniz."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Şifreniz başarıyla sıfırlandı. Artık yeni şifrenizle giriş yapabilirsiniz."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Uzaklaştır"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Ваші дані зберігаються локально"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Ваші дані зберігаються безпечно і ніколи не передаються третім особам. Ви також можете самостійно розгорнути Reactive Resume на власних серверах для повного контролю над своїми даними."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Ваш пароль було успішно скинуто. Тепер ви можете увійти з новим паролем."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Зменшити"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Зулу"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Maʼlumotlaringiz lokal tarzda saqlanadi"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Maʼlumotlaringiz xavfsiz saqlanadi va uchinchi tomonlarga hech qachon ulashilmaydi. Shuningdek, rezyume qurilmasini o‘z serverlaringizda mustaqil joylashtirishingiz va maʼlumotlaringiz ustidan to‘liq nazorat qilishingiz mumkin."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Parolingiz muvaffaqiyatli tiklandi. Endi yangi parolingiz bilan tizimga kirishingiz mumkin."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Kichraytirish"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "Dữ liệu của bạn được lưu trữ cục bộ"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "Dữ liệu của bạn được lưu trữ an toàn và không bao giờ chia sẻ với bên thứ ba. Bạn cũng có thể tự host Reactive Resume trên máy chủ của riêng mình để kiểm soát hoàn toàn dữ liệu của mình."
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "Mật khẩu của bạn đã được đặt lại thành công. Bây giờ bạn có thể đăng nhập bằng mật khẩu mới."
|
||||
@@ -3244,4 +3248,3 @@ msgstr "Thu nhỏ"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "Zulu"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "你的数据将存储在本地"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "你的数据会被安全存储,且绝不会与第三方共享。你也可以在自己的服务器上自托管 Reactive Resume,以完全掌控你的数据。"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "你的密码已成功重置。你现在可以使用新密码登录。"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "缩小"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "祖鲁语"
|
||||
|
||||
|
||||
Generated
+4
-1
@@ -3186,6 +3186,10 @@ msgstr "您的資料將儲存在本機裝置中"
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr "您的資料會被安全保存,且絕不會分享給第三方。您也可以在自有伺服器上自行託管 Reactive Resume,完全掌控自己的資料。"
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr "您的密碼已成功重設。您現在可以使用新密碼登入。"
|
||||
@@ -3244,4 +3248,3 @@ msgstr "縮小"
|
||||
#: src/utils/locale.ts
|
||||
msgid "Zulu"
|
||||
msgstr "祖魯語"
|
||||
|
||||
|
||||
Generated
+4
@@ -3181,6 +3181,10 @@ msgstr ""
|
||||
msgid "Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data."
|
||||
msgstr ""
|
||||
|
||||
#: src/components/resume/store/resume.ts
|
||||
msgid "Your latest changes could not be saved. Please make sure you are connected to the internet and try again."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/auth/reset-password.tsx
|
||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
||||
msgstr ""
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user';--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp(6) with time zone;
|
||||
File diff suppressed because it is too large
Load Diff
+25
-23
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reactive-resume",
|
||||
"version": "5.0.14",
|
||||
"version": "5.0.15",
|
||||
"description": "Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -31,13 +31,14 @@
|
||||
"preview": "vp preview",
|
||||
"start": "node .output/server/index.mjs",
|
||||
"test": "vp test",
|
||||
"test:coverage": "vp test --coverage --run",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.64",
|
||||
"@ai-sdk/google": "^3.0.53",
|
||||
"@ai-sdk/openai": "^3.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"@ai-sdk/google": "^3.0.55",
|
||||
"@ai-sdk/openai": "^3.0.49",
|
||||
"@aws-sdk/client-s3": "^3.1022.0",
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@better-auth/api-key": "^1.5.6",
|
||||
"@better-auth/drizzle-adapter": "^1.5.6",
|
||||
@@ -50,7 +51,7 @@
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lingui/core": "^5.9.4",
|
||||
"@lingui/react": "^5.9.4",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@monaco-editor/react": "4.8.0-rc.3",
|
||||
"@orpc/client": "^1.13.13",
|
||||
"@orpc/json-schema": "^1.13.13",
|
||||
@@ -62,20 +63,20 @@
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.8",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.10",
|
||||
"@tanstack/react-start": "^1.167.13",
|
||||
"@tanstack/react-start": "^1.167.16",
|
||||
"@tanstack/zod-adapter": "^1.166.9",
|
||||
"@tiptap/extension-highlight": "^3.21.0",
|
||||
"@tiptap/extension-table": "^3.21.0",
|
||||
"@tiptap/extension-text-align": "^3.21.0",
|
||||
"@tiptap/pm": "^3.21.0",
|
||||
"@tiptap/react": "^3.21.0",
|
||||
"@tiptap/starter-kit": "^3.21.0",
|
||||
"@tiptap/extension-highlight": "^3.22.0",
|
||||
"@tiptap/extension-table": "^3.22.0",
|
||||
"@tiptap/extension-text-align": "^3.22.0",
|
||||
"@tiptap/pm": "^3.22.0",
|
||||
"@tiptap/react": "^3.22.0",
|
||||
"@tiptap/starter-kit": "^3.22.0",
|
||||
"@uiw/color-convert": "^2.9.6",
|
||||
"@uiw/react-color-colorful": "^2.9.6",
|
||||
"ai": "^6.0.141",
|
||||
"ai": "^6.0.142",
|
||||
"ai-sdk-ollama": "^3.8.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.5.6",
|
||||
@@ -106,10 +107,10 @@
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-window": "^2.2.7",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"shadcn": "^4.1.1",
|
||||
"shadcn": "^4.1.2",
|
||||
"sharp": "^0.34.5",
|
||||
"sonner": "^2.0.7",
|
||||
"srvx": "^0.11.13",
|
||||
"srvx": "^0.11.14",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"ts-pattern": "^5.9.0",
|
||||
@@ -129,6 +130,7 @@
|
||||
"@lingui/vite-plugin": "^5.9.4",
|
||||
"@rolldown/plugin-babel": "^0.2.2",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^25.5.0",
|
||||
@@ -136,24 +138,24 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260329.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260401.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"drizzle-kit": "1.0.0-beta.20",
|
||||
"happy-dom": "^20.8.9",
|
||||
"jose": "^6.2.2",
|
||||
"jsdom": "^29.0.1",
|
||||
"knip": "^6.1.0",
|
||||
"knip": "^6.2.0",
|
||||
"nitro": "3.0.260311-beta",
|
||||
"node-addon-api": "^8.7.0",
|
||||
"node-gyp": "^12.2.0",
|
||||
"npm-check-updates": "^19.6.6",
|
||||
"tsx": "^4.21.0",
|
||||
"npm-check-updates": "^20.0.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plus": "latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@isaacs/brace-expansion": "^5.0.1",
|
||||
|
||||
Generated
+889
-636
File diff suppressed because it is too large
Load Diff
@@ -192,8 +192,7 @@ export async function migrateResumes() {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", handleShutdown);
|
||||
process.on("SIGTERM", handleShutdown);
|
||||
process.on("exit", handleShutdown);
|
||||
|
||||
// Initialize the importer
|
||||
const importer = new ReactiveResumeV4JSONImporter();
|
||||
@@ -465,8 +464,7 @@ export async function migrateResumes() {
|
||||
}
|
||||
|
||||
// Remove signal handlers
|
||||
process.off("SIGINT", handleShutdown);
|
||||
process.off("SIGTERM", handleShutdown);
|
||||
process.off("exit", handleShutdown);
|
||||
|
||||
const migrationEnd = performance.now();
|
||||
const migrationDurationMs = migrationEnd - migrationStart;
|
||||
|
||||
@@ -186,8 +186,7 @@ export async function migrateUsers() {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", handleShutdown);
|
||||
process.on("SIGTERM", handleShutdown);
|
||||
process.on("exit", handleShutdown);
|
||||
|
||||
while (hasMore) {
|
||||
// Check if shutdown was requested
|
||||
@@ -409,8 +408,7 @@ export async function migrateUsers() {
|
||||
}
|
||||
|
||||
// Remove signal handlers
|
||||
process.off("SIGINT", handleShutdown);
|
||||
process.off("SIGTERM", handleShutdown);
|
||||
process.off("exit", handleShutdown);
|
||||
|
||||
const migrationEnd = performance.now();
|
||||
const migrationDurationMs = migrationEnd - migrationStart;
|
||||
|
||||
@@ -69,28 +69,18 @@ export const CometCard = ({
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={{ scale: 1, z: 0 }}
|
||||
className="relative rounded-md"
|
||||
style={{
|
||||
rotateX: rotateX,
|
||||
rotateY: rotateY,
|
||||
translateX: translateX,
|
||||
translateY: translateY,
|
||||
willChange: "transform",
|
||||
}}
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="relative rounded-md will-change-transform"
|
||||
whileHover={{ z: 50, scale: scaleFactor, transition: { duration: 0.2 } }}
|
||||
style={{ rotateX: rotateX, rotateY: rotateY, translateX: translateX, translateY: translateY }}
|
||||
>
|
||||
{children}
|
||||
|
||||
<motion.div
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{
|
||||
background: glareBackground,
|
||||
opacity: glareOpacity,
|
||||
willChange: "opacity",
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay"
|
||||
style={{ background: glareBackground, opacity: glareOpacity }}
|
||||
className="pointer-events-none absolute inset-0 z-50 h-full w-full rounded-md mix-blend-overlay will-change-[opacity]"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,7 @@ export const Spotlight = ({
|
||||
<motion.div
|
||||
animate={{ x: [0, xOffset, 0] }}
|
||||
transition={{ duration, repeat: Infinity, repeatType: "reverse", ease: "easeInOut" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw"
|
||||
style={{ willChange: "transform" }}
|
||||
className="pointer-events-none absolute inset-s-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-s-0 top-0"
|
||||
@@ -69,14 +68,8 @@ export const Spotlight = ({
|
||||
|
||||
<motion.div
|
||||
animate={{ x: [0, -xOffset, 0] }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw"
|
||||
transition={{
|
||||
duration,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
repeatType: "reverse",
|
||||
}}
|
||||
style={{ willChange: "transform" }}
|
||||
transition={{ duration, repeat: Infinity, ease: "easeInOut", repeatType: "reverse" }}
|
||||
className="pointer-events-none absolute inset-e-0 top-0 z-40 h-svh w-svw will-change-transform"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-e-0 top-0"
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock dnd-kit
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
closestCenter: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
PointerSensor: vi.fn(),
|
||||
useSensor: vi.fn(() => ({})),
|
||||
useSensors: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
sortableKeyboardCoordinates: vi.fn(),
|
||||
useSortable: () => ({
|
||||
attributes: {},
|
||||
listeners: {},
|
||||
setNodeRef: vi.fn(),
|
||||
transform: null,
|
||||
transition: null,
|
||||
isDragging: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/utilities", () => ({
|
||||
CSS: { Transform: { toString: () => "" } },
|
||||
}));
|
||||
|
||||
// Mock framer motion
|
||||
vi.mock("motion/react", () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock lingui
|
||||
vi.mock("@lingui/react/macro", () => ({
|
||||
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock phosphor icons
|
||||
vi.mock("@phosphor-icons/react", () => ({
|
||||
PencilSimpleIcon: ({ className }: any) => <span data-testid="edit-icon" className={className} />,
|
||||
XIcon: ({ className }: any) => <span data-testid="remove-icon" className={className} />,
|
||||
}));
|
||||
|
||||
import { ChipInput } from "./chip-input";
|
||||
|
||||
describe("ChipInput", () => {
|
||||
describe("rendering", () => {
|
||||
it("renders the input field", () => {
|
||||
render(<ChipInput />);
|
||||
expect(screen.getByRole("textbox")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders existing chips", () => {
|
||||
render(<ChipInput value={["React", "TypeScript", "Node"]} />);
|
||||
expect(screen.getByText("React")).toBeDefined();
|
||||
expect(screen.getByText("TypeScript")).toBeDefined();
|
||||
expect(screen.getByText("Node")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders no chips when value is empty", () => {
|
||||
render(<ChipInput value={[]} />);
|
||||
|
||||
// Only the input area, no chip badges
|
||||
expect(screen.queryByText("React")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows description by default", () => {
|
||||
render(<ChipInput />);
|
||||
expect(screen.getByText(/Enter/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides description when hideDescription is true", () => {
|
||||
render(<ChipInput hideDescription />);
|
||||
expect(screen.queryByText(/Enter/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding chips via Enter key", () => {
|
||||
it("adds a chip when Enter is pressed", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
|
||||
it("does not add empty chips", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("trims whitespace from chips", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: " React " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
|
||||
it("prevents duplicate chips", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["React"]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
// Set is used, so duplicates are filtered — onChange should be called with same array
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adding chips via comma", () => {
|
||||
it("adds a chip when comma is typed in the input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React," } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React"]);
|
||||
});
|
||||
|
||||
it("handles multiple comma-separated values", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={[]} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React,TypeScript," } });
|
||||
|
||||
// First call adds "React", second call adds "TypeScript"
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing chips", () => {
|
||||
it("calls onChange without the removed chip when remove button is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["React", "Vue", "Angular"]} onChange={onChange} />);
|
||||
|
||||
// Find all remove buttons
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
fireEvent.click(removeButtons[1]); // Remove "Vue"
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React", "Angular"]);
|
||||
});
|
||||
|
||||
it("removes the first chip correctly", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
|
||||
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
fireEvent.click(removeButtons[0]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["B", "C"]);
|
||||
});
|
||||
|
||||
it("removes the last chip correctly", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["A", "B", "C"]} onChange={onChange} />);
|
||||
|
||||
const removeButtons = screen.getAllByLabelText(/Remove/);
|
||||
fireEvent.click(removeButtons[2]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["A", "B"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing chips", () => {
|
||||
it("enters edit mode when edit button is clicked", () => {
|
||||
render(<ChipInput value={["React", "Vue"]} />);
|
||||
|
||||
const editButtons = screen.getAllByLabelText(/Edit/);
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
expect((input as HTMLInputElement).value).toBe("React");
|
||||
expect(input.getAttribute("aria-label")).toBe("Edit keyword");
|
||||
});
|
||||
|
||||
it("exits edit mode with Escape key", () => {
|
||||
render(<ChipInput value={["React"]} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit React");
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
expect(input.getAttribute("aria-label")).toBe("Add keyword");
|
||||
});
|
||||
|
||||
it("saves edit when Enter is pressed", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ChipInput value={["React"]} onChange={onChange} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit React");
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "React.js" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(["React.js"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("input placeholder", () => {
|
||||
it("shows 'Add a keyword...' by default", () => {
|
||||
render(<ChipInput />);
|
||||
expect(screen.getByPlaceholderText("Add a keyword...")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows 'Editing keyword...' in edit mode", () => {
|
||||
render(<ChipInput value={["Test"]} />);
|
||||
|
||||
const editButton = screen.getByLabelText("Edit Test");
|
||||
fireEvent.click(editButton);
|
||||
|
||||
expect(screen.getByPlaceholderText("Editing keyword...")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("uncontrolled mode", () => {
|
||||
it("works with defaultValue", () => {
|
||||
render(<ChipInput defaultValue={["Initial"]} />);
|
||||
expect(screen.getByText("Initial")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -70,11 +70,10 @@ function ChipItem({ id, chip, index, isEditing, onEdit, onRemove }: ChipItemProp
|
||||
>
|
||||
<span className="max-w-32 truncate">{chip}</span>
|
||||
<motion.div
|
||||
initial={false}
|
||||
initial="false"
|
||||
animate={isHovered ? { opacity: 1, scaleX: 1, x: 0 } : { opacity: 0, scaleX: 0.95, x: -3 }}
|
||||
transition={{ duration: 0.12, ease: "easeOut" }}
|
||||
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden"
|
||||
style={{ willChange: "transform, opacity" }}
|
||||
className="ms-2 flex w-10 shrink-0 origin-left items-center gap-x-1 overflow-hidden will-change-[transform,opacity]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock lingui macros
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray) => strings[0],
|
||||
}));
|
||||
vi.mock("@lingui/react/macro", () => ({
|
||||
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Mock phosphor icons
|
||||
vi.mock("@phosphor-icons/react", () => ({
|
||||
TagIcon: () => <span data-testid="tag-icon" />,
|
||||
}));
|
||||
|
||||
import { URLInput } from "./url-input";
|
||||
|
||||
describe("URLInput", () => {
|
||||
describe("URL prefix handling", () => {
|
||||
it("displays URL without https:// prefix", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
|
||||
|
||||
const input = screen.getByDisplayValue("example.com");
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays empty string when URL is empty", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
const urlInput = inputs[0];
|
||||
expect((urlInput as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("adds https:// prefix when user types", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "example.com" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
label: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-prefix when user types https://", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "https://example.com" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "https://example.com",
|
||||
label: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends empty URL when input is cleared", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "https://example.com", label: "" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "",
|
||||
label: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("label preservation", () => {
|
||||
it("preserves label when URL changes", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "https://old.com", label: "My Site" }} onChange={onChange} />);
|
||||
|
||||
const inputs = screen.getAllByRole("textbox");
|
||||
fireEvent.change(inputs[0], { target: { value: "new.com" } });
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
url: "https://new.com",
|
||||
label: "My Site",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("hideLabelButton prop", () => {
|
||||
it("hides the label button when hideLabelButton is true", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} hideLabelButton={true} />);
|
||||
|
||||
expect(screen.queryByTestId("tag-icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the label button by default", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByTestId("tag-icon")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("displays the https:// prefix text", () => {
|
||||
it("shows https:// as static text", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<URLInput value={{ url: "", label: "" }} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByText("https://")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mock @lingui/core/macro
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray, ...values: unknown[]) => {
|
||||
let result = strings[0];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
result += String(values[i]) + strings[i + 1];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock PageIcon to avoid deep dependency chain
|
||||
vi.mock("../resume/shared/page-icon", () => ({
|
||||
PageIcon: ({ icon, className }: { icon: string; className?: string }) => (
|
||||
<span data-testid="page-icon" data-icon={icon} className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
import { LevelDisplay } from "./display";
|
||||
|
||||
describe("LevelDisplay", () => {
|
||||
describe("returns null for invalid states", () => {
|
||||
it("returns null when level is 0", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={0} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when type is 'hidden'", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="hidden" level={3} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when icon is empty string", () => {
|
||||
const { container } = render(<LevelDisplay icon="" type="circle" level={3} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders correct number of elements", () => {
|
||||
it("always renders 5 elements for non-zero level", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders 5 elements for level 1", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="square" level={1} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("renders 5 elements for level 5", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={5} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("active state for circle/square/rectangle types", () => {
|
||||
it("marks correct number of items as active for level 3", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("marks 1 item active for level 1", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="square" level={1} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("marks all 5 items active for level 5", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={5} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("progress-bar type", () => {
|
||||
it("renders progress bar elements", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="progress-bar" level={3} />);
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper.children).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("has correct active count for progress-bar", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="progress-bar" level={2} />);
|
||||
const items = Array.from((container.firstChild as HTMLElement).children);
|
||||
const activeItems = items.filter((el) => el.getAttribute("data-active") === "true");
|
||||
expect(activeItems).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("icon type", () => {
|
||||
it("renders PageIcon components", () => {
|
||||
render(<LevelDisplay icon="star" type="icon" level={3} />);
|
||||
const icons = screen.getAllByTestId("page-icon");
|
||||
expect(icons).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("passes the icon name to PageIcon", () => {
|
||||
render(<LevelDisplay icon="star" type="icon" level={2} />);
|
||||
const icons = screen.getAllByTestId("page-icon");
|
||||
expect(icons[0].getAttribute("data-icon")).toBe("star");
|
||||
});
|
||||
|
||||
it("applies opacity to inactive icons", () => {
|
||||
render(<LevelDisplay icon="star" type="icon" level={2} />);
|
||||
const icons = screen.getAllByTestId("page-icon");
|
||||
// First 2 should be active (no opacity-40), last 3 inactive (opacity-40)
|
||||
expect(icons[0].className).not.toContain("opacity-40");
|
||||
expect(icons[1].className).not.toContain("opacity-40");
|
||||
expect(icons[2].className).toContain("opacity-40");
|
||||
expect(icons[3].className).toContain("opacity-40");
|
||||
expect(icons[4].className).toContain("opacity-40");
|
||||
});
|
||||
});
|
||||
|
||||
describe("aria attributes", () => {
|
||||
it("has aria-label showing level out of 5", () => {
|
||||
render(<LevelDisplay icon="star" type="circle" level={3} />);
|
||||
const el = screen.getByRole("presentation");
|
||||
expect(el.getAttribute("aria-label")).toContain("3");
|
||||
expect(el.getAttribute("aria-label")).toContain("5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies custom className to the wrapper", () => {
|
||||
const { container } = render(<LevelDisplay icon="star" type="circle" level={3} className="custom-level" />);
|
||||
expect((container.firstChild as HTMLElement).className).toContain("custom-level");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
import { LinkedTitle } from "./linked-title";
|
||||
|
||||
describe("LinkedTitle", () => {
|
||||
describe("renders as link when showLinkInTitle and website.url are provided", () => {
|
||||
it("renders an anchor tag", () => {
|
||||
render(
|
||||
<LinkedTitle title="Acme Corp" website={{ url: "https://acme.com", label: "Acme" }} showLinkInTitle={true} />,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "Acme Corp" });
|
||||
expect(link).toBeDefined();
|
||||
expect(link.getAttribute("href")).toBe("https://acme.com");
|
||||
});
|
||||
|
||||
it("sets target=_blank and rel=noopener on the link", () => {
|
||||
render(
|
||||
<LinkedTitle title="Company" website={{ url: "https://example.com", label: "" }} showLinkInTitle={true} />,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
expect(link.getAttribute("rel")).toBe("noopener");
|
||||
});
|
||||
|
||||
it("wraps the title in a strong tag inside the link", () => {
|
||||
render(
|
||||
<LinkedTitle title="Bold Title" website={{ url: "https://example.com", label: "" }} showLinkInTitle={true} />,
|
||||
);
|
||||
|
||||
const strong = screen.getByText("Bold Title");
|
||||
expect(strong.tagName).toBe("STRONG");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renders as plain strong tag otherwise", () => {
|
||||
it("renders strong when showLinkInTitle is false", () => {
|
||||
render(
|
||||
<LinkedTitle title="Plain Title" website={{ url: "https://example.com", label: "" }} showLinkInTitle={false} />,
|
||||
);
|
||||
|
||||
const strong = screen.getByText("Plain Title");
|
||||
expect(strong.tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders strong when showLinkInTitle is undefined", () => {
|
||||
render(<LinkedTitle title="No Link" />);
|
||||
|
||||
expect(screen.getByText("No Link").tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders strong when website is undefined", () => {
|
||||
render(<LinkedTitle title="No Website" showLinkInTitle={true} />);
|
||||
|
||||
expect(screen.getByText("No Website").tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders strong when website.url is empty", () => {
|
||||
render(<LinkedTitle title="Empty URL" website={{ url: "", label: "" }} showLinkInTitle={true} />);
|
||||
|
||||
expect(screen.getByText("Empty URL").tagName).toBe("STRONG");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("className prop", () => {
|
||||
it("applies className to the link element", () => {
|
||||
render(
|
||||
<LinkedTitle
|
||||
title="Styled"
|
||||
website={{ url: "https://x.com", label: "" }}
|
||||
showLinkInTitle={true}
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link.className).toContain("custom-class");
|
||||
});
|
||||
|
||||
it("applies className to the strong element", () => {
|
||||
render(<LinkedTitle title="Styled Strong" className="my-class" />);
|
||||
|
||||
const strong = screen.getByText("Styled Strong");
|
||||
expect(strong.className).toContain("my-class");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
// Mock dependencies before importing the store
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray) => strings[0],
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: vi.fn(() => "toast-id"),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/integrations/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
update: {
|
||||
call: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { defaultResumeData } from "@/schema/resume/data";
|
||||
|
||||
import { useResumeStore } from "./resume";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeResume(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
id: "test-resume-id",
|
||||
name: "Test Resume",
|
||||
slug: "test-resume",
|
||||
tags: ["test"],
|
||||
isLocked: false,
|
||||
data: structuredClone(defaultResumeData),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Reset store between tests
|
||||
useResumeStore.getState().initialize(null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// initialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — initialize", () => {
|
||||
it("sets resume and isReady to true", () => {
|
||||
const resume = makeResume();
|
||||
useResumeStore.getState().initialize(resume);
|
||||
|
||||
const state = useResumeStore.getState();
|
||||
expect(state.isReady).toBe(true);
|
||||
expect(state.resume.id).toBe("test-resume-id");
|
||||
expect(state.resume.name).toBe("Test Resume");
|
||||
});
|
||||
|
||||
it("sets isReady to false when initialized with null", () => {
|
||||
useResumeStore.getState().initialize(null);
|
||||
|
||||
const state = useResumeStore.getState();
|
||||
expect(state.isReady).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces previous resume data", () => {
|
||||
useResumeStore.getState().initialize(makeResume({ name: "First" }));
|
||||
expect(useResumeStore.getState().resume.name).toBe("First");
|
||||
|
||||
useResumeStore.getState().initialize(makeResume({ name: "Second" }));
|
||||
expect(useResumeStore.getState().resume.name).toBe("Second");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateResumeData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — updateResumeData", () => {
|
||||
it("updates resume data via immer draft", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Jane Doe";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.basics.name).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("can update nested fields", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.website.url = "https://example.com";
|
||||
draft.basics.email = "jane@example.com";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.basics.website.url).toBe("https://example.com");
|
||||
expect(useResumeStore.getState().resume.data.basics.email).toBe("jane@example.com");
|
||||
});
|
||||
|
||||
it("can add items to sections", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.sections.skills.items.push({
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(1);
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items[0].name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("does not update when resume is not initialized", () => {
|
||||
// Store starts with null resume
|
||||
useResumeStore.getState().initialize(null);
|
||||
|
||||
// Should not throw
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Should not apply";
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks updates when resume is locked", async () => {
|
||||
const { toast } = await import("sonner");
|
||||
useResumeStore.getState().initialize(makeResume({ isLocked: true }));
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Should not apply";
|
||||
});
|
||||
|
||||
// Name should remain unchanged
|
||||
expect(useResumeStore.getState().resume.data.basics.name).toBe("");
|
||||
// Should show error toast
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not block updates on unlocked resume", () => {
|
||||
useResumeStore.getState().initialize(makeResume({ isLocked: false }));
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Updated";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.basics.name).toBe("Updated");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multiple updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — multiple updates", () => {
|
||||
it("applies sequential updates correctly", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "First Update";
|
||||
});
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.headline = "Developer";
|
||||
});
|
||||
|
||||
const data = useResumeStore.getState().resume.data;
|
||||
expect(data.basics.name).toBe("First Update");
|
||||
expect(data.basics.headline).toBe("Developer");
|
||||
});
|
||||
|
||||
it("preserves unmodified sections across updates", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Jane";
|
||||
});
|
||||
|
||||
// Metadata and other sections should be untouched
|
||||
expect(useResumeStore.getState().resume.data.metadata.template).toBe("onyx");
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Temporal store (undo/redo)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — temporal", () => {
|
||||
it("has a temporal store accessible", () => {
|
||||
expect(useResumeStore.temporal).toBeDefined();
|
||||
expect(useResumeStore.temporal.getState).toBeDefined();
|
||||
});
|
||||
|
||||
it("temporal store has undo/redo actions", () => {
|
||||
const temporal = useResumeStore.temporal.getState();
|
||||
expect(typeof temporal.undo).toBe("function");
|
||||
expect(typeof temporal.redo).toBe("function");
|
||||
expect(typeof temporal.clear).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResumeStore — edge cases", () => {
|
||||
it("can update metadata fields", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.metadata.template = "pikachu";
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.metadata.template).toBe("pikachu");
|
||||
});
|
||||
|
||||
it("can update picture settings", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.picture.url = "https://example.com/photo.jpg";
|
||||
draft.picture.size = 120;
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.picture.url).toBe("https://example.com/photo.jpg");
|
||||
expect(useResumeStore.getState().resume.data.picture.size).toBe(120);
|
||||
});
|
||||
|
||||
it("can remove all items from a section", () => {
|
||||
const resume = makeResume();
|
||||
resume.data.sections.skills.items = [
|
||||
{ id: "s1", hidden: false, icon: "", name: "JS", proficiency: "", level: 0, keywords: [] },
|
||||
];
|
||||
useResumeStore.getState().initialize(resume);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.sections.skills.items = [];
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.sections.skills.items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("can update custom sections", () => {
|
||||
useResumeStore.getState().initialize(makeResume());
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.customSections.push({
|
||||
id: "custom-1",
|
||||
type: "experience",
|
||||
title: "Freelance",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
items: [],
|
||||
});
|
||||
});
|
||||
|
||||
expect(useResumeStore.getState().resume.data.customSections).toHaveLength(1);
|
||||
expect(useResumeStore.getState().resume.data.customSections[0].title).toBe("Freelance");
|
||||
});
|
||||
});
|
||||
@@ -32,12 +32,37 @@ type ResumeStore = ResumeStoreState & ResumeStoreActions;
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const _syncResume = (resume: Resume) => {
|
||||
void orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
let syncErrorToastId: string | number | undefined;
|
||||
|
||||
const _syncResume = async (resume: Resume) => {
|
||||
try {
|
||||
await orpc.resume.update.call({ id: resume.id, data: resume.data }, { signal });
|
||||
|
||||
// Dismiss error toast on successful sync
|
||||
if (syncErrorToastId !== undefined) {
|
||||
toast.dismiss(syncErrorToastId);
|
||||
syncErrorToastId = undefined;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Ignore aborted requests (e.g. page navigation)
|
||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||
|
||||
syncErrorToastId = toast.error(
|
||||
t`Your latest changes could not be saved. Please make sure you are connected to the internet and try again.`,
|
||||
{ id: syncErrorToastId, duration: Infinity },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const syncResume = debounce(_syncResume, 500, { signal });
|
||||
|
||||
// Flush pending sync before the page unloads to prevent data loss
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
syncResume.flush();
|
||||
});
|
||||
}
|
||||
|
||||
let errorToastId: string | number | undefined;
|
||||
|
||||
type PartializedState = { resume: Resume | null };
|
||||
|
||||
@@ -38,7 +38,7 @@ function AccordionContent({ className, children, ...props }: AccordionPrimitive.
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
"h-(--accordion-panel-height) pt-0 pb-4 data-ending-style:h-0 data-starting-style:h-0 [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { awardItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = awardItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateAwardDialog({ data }: DialogProps<"resume.sections.awards.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.awards.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "awards", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateAwardDialog({ data }: DialogProps<"resume.sections.awards.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.awards.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.awards.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "awards", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { certificationItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = certificationItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateCertificationDialog({ data }: DialogProps<"resume.sections
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.certifications.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "certifications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateCertificationDialog({ data }: DialogProps<"resume.sections
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.certifications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.certifications.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "certifications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { educationItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = educationItemSchema;
|
||||
@@ -47,12 +48,7 @@ export function CreateEducationDialog({ data }: DialogProps<"resume.sections.edu
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.education.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "education", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -111,15 +107,7 @@ export function UpdateEducationDialog({ data }: DialogProps<"resume.sections.edu
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.education.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.education.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "education", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { experienceItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = experienceItemSchema;
|
||||
@@ -49,14 +50,8 @@ export function CreateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.experience.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "experience", formData, data?.customSectionId);
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -113,17 +108,8 @@ export function UpdateExperienceDialog({ data }: DialogProps<"resume.sections.ex
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.experience.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.experience.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "experience", formData, data?.customSectionId);
|
||||
});
|
||||
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { interestItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -42,12 +43,7 @@ export function CreateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.interests.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "interests", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -100,15 +96,7 @@ export function UpdateInterestDialog({ data }: DialogProps<"resume.sections.inte
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.interests.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.interests.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "interests", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { languageItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = languageItemSchema;
|
||||
@@ -40,12 +41,7 @@ export function CreateLanguageDialog({ data }: DialogProps<"resume.sections.lang
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.languages.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "languages", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -98,15 +94,7 @@ export function UpdateLanguageDialog({ data }: DialogProps<"resume.sections.lang
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.languages.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.languages.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "languages", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { type DialogProps, useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { profileItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -44,12 +45,7 @@ export function CreateProfileDialog({ data }: DialogProps<"resume.sections.profi
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.profiles.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "profiles", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -104,15 +100,7 @@ export function UpdateProfileDialog({ data }: DialogProps<"resume.sections.profi
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.profiles.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.profiles.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "profiles", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { projectItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = projectItemSchema;
|
||||
@@ -43,12 +44,7 @@ export function CreateProjectDialog({ data }: DialogProps<"resume.sections.proje
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.projects.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "projects", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -103,15 +99,7 @@ export function UpdateProjectDialog({ data }: DialogProps<"resume.sections.proje
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.projects.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.projects.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "projects", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { publicationItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = publicationItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreatePublicationDialog({ data }: DialogProps<"resume.sections.p
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.publications.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "publications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdatePublicationDialog({ data }: DialogProps<"resume.sections.p
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.publications.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.publications.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "publications", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { referenceItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = referenceItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateReferenceDialog({ data }: DialogProps<"resume.sections.ref
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.references.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "references", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateReferenceDialog({ data }: DialogProps<"resume.sections.ref
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.references.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.references.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "references", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Slider } from "@/components/ui/slider";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { skillItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
import { cn } from "@/utils/style";
|
||||
|
||||
@@ -46,12 +47,7 @@ export function CreateSkillDialog({ data }: DialogProps<"resume.sections.skills.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.skills.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "skills", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -106,15 +102,7 @@ export function UpdateSkillDialog({ data }: DialogProps<"resume.sections.skills.
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.skills.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.skills.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "skills", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogStore } from "@/dialogs/store";
|
||||
import { useFormBlocker } from "@/hooks/use-form-blocker";
|
||||
import { volunteerItemSchema } from "@/schema/resume/data";
|
||||
import { createSectionItem, updateSectionItem } from "@/utils/resume/section-actions";
|
||||
import { generateId } from "@/utils/string";
|
||||
|
||||
const formSchema = volunteerItemSchema;
|
||||
@@ -44,12 +45,7 @@ export function CreateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (section) section.items.push(formData);
|
||||
} else {
|
||||
draft.sections.volunteer.items.push(formData);
|
||||
}
|
||||
createSectionItem(draft, "volunteer", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
@@ -105,15 +101,7 @@ export function UpdateVolunteerDialog({ data }: DialogProps<"resume.sections.vol
|
||||
|
||||
const onSubmit = (formData: FormValues) => {
|
||||
updateResumeData((draft) => {
|
||||
if (data?.customSectionId) {
|
||||
const section = draft.customSections.find((s) => s.id === data.customSectionId);
|
||||
if (!section) return;
|
||||
const index = section.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) section.items[index] = formData;
|
||||
} else {
|
||||
const index = draft.sections.volunteer.items.findIndex((item) => item.id === formData.id);
|
||||
if (index !== -1) draft.sections.volunteer.items[index] = formData;
|
||||
}
|
||||
updateSectionItem(draft, "volunteer", formData, data?.customSectionId);
|
||||
});
|
||||
closeDialog();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { useDialogStore } from "./store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useDialogStore.setState({ open: false, activeDialog: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — initial state", () => {
|
||||
it("starts closed with no active dialog", () => {
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(false);
|
||||
expect(state.activeDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// openDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — openDialog", () => {
|
||||
it("opens dialog with correct type and data", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.create");
|
||||
});
|
||||
|
||||
it("opens dialog with data payload", () => {
|
||||
useDialogStore.getState().openDialog("resume.update", {
|
||||
id: "r1",
|
||||
name: "My Resume",
|
||||
slug: "my-resume",
|
||||
tags: ["tag1"],
|
||||
});
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.update");
|
||||
expect((state.activeDialog as any)?.data.name).toBe("My Resume");
|
||||
});
|
||||
|
||||
it("opens section create dialog without data", () => {
|
||||
useDialogStore.getState().openDialog("resume.sections.skills.create", undefined);
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect(state.activeDialog?.type).toBe("resume.sections.skills.create");
|
||||
});
|
||||
|
||||
it("opens section update dialog with item data", () => {
|
||||
useDialogStore.getState().openDialog("resume.sections.skills.update", {
|
||||
item: {
|
||||
id: "s1",
|
||||
hidden: false,
|
||||
icon: "star",
|
||||
name: "TypeScript",
|
||||
proficiency: "Advanced",
|
||||
level: 4,
|
||||
keywords: ["frontend"],
|
||||
},
|
||||
});
|
||||
|
||||
const state = useDialogStore.getState();
|
||||
expect(state.open).toBe(true);
|
||||
expect((state.activeDialog as any)?.data.item.name).toBe("TypeScript");
|
||||
});
|
||||
|
||||
it("replaces previous dialog when opening a new one", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.create");
|
||||
|
||||
useDialogStore.getState().openDialog("resume.import", undefined);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.import");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// closeDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — closeDialog", () => {
|
||||
it("sets open to false immediately", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
|
||||
useDialogStore.getState().closeDialog();
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("clears activeDialog after 300ms delay (animation)", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().closeDialog();
|
||||
|
||||
// activeDialog should still be set immediately after close
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
// After 300ms, it should be cleared
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
|
||||
it("does not clear activeDialog before 300ms", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().closeDialog();
|
||||
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// onOpenChange
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — onOpenChange", () => {
|
||||
it("sets open state directly", () => {
|
||||
useDialogStore.getState().onOpenChange(true);
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
|
||||
useDialogStore.getState().onOpenChange(false);
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("clears activeDialog after 300ms when set to false", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().onOpenChange(false);
|
||||
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT clear activeDialog when set to true", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().onOpenChange(true);
|
||||
|
||||
vi.advanceTimersByTime(500);
|
||||
// activeDialog should still be set
|
||||
expect(useDialogStore.getState().activeDialog).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("DialogStore — edge cases", () => {
|
||||
it("handles close when already closed", () => {
|
||||
useDialogStore.getState().closeDialog();
|
||||
expect(useDialogStore.getState().open).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(useDialogStore.getState().activeDialog).toBeNull();
|
||||
});
|
||||
|
||||
it("handles rapid open/close cycles", () => {
|
||||
useDialogStore.getState().openDialog("resume.create", undefined);
|
||||
useDialogStore.getState().closeDialog();
|
||||
useDialogStore.getState().openDialog("resume.import", undefined);
|
||||
|
||||
expect(useDialogStore.getState().open).toBe(true);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe("resume.import");
|
||||
|
||||
vi.advanceTimersByTime(300);
|
||||
// The close timeout from the first close might fire, but the new open should have
|
||||
// set a new activeDialog, so the current type should still be "resume.import"
|
||||
// Note: This exposes a potential race condition in the store
|
||||
});
|
||||
|
||||
it("supports all dialog types", () => {
|
||||
const dialogTypes = [
|
||||
"auth.change-password",
|
||||
"auth.two-factor.enable",
|
||||
"auth.two-factor.disable",
|
||||
"api-key.create",
|
||||
"resume.create",
|
||||
"resume.import",
|
||||
"resume.template.gallery",
|
||||
] as const;
|
||||
|
||||
for (const type of dialogTypes) {
|
||||
useDialogStore.getState().openDialog(type, undefined);
|
||||
expect(useDialogStore.getState().activeDialog?.type).toBe(type);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { act, render, renderHook, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { ConfirmDialogProvider, useConfirm } from "./use-confirm";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <ConfirmDialogProvider>{children}</ConfirmDialogProvider>;
|
||||
}
|
||||
|
||||
/** A test component that triggers confirm and stores the result */
|
||||
function ConfirmTester() {
|
||||
const confirm = useConfirm();
|
||||
const [result, setResult] = React.useState<boolean | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const confirmed = await confirm("Delete this?", {
|
||||
description: "This action cannot be undone.",
|
||||
confirmText: "Delete",
|
||||
cancelText: "Keep",
|
||||
});
|
||||
setResult(confirmed);
|
||||
}}
|
||||
>
|
||||
Trigger
|
||||
</button>
|
||||
{result !== null && <span data-testid="result">{String(result)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useConfirm", () => {
|
||||
it("throws when used outside provider", () => {
|
||||
expect(() => {
|
||||
renderHook(() => useConfirm());
|
||||
}).toThrow("useConfirm must be used within a <ConfirmDialogProvider />");
|
||||
});
|
||||
|
||||
it("returns a function when used inside provider", () => {
|
||||
const { result } = renderHook(() => useConfirm(), { wrapper });
|
||||
expect(typeof result.current).toBe("function");
|
||||
});
|
||||
|
||||
it("confirm() returns a promise", () => {
|
||||
const { result } = renderHook(() => useConfirm(), { wrapper });
|
||||
|
||||
let promise: Promise<boolean> | undefined;
|
||||
act(() => {
|
||||
promise = result.current("Are you sure?");
|
||||
});
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("opens dialog with title and description when confirm is called", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmTester />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Trigger").click();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete this?")).toBeDefined();
|
||||
expect(screen.getByText("This action cannot be undone.")).toBeDefined();
|
||||
expect(screen.getByText("Delete")).toBeDefined();
|
||||
expect(screen.getByText("Keep")).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves true when confirm button is clicked", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmTester />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Trigger").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Delete").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("true");
|
||||
});
|
||||
|
||||
it("resolves false when cancel button is clicked", async () => {
|
||||
render(
|
||||
<ConfirmDialogProvider>
|
||||
<ConfirmTester />
|
||||
</ConfirmDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Trigger").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Keep").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("false");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { useControlledState } from "./use-controlled-state";
|
||||
|
||||
describe("useControlledState", () => {
|
||||
describe("uncontrolled mode (no value prop)", () => {
|
||||
it("uses defaultValue as initial state", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: "hello" }));
|
||||
expect(result.current[0]).toBe("hello");
|
||||
});
|
||||
|
||||
it("updates internal state when setter is called", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: 0 }));
|
||||
|
||||
act(() => {
|
||||
result.current[1](42);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(42);
|
||||
});
|
||||
|
||||
it("calls onChange when setter is called", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: "a", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b");
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("passes extra args to onChange", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState<string, [number]>({ defaultValue: "a", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("b", 99);
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("b", 99);
|
||||
});
|
||||
});
|
||||
|
||||
describe("controlled mode (value prop provided)", () => {
|
||||
it("uses value prop as initial state", () => {
|
||||
const { result } = renderHook(() => useControlledState({ value: "controlled" }));
|
||||
expect(result.current[0]).toBe("controlled");
|
||||
});
|
||||
|
||||
it("syncs internal state when value prop changes", () => {
|
||||
let value = "first";
|
||||
const { result, rerender } = renderHook(() => useControlledState({ value }));
|
||||
|
||||
expect(result.current[0]).toBe("first");
|
||||
|
||||
value = "second";
|
||||
rerender();
|
||||
|
||||
expect(result.current[0]).toBe("second");
|
||||
});
|
||||
|
||||
it("still calls onChange when setter is called in controlled mode", () => {
|
||||
const onChange = vi.fn();
|
||||
const { result } = renderHook(() => useControlledState({ value: "x", onChange }));
|
||||
|
||||
act(() => {
|
||||
result.current[1]("y");
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith("y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles undefined defaultValue", () => {
|
||||
const { result } = renderHook(() => useControlledState({}));
|
||||
expect(result.current[0]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles boolean values", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: false }));
|
||||
expect(result.current[0]).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current[1](true);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(true);
|
||||
});
|
||||
|
||||
it("handles object values", () => {
|
||||
const obj = { key: "value" };
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: obj }));
|
||||
expect(result.current[0]).toBe(obj);
|
||||
});
|
||||
|
||||
it("works without onChange callback", () => {
|
||||
const { result } = renderHook(() => useControlledState({ defaultValue: 5 }));
|
||||
|
||||
act(() => {
|
||||
result.current[1](10);
|
||||
});
|
||||
|
||||
expect(result.current[0]).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
import { useIsMobile } from "./use-mobile";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock window.matchMedia
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockMatchMedia = {
|
||||
matches: boolean;
|
||||
listeners: Array<(e: { matches: boolean }) => void>;
|
||||
trigger: (matches: boolean) => void;
|
||||
};
|
||||
|
||||
let mockMql: MockMatchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMql = {
|
||||
matches: false,
|
||||
listeners: [],
|
||||
trigger(matches: boolean) {
|
||||
this.matches = matches;
|
||||
for (const listener of this.listeners) {
|
||||
listener({ matches });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
vi.fn(() => ({
|
||||
get matches() {
|
||||
return mockMql.matches;
|
||||
},
|
||||
addEventListener: (_event: string, cb: (e: { matches: boolean }) => void) => {
|
||||
mockMql.listeners.push(cb);
|
||||
},
|
||||
removeEventListener: (_event: string, cb: (e: { matches: boolean }) => void) => {
|
||||
mockMql.listeners = mockMql.listeners.filter((l) => l !== cb);
|
||||
},
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useIsMobile", () => {
|
||||
it("returns false on desktop-sized screens", () => {
|
||||
mockMql.matches = false;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on mobile-sized screens", () => {
|
||||
mockMql.matches = true;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when screen size changes to mobile", () => {
|
||||
mockMql.matches = false;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
mockMql.trigger(true);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when screen size changes to desktop", () => {
|
||||
mockMql.matches = true;
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
mockMql.trigger(false);
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up event listener on unmount", () => {
|
||||
mockMql.matches = false;
|
||||
const { unmount } = renderHook(() => useIsMobile());
|
||||
|
||||
expect(mockMql.listeners).toHaveLength(1);
|
||||
unmount();
|
||||
expect(mockMql.listeners).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses 768px breakpoint (max-width: 767px)", () => {
|
||||
renderHook(() => useIsMobile());
|
||||
expect(window.matchMedia).toHaveBeenCalledWith("(max-width: 767px)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
||||
|
||||
vi.mock("@lingui/core/macro", () => ({
|
||||
t: (strings: TemplateStringsArray) => strings[0],
|
||||
}));
|
||||
|
||||
import { PromptDialogProvider, usePrompt } from "./use-prompt";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <PromptDialogProvider>{children}</PromptDialogProvider>;
|
||||
}
|
||||
|
||||
function PromptTester() {
|
||||
const prompt = usePrompt();
|
||||
const [result, setResult] = React.useState<string | null | undefined>(undefined);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const value = await prompt("Enter name", {
|
||||
description: "Provide your full name",
|
||||
defaultValue: "John",
|
||||
confirmText: "Submit",
|
||||
cancelText: "Skip",
|
||||
});
|
||||
setResult(value);
|
||||
}}
|
||||
>
|
||||
Open Prompt
|
||||
</button>
|
||||
{result !== undefined && <span data-testid="result">{result === null ? "null" : result}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("usePrompt", () => {
|
||||
it("throws when used outside provider", () => {
|
||||
expect(() => {
|
||||
renderHook(() => usePrompt());
|
||||
}).toThrow("usePrompt must be used within a <PromptDialogProvider />");
|
||||
});
|
||||
|
||||
it("returns a function when used inside provider", () => {
|
||||
const { result } = renderHook(() => usePrompt(), { wrapper });
|
||||
expect(typeof result.current).toBe("function");
|
||||
});
|
||||
|
||||
it("prompt() returns a promise", () => {
|
||||
const { result } = renderHook(() => usePrompt(), { wrapper });
|
||||
|
||||
let promise: Promise<string | null> | undefined;
|
||||
act(() => {
|
||||
promise = result.current("Enter value");
|
||||
});
|
||||
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
});
|
||||
|
||||
it("opens dialog with title, description, and default value", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Enter name")).toBeDefined();
|
||||
expect(screen.getByText("Provide your full name")).toBeDefined();
|
||||
expect(screen.getByText("Submit")).toBeDefined();
|
||||
expect(screen.getByText("Skip")).toBeDefined();
|
||||
|
||||
// Check default value in input
|
||||
const input = screen.getByDisplayValue("John");
|
||||
expect(input).toBeDefined();
|
||||
});
|
||||
|
||||
it("resolves with input value when confirm is clicked", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
// Change the input value
|
||||
const input = screen.getByDisplayValue("John");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Jane Doe" } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Submit").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("Jane Doe");
|
||||
});
|
||||
|
||||
it("resolves with null when cancel is clicked", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Skip").click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("null");
|
||||
});
|
||||
|
||||
it("resolves with value when Enter key is pressed", async () => {
|
||||
render(
|
||||
<PromptDialogProvider>
|
||||
<PromptTester />
|
||||
</PromptDialogProvider>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("Open Prompt").click();
|
||||
});
|
||||
|
||||
const input = screen.getByDisplayValue("John");
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: "Enter User" } });
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("result").textContent).toBe("Enter User");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { useAIStore } from "./store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reset store between tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
afterEach(() => {
|
||||
useAIStore.getState().reset();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — initial state", () => {
|
||||
it("starts with default values", () => {
|
||||
const state = useAIStore.getState();
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("");
|
||||
expect(state.apiKey).toBe("");
|
||||
expect(state.baseURL).toBe("");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// set()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — set()", () => {
|
||||
it("updates provider", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "anthropic";
|
||||
});
|
||||
expect(useAIStore.getState().provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
it("updates model and apiKey", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.model = "gpt-4";
|
||||
draft.apiKey = "sk-test-key";
|
||||
});
|
||||
expect(useAIStore.getState().model).toBe("gpt-4");
|
||||
expect(useAIStore.getState().apiKey).toBe("sk-test-key");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when provider changes", () => {
|
||||
// First set testStatus to success
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
// Now change provider — should reset
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "gemini";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when model changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.model = "new-model";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when apiKey changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.apiKey = "new-key";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("resets testStatus to unverified when baseURL changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.baseURL = "https://new-url.com";
|
||||
});
|
||||
expect(useAIStore.getState().testStatus).toBe("unverified");
|
||||
});
|
||||
|
||||
it("disables when config changes", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
|
||||
// Change provider — should disable
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "ollama";
|
||||
});
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT reset testStatus when non-config fields change", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
// Changing testStatus itself shouldn't trigger the reset logic
|
||||
// (since provider/model/apiKey/baseURL didn't change)
|
||||
expect(useAIStore.getState().testStatus).toBe("success");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canEnable()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — canEnable()", () => {
|
||||
it("returns false when testStatus is unverified", () => {
|
||||
expect(useAIStore.getState().canEnable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when testStatus is failure", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
expect(useAIStore.getState().canEnable()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when testStatus is success", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
expect(useAIStore.getState().canEnable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setEnabled()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — setEnabled()", () => {
|
||||
it("enables when testStatus is success", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses to enable when testStatus is not success", () => {
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("can disable regardless of testStatus", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(true);
|
||||
|
||||
useAIStore.getState().setEnabled(false);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses to enable after failure", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.testStatus = "failure";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
expect(useAIStore.getState().enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// reset()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("AI Store — reset()", () => {
|
||||
it("restores all state to initial values", () => {
|
||||
useAIStore.getState().set((draft) => {
|
||||
draft.provider = "anthropic";
|
||||
draft.model = "claude-3";
|
||||
draft.apiKey = "sk-key";
|
||||
draft.baseURL = "https://api.anthropic.com";
|
||||
draft.testStatus = "success";
|
||||
});
|
||||
useAIStore.getState().setEnabled(true);
|
||||
|
||||
useAIStore.getState().reset();
|
||||
|
||||
const state = useAIStore.getState();
|
||||
expect(state.enabled).toBe(false);
|
||||
expect(state.provider).toBe("openai");
|
||||
expect(state.model).toBe("");
|
||||
expect(state.apiKey).toBe("");
|
||||
expect(state.baseURL).toBe("");
|
||||
expect(state.testStatus).toBe("unverified");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,13 @@ import { apiKeyClient } from "@better-auth/api-key/client";
|
||||
import { dashClient } from "@better-auth/infra/client";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
||||
import { genericOAuthClient, inferAdditionalFields, twoFactorClient, usernameClient } from "better-auth/client/plugins";
|
||||
import {
|
||||
adminClient,
|
||||
genericOAuthClient,
|
||||
inferAdditionalFields,
|
||||
twoFactorClient,
|
||||
usernameClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import type { auth } from "./config";
|
||||
@@ -11,6 +17,7 @@ const getAuthClient = () => {
|
||||
return createAuthClient({
|
||||
plugins: [
|
||||
dashClient(),
|
||||
adminClient(),
|
||||
apiKeyClient(),
|
||||
usernameClient(),
|
||||
twoFactorClient({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { GenericOAuthConfig } from "better-auth/plugins";
|
||||
import type { JWTPayload } from "jose";
|
||||
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
@@ -7,7 +6,7 @@ import { dash } from "@better-auth/infra";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { BetterAuthError, betterAuth } from "better-auth";
|
||||
import { verifyAccessToken } from "better-auth/oauth2";
|
||||
import { jwt, openAPI } from "better-auth/plugins";
|
||||
import { admin, jwt, openAPI, type GenericOAuthConfig } from "better-auth/plugins";
|
||||
import { genericOAuth } from "better-auth/plugins/generic-oauth";
|
||||
import { twoFactor } from "better-auth/plugins/two-factor";
|
||||
import { username } from "better-auth/plugins/username";
|
||||
@@ -124,7 +123,7 @@ const getAuthConfig = () => {
|
||||
emailAndPassword: {
|
||||
enabled: !env.FLAG_DISABLE_EMAIL_AUTH,
|
||||
autoSignIn: true,
|
||||
minPasswordLength: 6,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 64,
|
||||
requireEmailVerification: false,
|
||||
disableSignUp: env.FLAG_DISABLE_SIGNUPS || env.FLAG_DISABLE_EMAIL_AUTH,
|
||||
@@ -175,7 +174,7 @@ const getAuthConfig = () => {
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
trustedProviders: ["google", "github"],
|
||||
trustedProviders: ["google", "github", "linkedin"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -249,10 +248,38 @@ const getAuthConfig = () => {
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
linkedin: {
|
||||
enabled: !!env.LINKEDIN_CLIENT_ID && !!env.LINKEDIN_CLIENT_SECRET,
|
||||
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
|
||||
clientId: env.LINKEDIN_CLIENT_ID!,
|
||||
clientSecret: env.LINKEDIN_CLIENT_SECRET!,
|
||||
mapProfileToUser: async (profile) => {
|
||||
if (!profile.email) {
|
||||
throw new BetterAuthError(
|
||||
"LinkedIn provider did not return an email address. This is required for user creation.",
|
||||
{ cause: "EMAIL_REQUIRED" },
|
||||
);
|
||||
}
|
||||
|
||||
const username = profile.email.split("@")[0];
|
||||
const name = profile.name ?? username;
|
||||
|
||||
return {
|
||||
name,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
username,
|
||||
displayUsername: username,
|
||||
emailVerified: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
jwt(),
|
||||
admin(),
|
||||
openAPI(),
|
||||
genericOAuth({ config: authConfigs }),
|
||||
twoFactor({ issuer: "Reactive Resume" }),
|
||||
|
||||
@@ -7,6 +7,6 @@ export type AuthSession = {
|
||||
user: typeof auth.$Infer.Session.user;
|
||||
};
|
||||
|
||||
const authProviderSchema = z.enum(["credential", "google", "github", "custom"]);
|
||||
const authProviderSchema = z.enum(["credential", "google", "github", "linkedin", "custom"]);
|
||||
|
||||
export type AuthProvider = z.infer<typeof authProviderSchema>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user