mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-02 01:34:26 +10:00
Merge remote-tracking branch 'origin/main' into codex/issue-execution-ledger
This commit is contained in:
@@ -121,6 +121,16 @@ describe("generateOpenApiSpec", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy input with omitted picture fit in the published request schema", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(spec.components?.schemas?.ResumeData).toMatchObject({
|
||||
properties: {
|
||||
picture: { required: expect.not.arrayContaining(["fit"]) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" en die volledige tydlyn daarvan sal permanent uitgevee word. Dit kan nie ongedaan gemaak word nie."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Inhoud"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Inhoud is in 'n rooster uitgelê."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fins"
|
||||
msgid "Fireworks"
|
||||
msgstr "Vuurwerke"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Pas om te sien"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" እና ሙሉ የጊዜ መስመሩ በቋሚነት ይሰረዛሉ። ይህ ሊቀለበስ አይችልም።"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "ይዘት"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "ይዘቱ በግሪድ ውስጥ ተቀምጧል።"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ፊኒሽ"
|
||||
msgid "Fireworks"
|
||||
msgstr "ርችቶች"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "ለእይታ ተስማሚ"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "سيتم حذف \"{0} · {1}\" وسجله الزمني الكامل نهائيًا. لا يمكن التراجع عن ذلك."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "المحتوى"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "المحتوى منظّم في شبكة."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "الفنلندية"
|
||||
msgid "Fireworks"
|
||||
msgstr "ألعاب نارية"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "مناسب للعرض"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" və onun tam zaman xətti həmişəlik silinəcək. Bu əməliyyatı geri qaytarmaq mümkün deyil."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Məzmun"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Məzmun cədvəl (grid) şəklində düzülüb."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fin"
|
||||
msgid "Fireworks"
|
||||
msgstr "Atəşfəşanlıq"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Görüntü üçün uyğunlaşdırın"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" и цялата му хронология ще бъдат изтрити завинаги. Това не може да бъде отменено."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Съдържание"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Съдържанието е оформено в мрежа."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Фински"
|
||||
msgid "Fireworks"
|
||||
msgstr "Фойерверки"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Побиране в изглед"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" এবং এর সম্পূর্ণ টাইমলাইন স্থায়ীভাবে মুছে ফেলা হবে। এটি পূর্বাবস্থায় ফেরানো যাবে না।"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "বিষয়বস্তু"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "কনটেন্ট একটি গ্রিডে সাজানো আছে।"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ফিনিশ"
|
||||
msgid "Fireworks"
|
||||
msgstr "আতশবাজি"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "দেখার জন্য উপযুক্ত"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" i tota la seva cronologia se suprimiran permanentment. Això no es pot desfer."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Contingut"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "El contingut està disposat en una graella."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finès"
|
||||
msgid "Fireworks"
|
||||
msgstr "Focs artificials"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Ajustar a la vista"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" a celá jeho časová osa budou trvale smazány. Tuto akci nelze vrátit zpět."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Obsah"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Obsah je uspořádán do mřížky."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finština"
|
||||
msgid "Fireworks"
|
||||
msgstr "Ohňostroj"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Přizpůsobit zobrazení"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" og hele dens tidslinje slettes permanent. Dette kan ikke fortrydes."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Indhold"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Indhold er opsat i et gitter."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finsk"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fyrværkeri"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Tilpas til visning"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" und der gesamte Verlauf werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Inhalt"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Der Inhalt ist als Raster angeordnet."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finnisch"
|
||||
msgid "Fireworks"
|
||||
msgstr "Feuerwerk"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Passend zur Ansicht"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" και ολόκληρο το χρονολόγιό του θα διαγραφούν οριστικά. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Περιεχόμενο"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Το περιεχόμενο είναι διατεταγμένο σε πλέγμα."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Φινλανδικά"
|
||||
msgid "Fireworks"
|
||||
msgstr "Πυροτεχνήματα"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Κατάλληλο για προβολή"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Content"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Content is laid out in a grid."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finnish"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fireworks"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Fit to view"
|
||||
|
||||
@@ -1260,6 +1260,10 @@ msgstr "Content"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Content is laid out in a grid."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr "Contain"
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -1458,6 +1462,14 @@ msgstr "Count by source"
|
||||
msgid "Counted separately from the parse score. Coverage does not predict anything."
|
||||
msgstr "Counted separately from the parse score. Coverage does not predict anything."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr "Cover"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
#: src/features/applications/components/application-form-sheet.tsx
|
||||
#: src/features/cover-letters/library.tsx
|
||||
@@ -2505,6 +2517,10 @@ msgstr "Finnish"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fireworks"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr "Fit"
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Fit to view"
|
||||
@@ -2761,6 +2777,11 @@ msgstr "Here's your new API key"
|
||||
msgid "Hidden"
|
||||
msgstr "Hidden"
|
||||
|
||||
#: src/features/resume/builder/section-recovery.tsx
|
||||
msgid "Hidden sections"
|
||||
msgstr "Hidden sections"
|
||||
|
||||
#: src/features/resume/builder/section-recovery.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx
|
||||
@@ -4794,6 +4815,10 @@ msgstr "Save the file with every page upright."
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: src/features/resume/builder/draft.ts
|
||||
msgid "Saving is taking longer than expected. Your changes are still open."
|
||||
msgstr "Saving is taking longer than expected. Your changes are still open."
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/header.tsx
|
||||
msgid "Saving…"
|
||||
msgstr "Saving…"
|
||||
@@ -5037,6 +5062,10 @@ msgstr "Show"
|
||||
msgid "Show {0} more"
|
||||
msgstr "Show {0} more"
|
||||
|
||||
#: src/features/resume/builder/section-recovery.tsx
|
||||
msgid "Show {title} section"
|
||||
msgstr "Show {title} section"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx
|
||||
msgid "Show Download Buttons"
|
||||
msgstr "Show Download Buttons"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" y todo su historial se eliminarán permanentemente. Esta acción no se puede deshacer."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Contenido"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "El contenido está organizado en una cuadrícula."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finés"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fuegos artificiales"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Apto para visualización"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" و کل تاریخچه آن برای همیشه حذف میشوند. این کار قابل بازگشت نیست."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "محتوا"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "محتوا بهصورت شبکهای چیده شده است."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "فنلاندی"
|
||||
msgid "Fireworks"
|
||||
msgstr "آتش بازی"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "مناسب برای مشاهده"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" ja sen koko aikajana poistetaan pysyvästi. Tätä ei voi kumota."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Sisältö"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Sisältö on aseteltu ruudukkoon."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "suomi"
|
||||
msgid "Fireworks"
|
||||
msgstr "Ilotulitus"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Sovita näkymään"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" et tout son historique seront supprimés définitivement. Cette action est irréversible."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Contenu"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Le contenu est disposé en grille."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finnois"
|
||||
msgid "Fireworks"
|
||||
msgstr "Feux d'artifice"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Ajuster à la vue"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" וכל ציר הזמן שלו יימחקו לצמיתות. אי אפשר לבטל פעולה זו."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "תוכן"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "התוכן מסודר ברשת."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "פינית"
|
||||
msgid "Fireworks"
|
||||
msgstr "זיקוקים"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "התאמה לתצוגה"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" और इसकी पूरी टाइमलाइन स्थायी रूप से हटा दी जाएगी। इसे पूर्ववत नहीं किया जा सकता."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "सामग्री"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "सामग्री ग्रिड में व्यवस्थित है।"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "फिनिश"
|
||||
msgid "Fireworks"
|
||||
msgstr "आतिशबाजी"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "देखने के लिए उपयुक्त"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" és a teljes idővonala véglegesen törlődik. Ez nem vonható vissza."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Tartalom"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "A tartalom rácsszerkezetben van elrendezve."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "finn"
|
||||
msgid "Fireworks"
|
||||
msgstr "Tűzijáték"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Nézethez igazítva"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" dan seluruh linimasanya akan dihapus permanen. Ini tidak dapat dibatalkan."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Konten"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Konten ditata dalam bentuk kisi."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finlandia"
|
||||
msgid "Fireworks"
|
||||
msgstr "Kembang api"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Sesuai untuk dilihat"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" e tutta la sua cronologia verranno eliminati definitivamente. Questa azione non può essere annullata."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Contenuto"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Il contenuto è impaginato in una griglia."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finlandese"
|
||||
msgid "Fireworks"
|
||||
msgstr "fuochi d'artificio"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Adatto alla visione"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" とその全タイムラインは完全に削除されます。この操作は元に戻せません。"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "コンテンツ"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "コンテンツがグリッドレイアウトになっています。"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "フィンランド語"
|
||||
msgid "Fireworks"
|
||||
msgstr "花火"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "見るのにふさわしい"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" និងប្រវត្តិពេលវេលាទាំងមូលរបស់វានឹងត្រូវបានលុបជាអចិន្ត្រៃយ៍។ មិនអាចត្រឡប់វិញបានទេ។"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "មាតិកា"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "មាតិកាត្រូវបានរៀបចំតាមទម្រង់ក្រឡាចត្រង្គ។"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finnish"
|
||||
msgid "Fireworks"
|
||||
msgstr "កាំជ្រួច"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "សមនឹងមើល"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" ಮತ್ತು ಅದರ ಸಂಪೂರ್ಣ ಕಾಲರೇಖೆಯನ್ನು ಶಾಶ್ವತವಾಗಿ ಅಳಿಸಲಾಗುತ್ತದೆ. ಇದನ್ನು ಹಿಂತಿರುಗಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "ವಿಷಯ"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "ವಿಷಯವನ್ನು ಗ್ರಿಡ್ನಲ್ಲಿ ಜೋಡಿಸಲಾಗಿದೆ."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ಫಿನ್ನಿಷ್"
|
||||
msgid "Fireworks"
|
||||
msgstr "ಪಟಾಕಿಗಳು"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "ವೀಕ್ಷಿಸಲು ಹೊಂದಿಕೊಳ್ಳಿ"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" 및 전체 타임라인이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "내용"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "내용이 격자 형태로 배치되어 있습니다."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "핀란드어"
|
||||
msgid "Fireworks"
|
||||
msgstr "불꽃"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "보기에 적합함"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" ir visa jos laiko juosta bus visam laikui ištrinta. To negalima atšaukti."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Turinys"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Turinys sumaketuotas tinkleliu."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Suomių"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fejerverkai"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Pritaikyti peržiūrai"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" un visa tās laika skala tiks neatgriezeniski dzēsta. To nevar atsaukt."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Saturs"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Saturs ir izkārtots režģī."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Somu"
|
||||
msgid "Fireworks"
|
||||
msgstr "Uguņošana"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Piemērots skatam"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" എന്നതും അതിന്റെ പൂർണ്ണ ടൈംലൈനും ശാശ്വതമായി ഇല്ലാതാക്കും. ഇത് പഴയപടിയാക്കാൻ കഴിയില്ല."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "ഉള്ളടക്കം"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "ഉള്ളടക്കം ഒരു ഗ്രിഡിലായി ക്രമീകരിച്ചിരിക്കുന്നു."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ഫിന്നിഷ്"
|
||||
msgid "Fireworks"
|
||||
msgstr "വെടിക്കെട്ട്"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "കാണാൻ അനുയോജ്യം"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" आणि त्याची पूर्ण टाइमलाइन कायमची हटवली जाईल. हे पूर्ववत करता येणार नाही."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "सामग्री"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "सामग्री ग्रिडमध्ये मांडलेली आहे."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "फिन्निश"
|
||||
msgid "Fireworks"
|
||||
msgstr "फटाके"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "पाहण्यासाठी योग्य"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" dan garis masa penuhnya akan dipadamkan secara kekal. Tindakan ini tidak boleh dibuat asal."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Kandungan"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Kandungan disusun dalam grid."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finland"
|
||||
msgid "Fireworks"
|
||||
msgstr "Bunga api"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Sesuai untuk dilihat"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" र यसको पूरा टाइमलाइन स्थायी रूपमा मेटिनेछ। यो पूर्ववत गर्न सकिँदैन।"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "सामग्री"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "सामग्री ग्रिडमा सजाइएको छ।"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "फिनिस"
|
||||
msgid "Fireworks"
|
||||
msgstr "आतिशबाजी"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "हेर्नको लागि उपयुक्त"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" en de volledige tijdlijn worden permanent verwijderd. Dit kan niet ongedaan worden gemaakt."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Inhoud"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Inhoud is opgemaakt in een raster."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fins"
|
||||
msgid "Fireworks"
|
||||
msgstr "Vuurwerk"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Geschikt om te bekijken"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" og hele tidslinjen slettes permanent. Dette kan ikke angres."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Innhold"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Innholdet er lagt ut i et rutenett."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finsk"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fyrverkeri"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Tilpasset til visning"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" ଏବଂ ଏହାର ସମ୍ପୂର୍ଣ୍ଣ ସମୟରେଖା ସ୍ଥାୟୀଭାବେ ହଟାଯିବ। ଏହା ପୁନଃଫେରାଇ ହେବ ନାହିଁ।"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "ବିଷୟବସ୍ତୁ"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "ବିଷୟବସ୍ତୁ ଏକ ଗ୍ରିଡ୍ରେ ସଜ୍ଜିତ ଅଛି।"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ଫିନିଶ୍"
|
||||
msgid "Fireworks"
|
||||
msgstr "ଆତସବାଜି"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "ଦେଖିବା ପାଇଁ ଫିଟ୍"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" i pełna oś czasu zostaną trwale usunięte. Tej operacji nie można cofnąć."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Treść"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Treść jest ułożona w siatce."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fiński"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fajerwerki"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Dopasuj do widoku"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" e toda a sua linha do tempo serão excluídos permanentemente. Isso não pode ser desfeito."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Conteúdo"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "O conteúdo está organizado em uma grade."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finlandês"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fogos de artifício"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Adequado para visualização"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" e a sua cronologia completa serão eliminados permanentemente. Isto não pode ser anulado."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Conteúdo"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "O conteúdo está organizado numa grelha."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finlandês"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fogos de artifício"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Adequado para visualização"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" și istoricul său complet vor fi șterse definitiv. Această acțiune nu poate fi anulată."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Conținut"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Conținutul este dispus într-o grilă."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finlandeză"
|
||||
msgid "Fireworks"
|
||||
msgstr "Focuri de artificii"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Potrivire pentru vizualizare"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" и вся хронология будут безвозвратно удалены. Это действие нельзя отменить."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Содержание"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Содержимое расположено в виде сетки."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Финский"
|
||||
msgid "Fireworks"
|
||||
msgstr "Фейерверк"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Подходит для просмотра"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" a celá časová os budú natrvalo odstránené. Túto akciu nie je možné vrátiť späť."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Obsah"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Obsah je usporiadaný v mriežke."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fínčina"
|
||||
msgid "Fireworks"
|
||||
msgstr "Ohňostroj"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Prispôsobiť zobrazeniu"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" in celotna časovnica bosta trajno izbrisana. Tega ni mogoče razveljaviti."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Vsebina"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Vsebina je razporejena v mreži."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finščina"
|
||||
msgid "Fireworks"
|
||||
msgstr "Ognjemet"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Prilagodi pogledu"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" dhe e gjithë kronologjia e saj do të fshihen përgjithmonë. Kjo nuk mund të zhbëhet."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Përmbajtja"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Përmbajtja është vendosur në një rrjetë."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finlandisht"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fishekzjarre"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "I përshtatshëm për t’u parë"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" и цела временска линија биће трајно избрисани. Ово не може да се опозове."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Садржај"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Садржај је распоређен у мрежи."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Фински"
|
||||
msgid "Fireworks"
|
||||
msgstr "Ватромет"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Прилагоди приказу"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" och hela tidslinjen tas bort permanent. Detta går inte att ångra."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Innehåll"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Innehållet är upplagt i ett rutnät."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Finska"
|
||||
msgid "Fireworks"
|
||||
msgstr "Fyrverkeri"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Anpassa för visning"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" மற்றும் அதன் முழு காலவரிசை நிரந்தரமாக நீக்கப்படும். இதை மீட்டெடுக்க முடியாது."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "உள்ளடக்கம்"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "உள்ளடக்கம் ஒரு கட்டமைப்பில் (கிரிட்) அமைக்கப்பட்டுள்ளது."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ஃபின்னிஷ்"
|
||||
msgid "Fireworks"
|
||||
msgstr "பட்டாசுகள்"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "பார்வைக்கு ஏற்றது"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" మరియు దాని పూర్తి టైమ్లైన్ శాశ్వతంగా తొలగించబడతాయి. దీన్ని తిరిగి చేయలేరు."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "విషయం"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "కంటెంట్ గ్రిడ్లో అమర్చబడింది."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ఫిన్నిష్"
|
||||
msgid "Fireworks"
|
||||
msgstr "బాణసంచా"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "వీక్షించడానికి సరిపోతుంది"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" และไทม์ไลน์ทั้งหมดจะถูกลบถาวร ไม่สามารถย้อนกลับได้"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "เนื้อหา"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "เนื้อหาถูกจัดวางแบบกริด"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "ฟินแลนด์"
|
||||
msgid "Fireworks"
|
||||
msgstr "ดอกไม้ไฟ"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "เหมาะสำหรับรับชม"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" ve tüm zaman çizelgesi kalıcı olarak silinecek. Bu işlem geri alınamaz."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "İçerik"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "İçerik bir ızgara düzeninde yerleştirilmiş."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fince"
|
||||
msgid "Fireworks"
|
||||
msgstr "Havai fişekler"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Görüntülemeye uygun"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" і всю хронологію буде назавжди видалено. Це не можна скасувати."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Зміст"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Вміст розташований у вигляді сітки."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Фінська"
|
||||
msgid "Fireworks"
|
||||
msgstr "Феєрверки"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Пристосувати до перегляду"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" va uning to‘liq xronologiyasi butunlay o‘chiriladi. Buni ortga qaytarib bo‘lmaydi."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Mazmun"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Kontent panjara koʻrinishida joylashtirilgan."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Fin tili"
|
||||
msgid "Fireworks"
|
||||
msgstr "Mushakbozlik"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Ko'rish uchun moslash"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" và toàn bộ dòng thời gian sẽ bị xóa vĩnh viễn. Không thể hoàn tác."
|
||||
@@ -1214,6 +1222,10 @@ msgstr "Nội dung"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "Nội dung được trình bày theo dạng lưới."
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "Tiếng Phần Lan"
|
||||
msgid "Fireworks"
|
||||
msgstr "Pháo hoa"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "Phù hợp để xem"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" 及其完整时间线将被永久删除。此操作无法撤销。"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "内容"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "内容以网格形式排列。"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "芬兰语"
|
||||
msgid "Fireworks"
|
||||
msgstr "烟花"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "适合观看"
|
||||
|
||||
@@ -23,6 +23,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr "\"{0} · {1}\" 及其完整時間軸將被永久刪除。此操作無法復原。"
|
||||
@@ -1214,6 +1222,10 @@ msgstr "內容"
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr "內容以網格形式排列。"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2369,6 +2381,10 @@ msgstr "芬蘭語"
|
||||
msgid "Fireworks"
|
||||
msgstr "煙火"
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr "適合觀看"
|
||||
|
||||
@@ -18,6 +18,14 @@ msgstr ""
|
||||
#. placeholder {1}: application.company
|
||||
#. placeholder {1}: current.company
|
||||
#: src/features/applications/components/application-actions-menu.tsx
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore edges removed by an earlier crop."
|
||||
msgstr ""
|
||||
|
||||
#: src/features/applications/components/application-detail-sheet.tsx
|
||||
msgid "\"{0} · {1}\" and its full timeline will be permanently deleted. This can't be undone."
|
||||
msgstr ""
|
||||
@@ -1200,6 +1208,10 @@ msgstr ""
|
||||
msgid "Content is laid out in a grid."
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Contain"
|
||||
msgstr ""
|
||||
|
||||
#. Button label to continue to dashboard after successful registration
|
||||
#. Final action button after saving backup codes
|
||||
#. Primary action button to proceed to next step in two-factor setup
|
||||
@@ -2351,6 +2363,10 @@ msgstr ""
|
||||
msgid "Fireworks"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx
|
||||
msgid "Fit"
|
||||
msgstr ""
|
||||
|
||||
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||
msgid "Fit to view"
|
||||
msgstr ""
|
||||
|
||||
@@ -9,6 +9,7 @@ import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import {
|
||||
isEditableElementFocused,
|
||||
useBuilderResumeUpdateSubscription,
|
||||
useResumeCleanup,
|
||||
useResumeStore,
|
||||
useResumeUpdateSubscription,
|
||||
} from "./draft";
|
||||
@@ -20,6 +21,8 @@ const orpcMocks = vi.hoisted(() => ({
|
||||
updateResume: vi.fn(),
|
||||
}));
|
||||
|
||||
const useBlockerMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const consumeEventIteratorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const queryClientMock = vi.hoisted(() => ({
|
||||
@@ -45,6 +48,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => routerParamsMock.value,
|
||||
useBlocker: useBlockerMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
@@ -115,9 +119,139 @@ async function flushMicrotasks() {
|
||||
}
|
||||
|
||||
describe("builder resume autosave", () => {
|
||||
it("waits for the latest draft to save before allowing navigation", async () => {
|
||||
const initial = makeResume("navigation-debounce");
|
||||
useResumeStore.getState().initialize(initial);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
const hook = renderHook(() => useResumeCleanup());
|
||||
let complete!: (resume: Resume) => void;
|
||||
orpcMocks.updateResume.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Resume>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Navigate safely";
|
||||
});
|
||||
const blocker = useBlockerMock.mock.lastCall?.[0];
|
||||
expect(blocker).toBeDefined();
|
||||
let settled = false;
|
||||
const result = blocker.shouldBlockFn({ next: { params: {} } }).then((blocked: boolean) => {
|
||||
settled = true;
|
||||
return blocked;
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(settled).toBe(false);
|
||||
expect(orpcMocks.updateResume.mock.lastCall?.[1].signal.aborted).toBe(false);
|
||||
complete(withBasicsName(initial, "Navigate safely"));
|
||||
expect(await result).toBe(false);
|
||||
expect(useResumeStore.getState().saveStatus).toBe("saved");
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("waits for a queued edit after an in-flight save before navigating", async () => {
|
||||
const initial = makeResume("navigation-queued");
|
||||
useResumeStore.getState().initialize(initial);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
const hook = renderHook(() => useResumeCleanup());
|
||||
const completions: Array<(resume: Resume) => void> = [];
|
||||
orpcMocks.updateResume.mockImplementation(
|
||||
() =>
|
||||
new Promise<Resume>((resolve) => {
|
||||
completions.push(resolve);
|
||||
}),
|
||||
);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "First";
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Latest";
|
||||
});
|
||||
const blocker = useBlockerMock.mock.lastCall?.[0];
|
||||
let settled = false;
|
||||
const result = blocker.shouldBlockFn({ next: { params: {} } }).then((blocked: boolean) => {
|
||||
settled = true;
|
||||
return blocked;
|
||||
});
|
||||
completions[0](withBasicsName(initial, "First"));
|
||||
await flushMicrotasks();
|
||||
expect(settled).toBe(false);
|
||||
expect(orpcMocks.updateResume.mock.lastCall?.[0].data.basics.name).toBe("Latest");
|
||||
completions[1](withBasicsName(initial, "Latest"));
|
||||
expect(await result).toBe(false);
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Latest");
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("ends a stalled navigation wait without aborting or discarding the pending save", async () => {
|
||||
const initial = makeResume("navigation-timeout");
|
||||
useResumeStore.getState().initialize(initial);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
const hook = renderHook(() => useResumeCleanup());
|
||||
let complete!: (resume: Resume) => void;
|
||||
orpcMocks.updateResume.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Resume>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Pending name";
|
||||
});
|
||||
const blocker = useBlockerMock.mock.lastCall?.[0];
|
||||
let settled = false;
|
||||
const result = blocker.shouldBlockFn({ next: { params: {} } }).then((blocked: boolean) => {
|
||||
settled = true;
|
||||
return blocked;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(settled).toBe(true);
|
||||
expect(await result).toBe(true);
|
||||
expect(useResumeStore.getState().saveStatus).toBe("saving");
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Pending name");
|
||||
expect(orpcMocks.updateResume.mock.lastCall?.[1].signal.aborted).toBe(false);
|
||||
|
||||
orpcMocks.updateResume.mockResolvedValueOnce(withBasicsName(initial, "Latest name"));
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Latest name";
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1);
|
||||
complete(withBasicsName(initial, "Pending name"));
|
||||
await flushMicrotasks();
|
||||
expect(useResumeStore.getState().saveStatus).toBe("saved");
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Latest name");
|
||||
expect(await blocker.shouldBlockFn({ next: { params: {} } })).toBe(false);
|
||||
expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2);
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
it("keeps a failed draft in the builder and retries on the next navigation", async () => {
|
||||
const initial = makeResume("navigation-error");
|
||||
useResumeStore.getState().initialize(initial);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
const hook = renderHook(() => useResumeCleanup());
|
||||
orpcMocks.updateResume.mockRejectedValueOnce(new Error("Offline"));
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.basics.name = "Keep this draft";
|
||||
});
|
||||
const blocker = useBlockerMock.mock.lastCall?.[0];
|
||||
expect(blocker).toBeDefined();
|
||||
expect(await blocker.shouldBlockFn({ next: { params: {} } })).toBe(true);
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Keep this draft");
|
||||
expect(blocker.enableBeforeUnload()).toBe(true);
|
||||
orpcMocks.updateResume.mockResolvedValueOnce(withBasicsName(initial, "Keep this draft"));
|
||||
expect(await blocker.shouldBlockFn({ next: { params: {} } })).toBe(false);
|
||||
expect(blocker.enableBeforeUnload()).toBe(false);
|
||||
hook.unmount();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
orpcMocks.getResumeById.mockReset();
|
||||
useBlockerMock.mockReset();
|
||||
orpcMocks.patchResume.mockReset();
|
||||
orpcMocks.streamSubscribe.mockReset();
|
||||
orpcMocks.updateResume.mockReset();
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { WritableDraft } from "immer";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { consumeEventIterator } from "@orpc/client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "@tanstack/react-router";
|
||||
import { useBlocker, useParams } from "@tanstack/react-router";
|
||||
import { debounce, isEqual } from "es-toolkit";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
@@ -65,6 +65,7 @@ type Runtime = {
|
||||
isSaving: boolean;
|
||||
pendingResume?: Resume;
|
||||
syncErrorToastId?: string;
|
||||
slowSaveToastId?: string;
|
||||
syncResume: ReturnType<typeof debounce<(resume: Resume) => void>>;
|
||||
beforeUnloadHandler?: () => void;
|
||||
deferredRemoteResume?: Resume;
|
||||
@@ -78,6 +79,7 @@ type ResumeUpdateSubscriptionOptions = {
|
||||
};
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
const NAVIGATION_SAVE_WAIT_MS = 10_000;
|
||||
// Rapid edits within this window coalesce into a single undo step (e.g. typing a word / dragging).
|
||||
const HISTORY_COALESCE_MS = 500;
|
||||
// Bounded stacks: keep undo/redo memory (whole-resume snapshots) predictable during a long session.
|
||||
@@ -231,6 +233,10 @@ async function flushResumeSave(id: string) {
|
||||
timeout: 0,
|
||||
});
|
||||
} finally {
|
||||
if (runtime.slowSaveToastId !== undefined) {
|
||||
toast.close(runtime.slowSaveToastId);
|
||||
runtime.slowSaveToastId = undefined;
|
||||
}
|
||||
runtime.isSaving = false;
|
||||
if (runtime.pendingResume && runtime.syncErrorToastId === undefined) void flushResumeSave(id);
|
||||
}
|
||||
@@ -664,11 +670,56 @@ export function useBuilderResumeUpdateSubscription() {
|
||||
useResumeUpdateSubscription({ resumeId, onUpdate, onError });
|
||||
}
|
||||
|
||||
// Route transitions can await a save; unmount cleanup and browser unload cannot.
|
||||
function saveResumeBeforeLeaving(id: string): boolean | Promise<boolean> {
|
||||
const runtime = runtimes.get(id);
|
||||
const current = useResumeStore.getState().resume;
|
||||
if (!runtime?.hasPendingLocalChanges || current?.id !== id) return true;
|
||||
|
||||
runtime.syncResume.cancel();
|
||||
runtime.pendingResume = cloneResume(current);
|
||||
useResumeStore.getState().setSaveStatus("saving");
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const finish = (saved: boolean) => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(saved);
|
||||
};
|
||||
const unsubscribe = useResumeStore.subscribe((state) => {
|
||||
if (state.resume?.id !== id || state.saveStatus === "error") {
|
||||
finish(false);
|
||||
} else if (state.saveStatus === "saved" && !runtime.hasPendingLocalChanges) {
|
||||
finish(true);
|
||||
}
|
||||
});
|
||||
const timeout = setTimeout(() => {
|
||||
finish(false);
|
||||
// Keep the write in flight: it may already have reached the server.
|
||||
runtime.slowSaveToastId = toast.add({
|
||||
type: "info",
|
||||
description: t`Saving is taking longer than expected. Your changes are still open.`,
|
||||
id: runtime.slowSaveToastId,
|
||||
timeout: 0,
|
||||
});
|
||||
}, NAVIGATION_SAVE_WAIT_MS);
|
||||
void flushResumeSave(id);
|
||||
});
|
||||
}
|
||||
|
||||
export function useResumeCleanup() {
|
||||
const params = useParams({ strict: false }) as { resumeId?: string };
|
||||
const resumeId = params.resumeId;
|
||||
const reset = useResumeStore((state) => state.reset);
|
||||
|
||||
useBlocker({
|
||||
shouldBlockFn: async ({ next }) => {
|
||||
if (!resumeId || ("resumeId" in next.params && next.params.resumeId === resumeId)) return false;
|
||||
return !(await saveResumeBeforeLeaving(resumeId));
|
||||
},
|
||||
enableBeforeUnload: () => !!resumeId && (runtimes.get(resumeId)?.hasPendingLocalChanges ?? false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!resumeId) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Resume } from "./draft";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { ConfirmDialogProvider } from "@/hooks/use-confirm";
|
||||
import { CustomSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/left/sections/custom";
|
||||
import { useResumeStore } from "./draft";
|
||||
import {
|
||||
focusLeftSidebarSection,
|
||||
getVisibleLeftSidebarSections,
|
||||
SectionEditorList,
|
||||
SectionRecovery,
|
||||
} from "./section-recovery";
|
||||
|
||||
const routerParams = vi.hoisted(() => ({ resumeId: "section-recovery" }));
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
useParams: () => routerParams,
|
||||
}));
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
getById: { queryOptions: () => ({ queryKey: ["resume", "section-recovery"] }) },
|
||||
patch: { call: vi.fn() },
|
||||
update: { call: vi.fn(() => new Promise(() => undefined)) },
|
||||
},
|
||||
},
|
||||
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/ui/components/toast", () => ({
|
||||
toast: { add: vi.fn(), close: vi.fn() },
|
||||
}));
|
||||
|
||||
function makeResume(data: ResumeData, isLocked = false): Resume {
|
||||
return {
|
||||
id: routerParams.resumeId,
|
||||
name: "Section Recovery",
|
||||
slug: "section-recovery",
|
||||
tags: [],
|
||||
data,
|
||||
isLocked,
|
||||
updatedAt: new Date("2026-09-06T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function makeHiddenData(): ResumeData {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.summary.hidden = true;
|
||||
data.sections.experience.hidden = true;
|
||||
data.sections.experience.title = "Work History";
|
||||
data.customSections[0].hidden = true;
|
||||
data.customSections[0].title = "Earlier Roles";
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderRecovery(data = makeHiddenData(), isLocked = false) {
|
||||
useResumeStore.getState().initialize(makeResume(data, isLocked));
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<fieldset disabled={isLocked}>
|
||||
<SectionRecovery />
|
||||
</fieldset>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("hidden section recovery", () => {
|
||||
it("keeps Picture, Basics, and Custom editors while removing hidden printable editors", () => {
|
||||
const visible = getVisibleLeftSidebarSections(makeHiddenData());
|
||||
|
||||
expect(visible).toContain("picture");
|
||||
expect(visible).toContain("basics");
|
||||
expect(visible).toContain("custom");
|
||||
expect(visible).not.toContain("summary");
|
||||
expect(visible).not.toContain("experience");
|
||||
});
|
||||
|
||||
it("does not mount full editors for hidden printable sections", () => {
|
||||
useResumeStore.getState().initialize(makeResume(makeHiddenData()));
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<SectionEditorList renderSection={(section) => <div data-testid={`editor-${section}`} />} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("editor-picture")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("editor-basics")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("editor-custom")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("editor-summary")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("editor-experience")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the custom editor container while omitting only hidden custom children", () => {
|
||||
const data = makeHiddenData();
|
||||
useResumeStore.getState().initialize(makeResume(data));
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ConfirmDialogProvider>
|
||||
<CustomSectionBuilder />
|
||||
</ConfirmDialogProvider>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Earlier Roles")).not.toBeInTheDocument();
|
||||
expect(screen.getAllByText("Cover Letter").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("button", { name: "Add a new custom section" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists hidden built-in, summary, and custom sections by effective title", () => {
|
||||
renderRecovery();
|
||||
|
||||
expect(screen.getByRole("region", { name: "Hidden sections" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Show Summary section" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Show Earlier Roles section" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows via keyboard, changes only hidden state, and participates in undo and redo", async () => {
|
||||
const data = makeHiddenData();
|
||||
const before = structuredClone(data);
|
||||
vi.useRealTimers();
|
||||
const user = userEvent.setup();
|
||||
renderRecovery(data);
|
||||
const show = screen.getByRole("button", { name: "Show Work History section" });
|
||||
|
||||
show.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
const shown = useResumeStore.getState().resume?.data;
|
||||
expect(shown?.sections.experience.hidden).toBe(false);
|
||||
expect({ ...shown?.sections.experience, hidden: true }).toEqual(before.sections.experience);
|
||||
expect(shown?.metadata.layout).toEqual(before.metadata.layout);
|
||||
expect(screen.queryByRole("button", { name: "Show Work History section" })).not.toBeInTheDocument();
|
||||
|
||||
act(() => useResumeStore.getState().undo());
|
||||
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true);
|
||||
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
|
||||
|
||||
act(() => useResumeStore.getState().redo());
|
||||
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("shows an unplaced hidden section without choosing a layout location", () => {
|
||||
const data = makeHiddenData();
|
||||
for (const page of data.metadata.layout.pages) {
|
||||
page.main = page.main.filter((id) => id !== "experience");
|
||||
page.sidebar = page.sidebar.filter((id) => id !== "experience");
|
||||
}
|
||||
const layoutBefore = structuredClone(data.metadata.layout);
|
||||
renderRecovery(data);
|
||||
|
||||
act(() => screen.getByRole("button", { name: "Show Work History section" }).click());
|
||||
|
||||
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false);
|
||||
expect(useResumeStore.getState().resume?.data.metadata.layout).toEqual(layoutBefore);
|
||||
});
|
||||
|
||||
it("disables recovery actions for a locked resume", () => {
|
||||
renderRecovery(makeHiddenData(), true);
|
||||
const show = screen.getByRole("button", { name: "Show Work History section" });
|
||||
|
||||
expect(show).toBeDisabled();
|
||||
show.click();
|
||||
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true);
|
||||
expect(useResumeStore.getState().undoStack).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reopens a collapsed recovery group before focusing and scrolling the hidden section", async () => {
|
||||
vi.useRealTimers();
|
||||
const user = userEvent.setup();
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
});
|
||||
renderRecovery();
|
||||
const trigger = screen.getByRole("button", { name: "Hidden sections" });
|
||||
|
||||
await user.click(trigger);
|
||||
await waitFor(() => expect(document.getElementById("sidebar-hidden-experience")).toBeNull());
|
||||
|
||||
focusLeftSidebarSection("experience");
|
||||
|
||||
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
|
||||
const recoveryEntry = await waitFor(() => {
|
||||
const entry = document.getElementById("sidebar-hidden-experience");
|
||||
expect(entry).not.toBeNull();
|
||||
return entry;
|
||||
});
|
||||
await waitFor(() => expect(recoveryEntry).toHaveFocus());
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "start",
|
||||
inline: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import type { LeftSidebarSection } from "@/libs/resume/section";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { EyeClosedIcon, EyeIcon } from "@phosphor-icons/react";
|
||||
import { Fragment } from "react";
|
||||
import { getSectionAvailability } from "@reactive-resume/resume/section-availability";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
|
||||
|
||||
export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSection[] {
|
||||
const hiddenSectionIds = new Set(
|
||||
getSectionAvailability(data)
|
||||
.filter((section) => section.hidden)
|
||||
.map((section) => section.sectionId),
|
||||
);
|
||||
|
||||
return leftSidebarSections.filter(
|
||||
(section) =>
|
||||
section === "picture" || section === "basics" || section === "custom" || !hiddenSectionIds.has(section),
|
||||
);
|
||||
}
|
||||
|
||||
export function focusLeftSidebarSection(section: LeftSidebarSection): void {
|
||||
const editorTarget = document.getElementById(`sidebar-${section}`);
|
||||
if (editorTarget) {
|
||||
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
|
||||
const recoveryTargetId = `sidebar-hidden-${section}`;
|
||||
const focusRecoveryTarget = () => {
|
||||
const recoveryTarget = document.getElementById(recoveryTargetId);
|
||||
if (!recoveryTarget) return;
|
||||
|
||||
recoveryTarget.focus({ preventScroll: true });
|
||||
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
};
|
||||
|
||||
const trigger = document.getElementById("sidebar-hidden-sections-trigger");
|
||||
if (trigger?.getAttribute("aria-expanded") === "false") {
|
||||
trigger.click();
|
||||
requestAnimationFrame(focusRecoveryTarget);
|
||||
return;
|
||||
}
|
||||
|
||||
focusRecoveryTarget();
|
||||
}
|
||||
|
||||
type SectionEditorListProps = {
|
||||
renderSection: (section: LeftSidebarSection) => ReactNode;
|
||||
};
|
||||
|
||||
export function SectionEditorList({ renderSection }: SectionEditorListProps) {
|
||||
const sectionKey = useCurrentBuilderResumeSelector((resume) => getVisibleLeftSidebarSections(resume.data).join(","));
|
||||
const sections = sectionKey.split(",") as LeftSidebarSection[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{renderSection(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
<SectionRecovery />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getRecoverySectionTitle(data: ResumeData, sectionId: string): string {
|
||||
if (sectionId === "summary") return data.summary.title || getSectionTitle("summary");
|
||||
|
||||
if (Object.hasOwn(data.sections, sectionId)) {
|
||||
const type = sectionId as SectionType;
|
||||
return data.sections[type].title || getSectionTitle(type);
|
||||
}
|
||||
|
||||
const customSection = data.customSections.find((section) => section.id === sectionId);
|
||||
return customSection?.title || (customSection ? getSectionTitle(customSection.type) : sectionId);
|
||||
}
|
||||
|
||||
export function SectionRecovery() {
|
||||
const data = useCurrentBuilderResumeSelector((resume) => resume.data);
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const hiddenSections = getSectionAvailability(data).filter((section) => section.hidden);
|
||||
|
||||
if (hiddenSections.length === 0) return null;
|
||||
|
||||
const showSection = (sectionId: string) => {
|
||||
updateResumeData((draft) => {
|
||||
if (sectionId === "summary") {
|
||||
draft.summary.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.hasOwn(draft.sections, sectionId)) {
|
||||
draft.sections[sectionId as SectionType].hidden = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const customSection = draft.customSections.find((section) => section.id === sectionId);
|
||||
if (customSection) customSection.hidden = false;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Accordion defaultValue={["hidden-sections"]}>
|
||||
<AccordionItem value="hidden-sections" className="rounded-md border px-3">
|
||||
<AccordionTrigger
|
||||
id="sidebar-hidden-sections-trigger"
|
||||
className="items-center no-underline hover:no-underline"
|
||||
>
|
||||
<span className="flex items-center gap-x-2">
|
||||
<EyeClosedIcon aria-hidden="true" />
|
||||
<Trans>Hidden sections</Trans>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-3">
|
||||
<ul className="space-y-2">
|
||||
{hiddenSections.map(({ sectionId }) => {
|
||||
const title = getRecoverySectionTitle(data, sectionId);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={sectionId}
|
||||
id={`sidebar-hidden-${sectionId}`}
|
||||
tabIndex={-1}
|
||||
className="flex items-center justify-between gap-x-3 rounded-md bg-secondary/40 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium text-sm">{title}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t`Show ${title} section`}
|
||||
onClick={() => showSection(sectionId)}
|
||||
>
|
||||
<EyeIcon aria-hidden="true" />
|
||||
<Trans>Show</Trans>
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import { getResumeThumbnailRenderSize, RESUME_THUMBNAIL_TARGET_WIDTH } from "./resume-thumbnail.shared";
|
||||
import type { ResumeThumbnailSize } from "./resume-thumbnail.shared";
|
||||
import { getResumeThumbnailRenderSize } from "./resume-thumbnail.shared";
|
||||
|
||||
const canvasToBlob = (canvas: HTMLCanvasElement) =>
|
||||
new Promise<Blob>((resolve, reject) => {
|
||||
@@ -13,26 +14,35 @@ const canvasToBlob = (canvas: HTMLCanvasElement) =>
|
||||
}, "image/png");
|
||||
});
|
||||
|
||||
export const createPdfFirstPageImageUrl = async (file: Blob) => {
|
||||
export const createPdfFirstPageImageUrl = async (file: Blob, targetSize: ResumeThumbnailSize, signal?: AbortSignal) => {
|
||||
signal?.throwIfAborted();
|
||||
const { AnnotationMode, GlobalWorkerOptions, getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url).toString();
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
signal?.throwIfAborted();
|
||||
const loadingTask = getDocument({ data: new Uint8Array(arrayBuffer) });
|
||||
let pdfDocument: Awaited<typeof loadingTask.promise> | undefined;
|
||||
let renderTask: { cancel: () => void } | undefined;
|
||||
let destruction: Promise<void> | undefined;
|
||||
const destroy = () => (destruction ??= loadingTask.destroy());
|
||||
const abort = () => {
|
||||
renderTask?.cancel();
|
||||
void destroy();
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
pdfDocument = await loadingTask.promise;
|
||||
signal?.throwIfAborted();
|
||||
const page = await pdfDocument.getPage(1);
|
||||
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const baseViewport = page.getViewport({ scale: 1 });
|
||||
const pageSize: PreviewPageSize = { height: baseViewport.height, width: baseViewport.width };
|
||||
const renderSize = getResumeThumbnailRenderSize(
|
||||
pageSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
window.devicePixelRatio || 1,
|
||||
);
|
||||
const renderSize = getResumeThumbnailRenderSize(pageSize, targetSize);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const canvasContext = canvas.getContext("2d");
|
||||
@@ -43,22 +53,29 @@ export const createPdfFirstPageImageUrl = async (file: Blob) => {
|
||||
canvas.width = renderSize.width;
|
||||
|
||||
const viewport = page.getViewport({ scale: renderSize.scale });
|
||||
const renderTask = page.render({
|
||||
const task = page.render({
|
||||
canvas,
|
||||
canvasContext,
|
||||
viewport,
|
||||
annotationMode: AnnotationMode.DISABLE,
|
||||
background: "white",
|
||||
});
|
||||
renderTask = task;
|
||||
|
||||
await renderTask.promise;
|
||||
await task.promise;
|
||||
signal?.throwIfAborted();
|
||||
|
||||
const image = await canvasToBlob(canvas);
|
||||
signal?.throwIfAborted();
|
||||
return URL.createObjectURL(image);
|
||||
} finally {
|
||||
page.cleanup();
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
|
||||
throw error;
|
||||
} finally {
|
||||
void loadingTask.destroy();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
void destroy();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const pdfjsMock = vi.hoisted(() => {
|
||||
const page = {
|
||||
cleanup: vi.fn(),
|
||||
getViewport: vi.fn(({ scale }: { scale: number }) => ({ height: 200 * scale, width: 100 * scale })),
|
||||
render: vi.fn(() => ({ promise: Promise.resolve() })),
|
||||
render: vi.fn(() => ({ promise: Promise.resolve(), cancel: vi.fn() })),
|
||||
};
|
||||
|
||||
const pdfDocument = {
|
||||
@@ -15,7 +15,7 @@ const pdfjsMock = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
const loadingTask = {
|
||||
destroy: vi.fn(),
|
||||
destroy: vi.fn(async () => {}),
|
||||
promise: Promise.resolve(pdfDocument),
|
||||
};
|
||||
|
||||
@@ -85,9 +85,9 @@ describe("PDF.js browser entrypoints", () => {
|
||||
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
|
||||
await expect(createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }))).resolves.toBe(
|
||||
"blob:thumbnail",
|
||||
);
|
||||
await expect(
|
||||
createPdfFirstPageImageUrl(new Blob(["%PDF"], { type: "application/pdf" }), { width: 600, height: 900 }),
|
||||
).resolves.toBe("blob:thumbnail");
|
||||
|
||||
expect(pdfjsMock.legacyModule.GlobalWorkerOptions.workerSrc).toContain(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs",
|
||||
@@ -100,4 +100,39 @@ describe("PDF.js browser entrypoints", () => {
|
||||
);
|
||||
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels obsolete thumbnail rasterization and releases the PDF loading task", async () => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({} as CanvasRenderingContext2D);
|
||||
vi.spyOn(URL, "createObjectURL").mockImplementation(pdfjsMock.createObjectURL);
|
||||
let rejectRender!: (error: Error) => void;
|
||||
const promise = new Promise<never>((_resolve, reject) => {
|
||||
rejectRender = reject;
|
||||
});
|
||||
const cancel = vi.fn(() => rejectRender(new pdfjsMock.legacyModule.RenderingCancelledException("cancelled")));
|
||||
pdfjsMock.page.render.mockReturnValueOnce({ promise, cancel });
|
||||
const controller = new AbortController();
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
const pending = createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal);
|
||||
const rejected = expect(pending).rejects.toMatchObject({ name: "AbortError" });
|
||||
await vi.waitFor(() => expect(pdfjsMock.page.render).toHaveBeenCalledTimes(1));
|
||||
try {
|
||||
controller.abort();
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rejectRender(new DOMException("cancelled", "AbortError"));
|
||||
await rejected;
|
||||
}
|
||||
expect(pdfjsMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(pdfjsMock.createObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not load a PDF for an already aborted thumbnail request", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const { createPdfFirstPageImageUrl } = await import("./pdf-thumbnail");
|
||||
await expect(
|
||||
createPdfFirstPageImageUrl(new Blob(["%PDF"]), { width: 600, height: 900 }, controller.signal),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(pdfjsMock.legacyModule.getDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getResumeThumbnailCacheKey,
|
||||
getResumeThumbnailRenderSize,
|
||||
RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
getResumeThumbnailSize,
|
||||
} from "./resume-thumbnail.shared";
|
||||
|
||||
describe("getResumeThumbnailCacheKey", () => {
|
||||
@@ -20,45 +20,40 @@ describe("getResumeThumbnailCacheKey", () => {
|
||||
});
|
||||
|
||||
describe("getResumeThumbnailRenderSize", () => {
|
||||
it("uses the default target width when not provided", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 });
|
||||
|
||||
expect(size.width).toBe(RESUME_THUMBNAIL_TARGET_WIDTH);
|
||||
// Scale = 420/800 = 0.525 → height rounds to 630
|
||||
expect(size.height).toBe(630);
|
||||
expect(size.scale).toBeCloseTo(0.525, 5);
|
||||
it("covers the measured 607 CSS pixel card at DPR 3", () => {
|
||||
const target = getResumeThumbnailSize({ width: 607, height: 858.47 }, 3);
|
||||
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target);
|
||||
expect(size.width).toBeGreaterThanOrEqual(1821);
|
||||
expect(size.height).toBeGreaterThanOrEqual(2575);
|
||||
});
|
||||
|
||||
it("scales relative to the provided target width", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400);
|
||||
expect(size.width).toBe(400);
|
||||
expect(size.scale).toBeCloseTo(0.5, 5);
|
||||
expect(size.height).toBe(600);
|
||||
it("fits landscape pages within the measured container", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 1200, height: 800 }, { width: 600, height: 900 });
|
||||
expect(size).toEqual({ width: 600, height: 400, scale: 0.5 });
|
||||
});
|
||||
|
||||
it("clamps pixelRatio to a minimum of 1", () => {
|
||||
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 0.5);
|
||||
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 1);
|
||||
it("fits tall unpaginated pages by height rather than allocating their full width", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 600, height: 12000 }, { width: 600, height: 900 });
|
||||
expect(size).toEqual({ width: 45, height: 900, scale: 0.075 });
|
||||
});
|
||||
|
||||
it("bounds extreme zoom and viewport sizes without stretching the page", () => {
|
||||
const target = getResumeThumbnailSize({ width: 5000, height: 7071 }, 8);
|
||||
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, target);
|
||||
expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072);
|
||||
expect(Math.max(size.width, size.height)).toBeLessThanOrEqual(3072);
|
||||
expect(size.width / size.height).toBeCloseTo(595.28 / 841.89, 2);
|
||||
});
|
||||
|
||||
it("enforces the canvas budget when retained width and height came from different size buckets", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 595.28, height: 841.89 }, { width: 2112, height: 2985 });
|
||||
expect(size.width * size.height).toBeLessThanOrEqual(2048 * 3072);
|
||||
});
|
||||
|
||||
it("groups small fractional layout changes into the same physical size", () => {
|
||||
const a = getResumeThumbnailSize({ width: 295.5, height: 417.93 }, 2);
|
||||
const b = getResumeThumbnailSize({ width: 296, height: 418.64 }, 2);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("clamps pixelRatio to a maximum of 2", () => {
|
||||
const a = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 3);
|
||||
const b = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it("multiplies width/height by pixelRatio when within bounds", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 800, height: 1200 }, 400, 2);
|
||||
// pageScale = 0.5, outputScale = 2 → width = 800, height = 1200
|
||||
expect(size.width).toBe(800);
|
||||
expect(size.height).toBe(1200);
|
||||
expect(size.scale).toBeCloseTo(1, 5);
|
||||
});
|
||||
|
||||
it("rounds the output dimensions to integers", () => {
|
||||
const size = getResumeThumbnailRenderSize({ width: 793, height: 1123 }, 421, 1.5);
|
||||
expect(Number.isInteger(size.width)).toBe(true);
|
||||
expect(Number.isInteger(size.height)).toBe(true);
|
||||
expect(a.width).toBeGreaterThanOrEqual(592);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
type PageSize = {
|
||||
export type ResumeThumbnailSize = {
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const RESUME_THUMBNAIL_TARGET_WIDTH = 420;
|
||||
const MAX_THUMBNAIL_PIXEL_RATIO = 2;
|
||||
// A single-column Grid card is at most 608 CSS px before the sm breakpoint.
|
||||
// Its A4 container at DPR 3 needs 1824 x 2580 px. This budget accommodates it
|
||||
// with room for size buckets, while limiting extreme zoom/ultrawide canvases
|
||||
// to 24 MiB of RGBA pixels and 3072 px on either side.
|
||||
const MAX_THUMBNAIL_PIXELS = 2048 * 3072;
|
||||
const MAX_THUMBNAIL_DIMENSION = 3072;
|
||||
const THUMBNAIL_SIZE_STEP = 64;
|
||||
|
||||
export const getResumeThumbnailCacheKey = (resumeId: string, updatedAt: Date) => {
|
||||
return `${resumeId}:${updatedAt.getTime()}`;
|
||||
};
|
||||
|
||||
export const getResumeThumbnailRenderSize = (
|
||||
pageSize: PageSize,
|
||||
targetWidth = RESUME_THUMBNAIL_TARGET_WIDTH,
|
||||
pixelRatio = 1,
|
||||
) => {
|
||||
const outputScale = Math.min(Math.max(pixelRatio, 1), MAX_THUMBNAIL_PIXEL_RATIO);
|
||||
const pageScale = targetWidth / pageSize.width;
|
||||
const limitThumbnailSize = ({ width, height }: ResumeThumbnailSize): ResumeThumbnailSize => {
|
||||
const scale = Math.min(
|
||||
1,
|
||||
MAX_THUMBNAIL_DIMENSION / Math.max(width, height),
|
||||
Math.sqrt(MAX_THUMBNAIL_PIXELS / (width * height)),
|
||||
);
|
||||
return { width: Math.floor(width * scale), height: Math.floor(height * scale) };
|
||||
};
|
||||
|
||||
export const getResumeThumbnailSize = (container: ResumeThumbnailSize, pixelRatio: number): ResumeThumbnailSize => {
|
||||
const ratio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1;
|
||||
return limitThumbnailSize({
|
||||
width: Math.ceil((container.width * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP,
|
||||
height: Math.ceil((container.height * ratio) / THUMBNAIL_SIZE_STEP) * THUMBNAIL_SIZE_STEP,
|
||||
});
|
||||
};
|
||||
|
||||
export const getResumeThumbnailRenderSize = (pageSize: ResumeThumbnailSize, target: ResumeThumbnailSize) => {
|
||||
const bounds = limitThumbnailSize(target);
|
||||
// Match background-size: contain, including landscape and unpaginated PDFs.
|
||||
const scale = Math.min(bounds.width / pageSize.width, bounds.height / pageSize.height);
|
||||
|
||||
return {
|
||||
height: Math.round(pageSize.height * pageScale * outputScale),
|
||||
scale: pageScale * outputScale,
|
||||
width: Math.round(targetWidth * outputScale),
|
||||
height: Math.min(bounds.height, Math.ceil(pageSize.height * scale)),
|
||||
scale,
|
||||
width: Math.min(bounds.width, Math.ceil(pageSize.width * scale)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,17 +3,17 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Fragment, useCallback, useRef } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { match } from "ts-pattern";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
import { Separator } from "@reactive-resume/ui/components/separator";
|
||||
import { toast } from "@reactive-resume/ui/components/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||
import { getInitials } from "@reactive-resume/utils/string";
|
||||
import { CoverLetterLibraryDialog } from "@/features/cover-letters/library";
|
||||
import { useCurrentResume, useIsResumeLocked, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { focusLeftSidebarSection, SectionEditorList } from "@/features/resume/builder/section-recovery";
|
||||
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
@@ -74,12 +74,7 @@ export function BuilderSidebarLeft() {
|
||||
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
|
||||
|
||||
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
|
||||
{leftSidebarSections.map((section) => (
|
||||
<Fragment key={section}>
|
||||
{getSectionComponent(section)}
|
||||
<Separator />
|
||||
</Fragment>
|
||||
))}
|
||||
<SectionEditorList renderSection={getSectionComponent} />
|
||||
</fieldset>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -132,11 +127,7 @@ function SidebarEdge() {
|
||||
const scrollToSection = useCallback(
|
||||
(section: LeftSidebarSection) => {
|
||||
toggleSidebar("left", true);
|
||||
// Section ids are globally unique; document.getElementById reliably resolves the scroll target
|
||||
// (querying through the ScrollArea ref did not — its ref does not expose the scroll container).
|
||||
document
|
||||
.getElementById(`sidebar-${section}`)
|
||||
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
|
||||
focusLeftSidebarSection(section);
|
||||
},
|
||||
[toggleSidebar],
|
||||
);
|
||||
|
||||
@@ -127,9 +127,11 @@ export function CustomSectionBuilder() {
|
||||
return (
|
||||
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
|
||||
<AnimatePresence>
|
||||
{customSections.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
{customSections
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section) => (
|
||||
<CustomSectionContainer key={section.id} section={section} />
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Add Custom Section Button */}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { BasicsSectionBuilder } from "./basics";
|
||||
import { PictureSectionBuilder } from "./picture";
|
||||
|
||||
const state = vi.hoisted(() => ({ data: {} as ResumeData, update: vi.fn() }));
|
||||
const state = vi.hoisted(() => ({ data: {} as ResumeData, update: vi.fn(), uploadFile: vi.fn() }));
|
||||
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useCurrentBuilderResumeSelector: (selector: (resume: { data: ResumeData }) => unknown) => selector(state),
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/libs/tanstack-form", async () => {
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
storage: {
|
||||
uploadFile: { mutationOptions: () => ({ mutationFn: vi.fn() }) },
|
||||
uploadFile: { mutationOptions: () => ({ mutationFn: state.uploadFile }) },
|
||||
deleteFile: { mutationOptions: () => ({ mutationFn: vi.fn() }) },
|
||||
},
|
||||
},
|
||||
@@ -47,6 +47,8 @@ beforeEach(() => {
|
||||
state.data = structuredClone(defaultResumeData);
|
||||
state.update.mockReset();
|
||||
state.update.mockImplementation((update: (draft: ResumeData) => void) => update(state.data));
|
||||
state.uploadFile.mockReset();
|
||||
state.uploadFile.mockResolvedValue({ url: "/uploads/picture.png" });
|
||||
});
|
||||
|
||||
function renderSection(children: ReactNode) {
|
||||
@@ -83,4 +85,69 @@ describe("builder field labels", () => {
|
||||
fireEvent.change(input, { target: { value: "144" } });
|
||||
await waitFor(() => expect(state.data.picture.size).toBe(144));
|
||||
});
|
||||
|
||||
it("persists named fit choices and previews contain without cropping", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection(<PictureSectionBuilder />);
|
||||
|
||||
expect(screen.getByRole("group", { name: "Fit" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Contain" }));
|
||||
await waitFor(() => expect(state.data.picture.fit).toBe("contain"));
|
||||
|
||||
const file = new File(["full-image"], "full.png", { type: "image/png" });
|
||||
await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file);
|
||||
|
||||
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce());
|
||||
expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file);
|
||||
const preview = screen.getByRole("button", { name: "Delete picture" }).querySelector("img");
|
||||
expect(preview).toBeInTheDocument();
|
||||
expect(getComputedStyle(preview as HTMLImageElement).objectFit).toBe("contain");
|
||||
expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps cover uploads in cancelable crop flow", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSection(<PictureSectionBuilder />);
|
||||
|
||||
const file = new File(["crop-image"], "crop.png", { type: "image/png" });
|
||||
await user.upload(screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement, file);
|
||||
const dialog = screen.getByRole("dialog", { name: "Crop picture" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(dialog).not.toBeInTheDocument();
|
||||
expect(state.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries the same full contain file after an upload error", async () => {
|
||||
state.uploadFile.mockRejectedValueOnce(new Error("Upload failed"));
|
||||
const user = userEvent.setup();
|
||||
renderSection(<PictureSectionBuilder />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Contain" }));
|
||||
const file = new File(["full-image"], "full.png", { type: "image/png" });
|
||||
const input = screen.getAllByLabelText("Upload picture")[0] as HTMLInputElement;
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledOnce());
|
||||
expect(state.data.picture.url).toBe("");
|
||||
expect(screen.queryByRole("dialog", { name: "Crop picture" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(input.files).toHaveLength(0));
|
||||
|
||||
await user.upload(input, file);
|
||||
await waitFor(() => expect(state.uploadFile).toHaveBeenCalledTimes(2));
|
||||
expect(state.uploadFile.mock.calls[0]?.[0]).toBe(file);
|
||||
expect(state.uploadFile.mock.calls[1]?.[0]).toBe(file);
|
||||
});
|
||||
|
||||
it("disables fit and upload controls inside the builder lock fieldset", () => {
|
||||
renderSection(
|
||||
<fieldset disabled>
|
||||
<PictureSectionBuilder />
|
||||
</fieldset>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Contain" })).toBeDisabled();
|
||||
expect(screen.getAllByLabelText("Upload picture")[0]).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,8 @@ function PicturePreviewControls({
|
||||
<img
|
||||
alt=""
|
||||
src={normalizedPictureUrl}
|
||||
className="fade-in relative z-10 size-full animate-in rounded-md object-cover transition-opacity group-hover/picture:opacity-20"
|
||||
style={{ objectFit: picture.fit }}
|
||||
className="fade-in relative z-10 size-full animate-in rounded-md transition-opacity group-hover/picture:opacity-20"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -143,12 +144,58 @@ function PicturePreviewControls({
|
||||
);
|
||||
}
|
||||
|
||||
type PictureGeometryFieldsProps = {
|
||||
type PictureFieldProps = {
|
||||
form: PictureSettingsForm;
|
||||
onAutoSave: () => void;
|
||||
};
|
||||
|
||||
function PictureGeometryFields({ form, onAutoSave }: PictureGeometryFieldsProps) {
|
||||
function PictureFitField({ form, onAutoSave }: PictureFieldProps) {
|
||||
return (
|
||||
<form.Field name="fit">
|
||||
{(field) => (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-sm">
|
||||
<Trans>Fit</Trans>
|
||||
</div>
|
||||
<ButtonGroup role="group" aria-label={t`Fit`} className="w-full">
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.state.value === "cover" ? "default" : "outline"}
|
||||
aria-pressed={field.state.value === "cover"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
field.handleChange("cover");
|
||||
onAutoSave();
|
||||
}}
|
||||
>
|
||||
<Trans>Cover</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={field.state.value === "contain" ? "default" : "outline"}
|
||||
aria-pressed={field.state.value === "contain"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
field.handleChange("contain");
|
||||
onAutoSave();
|
||||
}}
|
||||
>
|
||||
<Trans>Contain</Trans>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<Trans>
|
||||
Cover crops the image to fill the frame. Contain shows the whole image. Reupload the original to restore
|
||||
edges removed by an earlier crop.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
);
|
||||
}
|
||||
|
||||
function PictureGeometryFields({ form, onAutoSave }: PictureFieldProps) {
|
||||
return (
|
||||
<>
|
||||
<form.Field name="size">
|
||||
@@ -519,7 +566,6 @@ function PictureSectionForm() {
|
||||
form.setFieldValue("url", url);
|
||||
handleAutoSave();
|
||||
toast.close(toastId);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.add({
|
||||
@@ -534,12 +580,19 @@ function PictureSectionForm() {
|
||||
id: toastId,
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onUploadPicture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (form.state.values.fit === "contain") {
|
||||
uploadPictureFile(file);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the interactive crop step instead of uploading immediately.
|
||||
setCropState({ file, imageSrc: URL.createObjectURL(file) });
|
||||
@@ -666,6 +719,8 @@ function PictureSectionForm() {
|
||||
onUploadPicture={onUploadPicture}
|
||||
/>
|
||||
|
||||
<PictureFitField form={form} onAutoSave={handleAutoSave} />
|
||||
|
||||
<div className="grid @md:grid-cols-2 grid-cols-1 gap-4">
|
||||
<PictureGeometryFields form={form} onAutoSave={handleAutoSave} />
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { ResumeThumbnail } from "./resume-thumbnail";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
inView: true,
|
||||
toPdf: vi.fn(async () => new Blob(["pdf"])),
|
||||
toImage: vi.fn(async (_pdf: Blob, size?: { width: number; height: number }) => `blob:${size?.width}x${size?.height}`),
|
||||
}));
|
||||
|
||||
vi.mock("motion/react", () => ({ useInView: () => mocks.inView }));
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob: mocks.toPdf }));
|
||||
vi.mock("@/features/resume/preview/pdf-thumbnail", () => ({ createPdfFirstPageImageUrl: mocks.toImage }));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
getById: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ["resume-data"],
|
||||
queryFn: async () => ({ data: sampleResumeData }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const resume: ComponentProps<typeof ResumeThumbnail>["resume"] = {
|
||||
id: "thumbnail-test",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
isLocked: false,
|
||||
isPublic: false,
|
||||
showDownloadButtons: true,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
let resize: () => void;
|
||||
let media: EventTarget;
|
||||
let width = 270;
|
||||
let height = 382;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.inView = true;
|
||||
mocks.toPdf.mockClear();
|
||||
mocks.toImage.mockClear();
|
||||
width = 270;
|
||||
height = 382;
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockImplementation(() => width);
|
||||
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockImplementation(() => height);
|
||||
vi.stubGlobal("devicePixelRatio", 2);
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
constructor(callback: () => void) {
|
||||
resize = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
vi.spyOn(window, "matchMedia").mockImplementation(() => {
|
||||
media = new EventTarget();
|
||||
return media as MediaQueryList;
|
||||
});
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } },
|
||||
});
|
||||
const ui = () => (
|
||||
<QueryClientProvider client={client}>
|
||||
<ResumeThumbnail resume={resume} isLocked={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const result = render(ui());
|
||||
const image = () => result.container.querySelector<HTMLElement>("[style*='background-image']")?.style.backgroundImage;
|
||||
return { ...result, image, refresh: () => result.rerender(ui()) };
|
||||
}
|
||||
|
||||
it("upgrades a cached image after growth and keeps it visible until replacement is ready", async () => {
|
||||
const view = setup();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
|
||||
let finish!: (url: string) => void;
|
||||
mocks.toImage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
width = 607;
|
||||
height = 859;
|
||||
resize();
|
||||
});
|
||||
await waitFor(() => expect(mocks.toImage).toHaveBeenCalledTimes(2));
|
||||
expect(view.image()).toContain("blob:576x768");
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||
expect(mocks.toImage.mock.calls[1]?.[1]).toEqual({ width: 1216, height: 1728 });
|
||||
await act(async () => finish("blob:1216x1728"));
|
||||
await waitFor(() => expect(view.image()).toContain("blob:1216x1728"));
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:576x768");
|
||||
act(() => {
|
||||
width = 295;
|
||||
height = 418;
|
||||
resize();
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(view.image()).toContain("blob:1216x1728");
|
||||
expect(mocks.toImage).toHaveBeenCalledTimes(2);
|
||||
view.unmount();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:1216x1728");
|
||||
});
|
||||
|
||||
it("upgrades after a DPR change even when CSS dimensions do not change", async () => {
|
||||
const view = setup();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
|
||||
act(() => {
|
||||
vi.stubGlobal("devicePixelRatio", 3);
|
||||
media.dispatchEvent(new Event("change"));
|
||||
});
|
||||
await waitFor(() => expect(view.image()).toContain("blob:832x1152"));
|
||||
});
|
||||
|
||||
it("does not generate thumbnails before an offscreen card enters view", async () => {
|
||||
mocks.inView = false;
|
||||
const view = setup();
|
||||
await act(async () => {});
|
||||
expect(view.image()).toBeUndefined();
|
||||
expect(mocks.toPdf).not.toHaveBeenCalled();
|
||||
mocks.inView = true;
|
||||
view.refresh();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:576x768"));
|
||||
mocks.inView = false;
|
||||
view.refresh();
|
||||
act(() => {
|
||||
width = 607;
|
||||
height = 859;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
expect(mocks.toImage).toHaveBeenCalledTimes(1);
|
||||
mocks.inView = true;
|
||||
view.refresh();
|
||||
await waitFor(() => expect(view.image()).toContain("blob:1216x1728"));
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { RefObject } from "react";
|
||||
import type { ResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import type { RouterOutput } from "@/libs/orpc/client";
|
||||
import { FileTextIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { createPdfFirstPageImageUrl } from "@/features/resume/preview/pdf-thumbnail";
|
||||
import { getResumeThumbnailCacheKey } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import { getResumeThumbnailCacheKey, getResumeThumbnailSize } from "@/features/resume/preview/resume-thumbnail.shared";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
|
||||
type ResumeListItem = RouterOutput["resume"]["list"][number];
|
||||
@@ -24,11 +26,11 @@ const throwIfAborted = (signal: AbortSignal) => {
|
||||
if (signal.aborted) throw new DOMException("Thumbnail generation aborted.", "AbortError");
|
||||
};
|
||||
|
||||
const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) => {
|
||||
const createResumeThumbnailUrl = async (data: ResumeData, size: ResumeThumbnailSize, signal: AbortSignal) => {
|
||||
const pdf = await createResumePdfBlob(data);
|
||||
throwIfAborted(signal);
|
||||
|
||||
const url = await createPdfFirstPageImageUrl(pdf);
|
||||
const url = await createPdfFirstPageImageUrl(pdf, size, signal);
|
||||
|
||||
if (signal.aborted) {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -38,18 +40,69 @@ const createResumeThumbnailUrl = async (data: ResumeData, signal: AbortSignal) =
|
||||
return url;
|
||||
};
|
||||
|
||||
function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | undefined): ThumbnailState {
|
||||
function useThumbnailSize(containerRef: RefObject<HTMLDivElement | null>, enabled: boolean) {
|
||||
const [size, setSize] = useState<ResumeThumbnailSize>({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!enabled || !container) return;
|
||||
let measured = { width: container.clientWidth, height: container.clientHeight };
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
let media: MediaQueryList;
|
||||
const syncSize = () => {
|
||||
clearTimeout(timeout);
|
||||
// Coalesce sidebar animations and continuous viewport resizing.
|
||||
timeout = setTimeout(() => {
|
||||
if (measured.width <= 0 || measured.height <= 0) return;
|
||||
const next = getResumeThumbnailSize(measured, window.devicePixelRatio);
|
||||
setSize((current) => {
|
||||
if (current.width >= next.width && current.height >= next.height) return current;
|
||||
return { width: Math.max(current.width, next.width), height: Math.max(current.height, next.height) };
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
const watchPixelRatio = () => {
|
||||
media?.removeEventListener("change", watchPixelRatio);
|
||||
media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
|
||||
media.addEventListener("change", watchPixelRatio);
|
||||
syncSize();
|
||||
};
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
measured = entries?.[0]?.contentRect ?? { width: container.clientWidth, height: container.clientHeight };
|
||||
syncSize();
|
||||
});
|
||||
observer.observe(container);
|
||||
watchPixelRatio();
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
media.removeEventListener("change", watchPixelRatio);
|
||||
};
|
||||
}, [containerRef, enabled]);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
function useResumeThumbnail(
|
||||
data: ResumeData | undefined,
|
||||
cacheKey: string,
|
||||
size: ResumeThumbnailSize,
|
||||
enabled: boolean,
|
||||
): ThumbnailState {
|
||||
const {
|
||||
data: thumbnailData,
|
||||
error: thumbnailError,
|
||||
isError: thumbnailIsError,
|
||||
} = useQuery({
|
||||
queryKey: ["resume-thumbnail", cacheKey],
|
||||
queryKey: ["resume-thumbnail", cacheKey, size.width, size.height],
|
||||
queryFn: ({ signal }) => {
|
||||
if (!data) throw new Error("Resume data is required to generate a thumbnail.");
|
||||
return createResumeThumbnailUrl(data, signal);
|
||||
return createResumeThumbnailUrl(data, size, signal);
|
||||
},
|
||||
enabled: Boolean(data && cacheKey),
|
||||
enabled: Boolean(enabled && data && size.width && size.height),
|
||||
placeholderData: (previous, query) => (query?.queryKey[1] === cacheKey ? previous : undefined),
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
@@ -74,14 +127,17 @@ function useResumeThumbnail(data: ResumeData | undefined, cacheKey: string | und
|
||||
|
||||
export function ResumeThumbnail({ isLocked, resume }: ResumeThumbnailProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px", once: true });
|
||||
const isInView = useInView(containerRef, { amount: 0.1, margin: "240px" });
|
||||
const size = useThumbnailSize(containerRef, isInView);
|
||||
const { data: resumeData, isError: resumeIsError } = useQuery({
|
||||
...orpc.resume.getById.queryOptions({ input: { id: resume.id } }),
|
||||
enabled: isInView,
|
||||
});
|
||||
const thumbnail = useResumeThumbnail(
|
||||
resumeData?.data,
|
||||
isInView ? getResumeThumbnailCacheKey(resume.id, resume.updatedAt) : undefined,
|
||||
getResumeThumbnailCacheKey(resume.id, resume.updatedAt),
|
||||
size,
|
||||
isInView,
|
||||
);
|
||||
const hasFailed = resumeIsError || thumbnail.status === "error";
|
||||
|
||||
|
||||
@@ -763,6 +763,7 @@ rss: true
|
||||
- **No more Browserless / Chromium dependency.** From v5.1.0 onwards, you do **not** need to run a `printer`, Browserless, or headless Chrome service alongside Reactive Resume. PDF generation is now performed entirely in your browser via `@react-pdf/renderer`. The `BROWSERLESS_TOKEN`, `PRINTER_ENDPOINT`, `PRINTER_APP_URL`, and `FLAG_DEBUG_PRINTER` environment variables are no longer read and can be removed from your `.env`.
|
||||
- **Database migrations.** This release ships several new schema migrations to bring the resume model up to date. They run automatically on container start, but you should **back up your PostgreSQL database before upgrading** in case rollback is needed.
|
||||
- **Custom CSS has been removed.** Because the resume PDF is no longer produced by a headless browser, raw CSS no longer applies to the exported document. A first-class template-customization story is on the roadmap for a future release.
|
||||
- **Job Listings has been removed.** The JSearch/RapidAPI-backed job listings feature and its settings were removed in the v5.1.0 transition. The old job-search settings path now redirects to **Integrations**. To tailor a resume, paste a job description into the AI Agent; see [Using the AI Agent workspace](/guides/using-ai-agent#tailor-a-resume-to-a-supplied-job-description).
|
||||
</Note>
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -3,24 +3,24 @@ title: "Accessing the previous version"
|
||||
description: "Access the previous version (v4) of Reactive Resume to retrieve old resumes, and export them for import into the latest version."
|
||||
---
|
||||
|
||||
## The previous version is still available
|
||||
## Check whether the previous version is available
|
||||
|
||||
If you've used Reactive Resume for a while, you may have resumes saved in the previous version. Version 4 (v4) is still fully accessible and will remain online for the foreseeable future.
|
||||
If you've used Reactive Resume for a while, you may have resumes saved in version 4 (v4). Access depends on whether
|
||||
your self-hosted instance or the hosted previous-version service is currently available.
|
||||
|
||||
<Info>The previous version of Reactive Resume is available at [https://v4.rxresu.me](https://v4.rxresu.me).</Info>
|
||||
<Info>
|
||||
When the hosted previous version is available, its address is
|
||||
[https://v4.rxresu.me](https://v4.rxresu.me). Availability is not guaranteed.
|
||||
</Info>
|
||||
|
||||
## Why keep the old version running?
|
||||
Self-hosted operators control their own v4 instance and backups. The [v4 to v5 migration
|
||||
guide](/self-hosting/migration) applies only to infrastructure they are authorized to operate. It does not authorize
|
||||
access to hosted databases or backups.
|
||||
|
||||
Anyone who created resumes in v4 can still open, edit, and export them there. Some people also prefer the interface they already know, and moving to a new version takes time.
|
||||
## When v4 is accessible
|
||||
|
||||
## How long will v4 be available?
|
||||
|
||||
The previous version will keep running for as long as possible, until the maintainer runs out of breath or funds to keep the server active. There are no immediate plans to shut it down.
|
||||
|
||||
<Warning>
|
||||
v4 will stay accessible for the foreseeable future, but I recommend moving to the latest version when you can. That
|
||||
is where new features and ongoing support land.
|
||||
</Warning>
|
||||
Open, export, and securely back up each resume you need. Import the export into v5 as a new resume; keep the v5 version
|
||||
until you have compared both copies.
|
||||
|
||||
## Accessing your v4 resumes
|
||||
|
||||
@@ -39,7 +39,7 @@ The previous version will keep running for as long as possible, until the mainta
|
||||
</Step>
|
||||
|
||||
<Step title="Access your resumes">
|
||||
Once signed in, you'll find all your previously created resumes in your dashboard, exactly as you left them.
|
||||
If the dashboard contains your resumes, export each one as JSON before making more changes.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
@@ -53,10 +53,28 @@ If you'd like to move your resumes to the latest version of Reactive Resume, you
|
||||
4. Use the import feature to upload your JSON file (select the "Reactive Resume v4 (JSON)" option)
|
||||
|
||||
<Info>
|
||||
Your existing resumes should already be available in the new version. If you don't see them, you can manually import
|
||||
them using the steps above.
|
||||
Import creates a separate resume. It should not be used to replace a newer v5 copy until you have compared both
|
||||
versions.
|
||||
</Info>
|
||||
|
||||
## When hosted v4 or a resume is unavailable
|
||||
|
||||
Only an authorized hosted service operator can determine whether a source snapshot exists. Open a GitHub issue without
|
||||
including resume contents, account credentials, reset links, or other private data. A useful request identifies the
|
||||
approximate time of the missing edits, the sign-in method, and whether the resume is missing or merely not visible.
|
||||
|
||||
Recovery is handled per owner. Before accessing content, the operator must record a private case with source snapshot
|
||||
time, owner verification, source-to-target mapping, target resume ID, content hashes, and proposed outcome. A matching
|
||||
email address, username, or resume title alone is not proof of ownership.
|
||||
|
||||
Default recovery result is a private JSON export delivered through an approved channel to a verified recipient. Old-only
|
||||
or divergent content must remain a separate copy; it must not overwrite a current v5 resume. If no source snapshot is
|
||||
available, the factual outcome is that the records cannot be recovered from the service. Local tooling cannot recreate
|
||||
missing source data.
|
||||
|
||||
An empty workspace with successful create responses or name conflicts can instead be a listing or account-mapping
|
||||
problem. That requires a separate session, create, list, and reload diagnosis; a v4 recovery export does not resolve it.
|
||||
|
||||
## Questions or issues?
|
||||
|
||||
If you run into problems accessing v4, or have questions about migrating your resumes, open an issue on [GitHub](https://github.com/amruthpillai/reactive-resume/issues).
|
||||
|
||||
@@ -45,8 +45,12 @@ Restoring an older patch rolls back that patch and any patches applied after it.
|
||||
|
||||
Live web research is handled only by the selected AI provider's native web search tool. When the provider/model supports it, the agent can use `web_search` for current company, industry, role, or URL-based context.
|
||||
|
||||
Provider-native `web_search` is not JSearch and does not restore the removed structured job-listings experience. It supplies web context to the agent; it does not provide a structured job-results API or guarantee that a provider/model can search.
|
||||
|
||||
When the provider/model does not support native web search, the agent still works for normal resume editing. If you ask it to browse, search the web, fetch a URL, or use current online context, it should tell you that live web research is unavailable with the selected provider/model and ask you to paste or attach the relevant content instead.
|
||||
|
||||
For a controlled workflow that works without live research, follow [Tailor a resume to a supplied job description](/guides/using-ai-agent#tailor-a-resume-to-a-supplied-job-description). Configure, test, and enable providers as described in [Using artificial intelligence](/guides/using-ai).
|
||||
|
||||
For self-hosted deployments:
|
||||
|
||||
- app-owned URL crawling is not available;
|
||||
|
||||
@@ -42,7 +42,7 @@ Every export runs from the same **Download** dialog. You can open it two ways.
|
||||
|
||||
If your resume includes a [cover letter section](/guides/adding-a-cover-letter), the download dialog treats the resume and cover letter as two distinct documents.
|
||||
|
||||
- The **Resume** tab exports only your resume content and excludes cover letter sections.
|
||||
- For PDF, DOCX, and Markdown, the **Resume** tab exports only your resume content and excludes cover letter sections. Its JSON export retains embedded cover-letter custom sections; independent cover letters remain separate dashboard exports.
|
||||
- The **Cover letter** tab exports only visible cover letter sections, rendered full width on their own page.
|
||||
|
||||
You do not need to reorder pages or hide sections by hand to produce a resume-only or cover-letter-only PDF. Select the tab that matches what you want, then pick a format.
|
||||
@@ -98,18 +98,83 @@ For examples and common workflows, see [Exporting a resume to Markdown](/guides/
|
||||
|
||||
Choose **JSON** when you want a structured backup of your resume.
|
||||
|
||||
The JSON export includes your full resume content and settings. You can import it later from the dashboard to restore the resume or create another version.
|
||||
The JSON export includes your full resume content and settings, including any cover letter sections embedded in that resume. You can import it later from the dashboard to restore the resume or create another version.
|
||||
|
||||
JSON is also useful when working with AI assistants that understand structured data. Export JSON, ask an assistant to review or edit the fields, then import the revised JSON as a new resume.
|
||||
|
||||
<Info>
|
||||
JSON export is only available on the **Resume** tab. Cover letters are exported through PDF, DOCX, or Markdown.
|
||||
JSON export in the download dialog is only available on the **Resume** tab. To export an independent letter as JSON, open it from **Cover letters** on the dashboard and select **Export JSON**.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
Review AI-generated changes before importing or using them. AI assistants can make mistakes or add details that do not reflect your experience.
|
||||
</Warning>
|
||||
|
||||
## Keep JSON backups in a local Git repository
|
||||
|
||||
Git can keep owner-controlled versions of your exported resume and cover letter without changing Reactive Resume or connecting it to a Git provider. This workflow is manual and local only: Reactive Resume does not commit, synchronize, or upload the files for you.
|
||||
|
||||
### Choose the right exports
|
||||
|
||||
Reactive Resume provides three different JSON shapes:
|
||||
|
||||
| Export | How to create it | What it contains | Can you import it as one resume? |
|
||||
| --- | --- | --- | --- |
|
||||
| **Single resume** | In the builder, open **Download**, stay on **Resume**, and download **JSON**. | Resume content and settings, including cover letter sections embedded in that resume. | Yes. |
|
||||
| **Independent cover letter** | Open a letter from **Cover letters** on the dashboard and select **Export JSON**. | That letter's content and copied resume styling in the Reactive Resume cover-letter format. | No. Import it through the cover-letter library instead. |
|
||||
| **Account archive** | Open **Settings**, go to **Account**, and select **Export my data**. | Account profile metadata, all owned resume records, independent cover letters, and an `exportedAt` timestamp. | No. It is an archive, not a single-resume import file. |
|
||||
|
||||
Use the single-resume and independent-cover-letter exports for the Git workflow below. An account archive is an additional portability backup, not a replacement for those files. Because its `exportedAt` timestamp changes on every export, do not expect otherwise unchanged account archives to be byte-for-byte identical.
|
||||
|
||||
### Create the local backup
|
||||
|
||||
1. Create a folder outside your Reactive Resume source checkout.
|
||||
2. Export the resume and each independent cover letter you want to keep.
|
||||
3. Give the files stable, descriptive names. The example below uses `resume.json` and `cover-letter.json`; replace each file with its latest export instead of changing its name.
|
||||
4. From that folder, initialize Git and inspect exactly what you are about to commit:
|
||||
|
||||
```bash
|
||||
git init
|
||||
git add -- resume.json cover-letter.json
|
||||
git diff --cached --stat
|
||||
git diff --cached -- resume.json cover-letter.json
|
||||
git commit -m "Back up resume and cover letter"
|
||||
```
|
||||
|
||||
After the first commit, replace the JSON files with fresh exports and inspect the visible changes before committing again:
|
||||
|
||||
```bash
|
||||
git diff -- resume.json
|
||||
```
|
||||
|
||||
Stable filenames and the exports' two-space indentation make field changes easier to review. Repeat the add, diff, and commit steps only after the changes look correct.
|
||||
|
||||
<Warning>
|
||||
Resume JSON, cover-letter JSON, and especially an account archive can contain private data such as contact details, application text, profile metadata, and email address. A local Git repository does not publish anything. Inspect every diff and decide separately whether to publish it; if you use a remote, make it private and control who can access it.
|
||||
</Warning>
|
||||
|
||||
<Info>
|
||||
JSON exports store image URLs, not offline copies of image files. An imported backup can show an image only while the referenced storage remains available and accessible.
|
||||
</Info>
|
||||
|
||||
### Recover an earlier resume without replacing the current one
|
||||
|
||||
Choose the committed revision of `resume.json` that you want to recover. List commits that changed the file, then inspect
|
||||
the selected revision from the backup folder. Choose a fresh, unused output filename; the example assumes
|
||||
`recovered-resume.json` does not already exist:
|
||||
|
||||
```bash
|
||||
git log --oneline -- resume.json
|
||||
git show <commit>:resume.json
|
||||
git show <commit>:resume.json > recovered-resume.json
|
||||
```
|
||||
|
||||
On the Reactive Resume dashboard, select **Import an existing resume**, choose `recovered-resume.json`, and complete the import. Import creates a new resume, so the current resume remains available for comparison or further editing.
|
||||
|
||||
Only a single-resume JSON export works for this recovery flow. Import independent cover letters through the cover-letter library. Restoring an account archive wholesale is outside this workflow.
|
||||
|
||||
Git backups complement Reactive Resume's rolling snapshots; they do not replace or synchronize with [undo and version history](/guides/undoing-changes-and-version-history).
|
||||
|
||||
## Printing your resume
|
||||
|
||||
To print, export a **PDF** and print it from your PDF viewer or browser. This gives you the same layout that recruiters see when you send them the file.
|
||||
|
||||
@@ -160,6 +160,15 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
|
||||
"type": "boolean",
|
||||
"description": "Whether to hide the picture from the resume."
|
||||
},
|
||||
"fit": {
|
||||
"default": "cover",
|
||||
"description": "How the picture fits its frame: cover crops overflow, while contain preserves the whole image.",
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cover",
|
||||
"contain"
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to the picture to display on the resume. Prefer local app-served paths (for example /uploads/...) populated via upload."
|
||||
|
||||
@@ -8,7 +8,7 @@ Reactive Resume keeps two layers of change history for every resume:
|
||||
- **Undo and redo**: a live timeline of the changes you've made in the current builder session.
|
||||
- **Version history**: server-side snapshots taken at meaningful moments, kept even after you close the builder.
|
||||
|
||||
Use undo for a quick correction. Use version history to jump back to a template switch, an import, or an AI edit you made earlier.
|
||||
Use undo for a quick correction. Use version history to jump back to an earlier editing snapshot, import, or AI/API edit.
|
||||
|
||||
## Undo and redo
|
||||
|
||||
@@ -44,12 +44,14 @@ Undo history lives in your browser for the current session. Reloading the builde
|
||||
Reactive Resume snapshots your resume automatically:
|
||||
|
||||
- when you import a resume;
|
||||
- when you switch templates;
|
||||
- when the AI assistant applies edits;
|
||||
- on periodic saves during editing.
|
||||
- when the AI assistant or API applies edits;
|
||||
- on periodic saves during editing, including template switches;
|
||||
- when you restore a version.
|
||||
|
||||
Snapshots are stored on the server, per resume, and are kept across sessions.
|
||||
|
||||
Reactive Resume keeps a rolling window of the 30 most recent snapshots for each resume. Periodic editing snapshots, including template changes, are throttled to at most one every two minutes. Imports, AI/API edits, and restores create their own checkpoints.
|
||||
|
||||
### Open version history
|
||||
|
||||
Click the **clock** icon in the builder header, next to the resume name, to open the version history menu.
|
||||
@@ -83,12 +85,22 @@ Restoring is **non-destructive**: it writes the older snapshot back through the
|
||||
from the dashboard.
|
||||
</Info>
|
||||
|
||||
## Keep longer owner-managed history with Git
|
||||
|
||||
In-app version history and Git backups solve different problems:
|
||||
|
||||
- **In-app version history** is automatic, stored by Reactive Resume, and limited to the 30 most recent rolling snapshots for one resume.
|
||||
- **Git history** contains only the JSON exports you choose to commit. It is stored in your own local repository, uses your commit messages, and follows the retention you choose.
|
||||
|
||||
Git backup is manual. Reactive Resume does not create commits, synchronize with a repository, or upload files to a remote. To set up a local repository and recover a committed export as a new resume, see [Keep JSON backups in a local Git repository](/guides/exporting-your-resume#keep-json-backups-in-a-local-git-repository).
|
||||
|
||||
## Which to use when
|
||||
|
||||
| Situation | Use |
|
||||
| --- | --- |
|
||||
| You typed a wrong word or moved an item you didn't mean to. | Undo (`Cmd/Ctrl+Z`) |
|
||||
| You just switched templates and want the old one back. | Undo, or version history if you've kept editing since. |
|
||||
| You just switched templates and want the old one back. | Undo; version history can help only after a periodic editing snapshot captures the switch. |
|
||||
| You imported a resume and want to compare with what you had before. | Version history |
|
||||
| The AI assistant applied edits you no longer want. | Undo the batch, or restore the pre-AI snapshot. |
|
||||
| You closed the browser and want to roll back yesterday's changes. | Version history |
|
||||
| You want selected backups beyond the 30-snapshot rolling window. | Export JSON and commit it to your own Git repository. |
|
||||
|
||||
@@ -78,13 +78,45 @@ The agent works best with concrete instructions:
|
||||
|
||||
You can attach files or images from the composer. The agent reads uploaded attachments when they are relevant to your request.
|
||||
|
||||
For supplied text, paste the content directly into the chat. Plain text, Markdown, and JSON attachments are available to the agent as extracted text. Images and supported files such as PDFs are passed directly to the selected provider when it can use them. If an attachment format is unsupported by the selected provider, paste the relevant text instead.
|
||||
|
||||
<Note>
|
||||
Text input is supported. Voice input is not supported in the agent workspace yet.
|
||||
</Note>
|
||||
|
||||
## Review patches
|
||||
## Tailor a resume to a supplied job description
|
||||
|
||||
When the agent edits the resume, the patch is applied immediately to the AI draft. The chat shows a small **Patch applied** line.
|
||||
Use this controlled workflow to test tailoring without relying on live web research:
|
||||
|
||||
<Steps>
|
||||
<Step title="Set up an AI provider">
|
||||
In **Dashboard → Settings → Integrations**, configure a supported AI provider, test it, and make sure it is enabled. Then return to **Agents**.
|
||||
</Step>
|
||||
|
||||
<Step title="Open a sample resume draft">
|
||||
Start a new thread and select a resume containing sample experience data. The agent creates an isolated AI draft, so the source resume remains unchanged.
|
||||
</Step>
|
||||
|
||||
<Step title="Supply the target role">
|
||||
Paste this sample job description into the chat:
|
||||
|
||||
```text
|
||||
Target role: backend engineer. Required: TypeScript and PostgreSQL.
|
||||
```
|
||||
|
||||
Then ask: "Tailor the existing experience for this role. Do not invent qualifications or experience."
|
||||
</Step>
|
||||
|
||||
<Step title="Review the draft">
|
||||
Review the updated draft. To approve patches before they are applied, inspect their JSON, or restore an earlier state, follow [Review edits and patches](#review-edits-and-patches).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
This workflow also works when the selected provider/model has no live web search. Supplying the job description gives the agent the context it needs for ordinary resume editing.
|
||||
|
||||
## Review edits and patches
|
||||
|
||||
By default, the agent applies resume patches immediately to the AI draft. Turn on **Review edits** in the thread menu if you want to approve or deny patches before they are applied. Applied patches appear in chat as a small **Patch applied** line.
|
||||
|
||||
Open the line to inspect the raw JSON Patch and use **Restore** if you want to roll the draft back to the state before that patch. Restoring an older patch also rolls back patches applied after it.
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ description: "How to self-host Reactive Resume with Docker (Postgres only), with
|
||||
|
||||
Reactive Resume can be self-hosted with Docker. These are the services you'll need:
|
||||
|
||||
The official image runs one application container that serves both the web app and API. PostgreSQL must run as a
|
||||
separate service and the app connects to it through `DATABASE_URL`; no all-in-one image with an embedded database is
|
||||
planned. Follow the [Docker Compose quickstart](#quickstart-using-docker-compose) below for the supported setup.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="PostgreSQL">Stores accounts, resumes, and application data.</Card>
|
||||
<Card title="Email (optional)">
|
||||
@@ -34,6 +38,22 @@ You can pull the latest app image from:
|
||||
<Card title="Storage">Enough for Postgres + uploads (start with 10-20 GB and scale as needed).</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Smallest supported setup
|
||||
|
||||
1. Provide a separate, healthy PostgreSQL service. In the example below, its service name is `postgres`.
|
||||
2. Put `APP_URL`, `DATABASE_URL`, and `AUTH_SECRET` in a private `.env` file. Set the database host in `DATABASE_URL`
|
||||
to a name or address reachable from the app container.
|
||||
3. If S3 is disabled, mount persistent storage for app uploads at `/app/data`.
|
||||
4. Attach the `reactive-resume` app service and PostgreSQL service to the intended private container network. Do not
|
||||
expose PostgreSQL to the public internet.
|
||||
5. Launch the services with the [Docker Compose quickstart](#quickstart-using-docker-compose) below.
|
||||
6. Wait for PostgreSQL, automatic migrations, and the app health check before opening the UI.
|
||||
|
||||
The repository's full `compose.yml` also defines optional Redis and S3-compatible storage services. Those services are
|
||||
not required for the core resume workflow; use the two-service example below when you only need the app and PostgreSQL.
|
||||
The repository file is a broader source-build stack and publishes administration ports for local use. Before using it
|
||||
on an internet-facing host, remove those host port mappings, bind them to loopback, or restrict them with a firewall.
|
||||
|
||||
## Quickstart using Docker Compose
|
||||
|
||||
Create a new folder (for example `reactive-resume/`) with:
|
||||
@@ -156,7 +176,7 @@ FLAG_ALLOW_UNSAFE_AI_BASE_URL="false"
|
||||
```yaml compose.yml
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:latest
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
@@ -230,6 +250,28 @@ docker compose logs -f reactive-resume
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Unraid and other homelab platforms
|
||||
|
||||
Use your platform's generic container configuration to create two separately managed containers: one for Reactive
|
||||
Resume and one for PostgreSQL. No official Unraid Community Applications template is provided.
|
||||
|
||||
- Use the official `amruthpillai/reactive-resume:latest` or `ghcr.io/amruthpillai/reactive-resume:latest` image for the
|
||||
app container.
|
||||
- Map the app's container port `3000` to the host port you want to use.
|
||||
- Connect both containers to a private container network. Set the host in `DATABASE_URL` to the PostgreSQL container or
|
||||
service name reachable on that network.
|
||||
- Set `APP_URL`, `DATABASE_URL`, and `AUTH_SECRET` as private environment variables.
|
||||
- When S3 is disabled, map persistent app upload storage to `/app/data`.
|
||||
- Give PostgreSQL its own persistent data volume and manage it independently from the app container.
|
||||
|
||||
<Warning>
|
||||
`localhost` inside the Reactive Resume container refers to that app container. It cannot reach a separate PostgreSQL
|
||||
container. Use the PostgreSQL container or service name on the private network instead.
|
||||
</Warning>
|
||||
|
||||
After starting both containers, wait for PostgreSQL to become healthy and check the app logs while automatic migrations
|
||||
run. Open the UI only after the app health check succeeds.
|
||||
|
||||
## How startup works (database migrations)
|
||||
|
||||
<Info>
|
||||
@@ -369,20 +411,24 @@ openssl rand -hex 32
|
||||
|
||||
## Updating your installation
|
||||
|
||||
To update your Reactive Resume installation to the latest version:
|
||||
To update an installation created from the image-based quickstart above to the latest version, follow only the numbered
|
||||
steps below. If you use the repository's full `compose.yml`, use the separate source-build path after these steps.
|
||||
|
||||
1. **Back up your database and uploads first.** Do this before every update.
|
||||
|
||||
2. **Pull the latest images** for all services defined in your Docker Compose file.
|
||||
The database and upload storage are independent resources. Recreating the app container must preserve both the
|
||||
PostgreSQL data volume or managed database and the `/app/data` mount or S3 bucket.
|
||||
|
||||
2. **Pull the latest app image.** Leave the PostgreSQL service unchanged.
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose pull reactive-resume
|
||||
```
|
||||
|
||||
3. **Restart the containers** to run the new images.
|
||||
3. **Recreate only the app container** to run the new image.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose up -d --no-deps reactive-resume
|
||||
```
|
||||
|
||||
4. **Check migration/startup logs** after deploy.
|
||||
@@ -396,12 +442,32 @@ To update your Reactive Resume installation to the latest version:
|
||||
docker image prune -f
|
||||
```
|
||||
|
||||
This process updates app services and automatically runs DB migrations on startup. If migration fails, restore from backup and fix configuration before retrying.
|
||||
### Update from the repository Compose file
|
||||
|
||||
The repository's full `compose.yml` names its build-only app service `reactive_resume`. After confirming its dependencies
|
||||
are healthy, rebuild that service and follow its migration/startup logs with:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build --no-deps reactive_resume
|
||||
docker compose logs -f reactive_resume
|
||||
```
|
||||
|
||||
Do not run `docker compose pull` for this build-only service.
|
||||
|
||||
This process updates the app container and automatically runs DB migrations on startup. If migration fails, restore from backup and fix configuration before retrying.
|
||||
|
||||
Update PostgreSQL separately from the app. Choose a supported, major-pinned PostgreSQL image or select the target version
|
||||
through your managed provider, then follow that image's, host's, or provider's upgrade procedure. Back up the database
|
||||
and verify that the backup can be restored before a major-version upgrade. Pulling a new app image and running app
|
||||
migrations do not upgrade the PostgreSQL server.
|
||||
|
||||
## Backups (recommended)
|
||||
|
||||
Reactive Resume stores data in two places: the PostgreSQL database and file uploads (either local storage or S3). Back up both on a regular schedule.
|
||||
|
||||
Test restores for both resources. An app container backup alone does not include the separate database or uploads, and
|
||||
recreating the app container must not replace either persistent resource.
|
||||
|
||||
### Database backups
|
||||
|
||||
Your PostgreSQL database holds all user accounts, resumes, and application data. Use `pg_dump` to take periodic backups and store them somewhere secure. Many providers of managed PostgreSQL also offer automated backups that handle scheduling, retention, and restores for you.
|
||||
|
||||
@@ -20,6 +20,25 @@ They go further than the basic setup in the [Self-hosting with Docker](/self-hos
|
||||
|
||||
---
|
||||
|
||||
## Reuse an existing PostgreSQL service
|
||||
|
||||
Reactive Resume always uses a separate PostgreSQL service; do not embed another database server in the app container.
|
||||
If your homelab or hosting platform already manages PostgreSQL, reuse it by setting `DATABASE_URL` to that service and
|
||||
allowing the app container to reach it over the intended private network. Keep database credentials private and do not
|
||||
expose PostgreSQL to the public internet.
|
||||
|
||||
When the database connection crosses a host or network boundary, require TLS with certificate and hostname verification.
|
||||
Use `sslmode=verify-full` in `DATABASE_URL` where the provider supports it, or the provider's equivalent verified-TLS
|
||||
configuration. Private routing limits exposure, but does not itself verify the database server's identity.
|
||||
|
||||
For generic Unraid and homelab container fields, including the `localhost` networking warning, see
|
||||
[Unraid and other homelab platforms](/self-hosting/docker#unraid-and-other-homelab-platforms). The core resume workflow
|
||||
does not require Redis or S3. Redis is a separate optional dependency for the AI Agent workspace, while S3-compatible
|
||||
storage is optional unless you need features that require private object storage; see the
|
||||
[environment variable reference](/self-hosting/docker#environment-variables) for those boundaries.
|
||||
|
||||
---
|
||||
|
||||
## Docker with Traefik
|
||||
|
||||
This example uses [Traefik](https://traefik.io/) as a reverse proxy with automatic SSL certificate management via Let's Encrypt. Postgres stays on an internal network. The Traefik dashboard is also routed, at `traefik.${DOMAIN}` behind basic auth — drop those labels if you do not want it reachable.
|
||||
|
||||
@@ -12,6 +12,12 @@ To move a Reactive Resume installation from **v4 to v5**, you set up a new v5 in
|
||||
Docker](/self-hosting/docker) guide. v5 schema migrations run automatically on app startup.
|
||||
</Info>
|
||||
|
||||
<Warning>
|
||||
This guide applies only to infrastructure and backups you are authorized to operate. It does not grant access to
|
||||
hosted Reactive Resume data. Only the hosted service operator can verify whether a hosted snapshot exists and
|
||||
authorize access to it.
|
||||
</Warning>
|
||||
|
||||
<Warning>
|
||||
**Keep your v4 instance running** until you have migrated all data to v5 and checked that everything works. That way
|
||||
you have a fallback if the migration goes wrong.
|
||||
@@ -48,6 +54,58 @@ The best migration approach depends on the size of your instance:
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Recover one owner's resumes without overwriting v5
|
||||
|
||||
Use a recovery case when an owner changed resumes after an earlier migration. Keep the case record private and outside
|
||||
Git because it may connect account and resume identifiers. Record these fields before inspecting resume content:
|
||||
|
||||
- Recovery case ID
|
||||
- Source snapshot capture time
|
||||
- Owner verification status
|
||||
- Source resume ID and mapped target resume ID, if one exists
|
||||
- Source and target content hashes
|
||||
- Proposed outcome: `no-op`, `export-copy`, or `blocked`
|
||||
|
||||
Case IDs, source resume IDs, and non-null target resume IDs must contain a non-whitespace character after trimming and
|
||||
must not contain Unicode control or format characters. Valid identifiers are preserved verbatim.
|
||||
|
||||
These hashes prove content equality only; they never prove ownership, source authenticity, or recipient identity.
|
||||
|
||||
An authorized operator should follow this order:
|
||||
|
||||
<Steps>
|
||||
<Step title="Verify source availability">
|
||||
Confirm that a source snapshot exists and record when it was captured. If no source snapshot exists, report that
|
||||
factual limit. Recovery tooling cannot reconstruct records that are absent from every available source.
|
||||
</Step>
|
||||
|
||||
<Step title="Verify owner and mapping">
|
||||
Verify the requester using the operator's approved account-ownership process. Confirm the old-to-new owner mapping;
|
||||
a matching email address, resume title, or username alone is not ownership proof. Stop if either check is incomplete.
|
||||
</Step>
|
||||
|
||||
<Step title="Compare without writing">
|
||||
Build one serialized JSON comparison request containing the case IDs, safety flags, and source and target values. The
|
||||
comparator accepts only this request string, not an object argument. Source and target data must already conform
|
||||
exactly to the current v5 resume-data schema before content hashes are calculated. Raw v4 exports are unsupported,
|
||||
and the comparator performs no format conversion. Identical content is a `no-op`. Source-only or divergent content
|
||||
is an `export-copy`. Missing identity evidence, mapping, valid current-v5 data, or a source snapshot is `blocked`.
|
||||
</Step>
|
||||
|
||||
<Step title="Deliver a separate private export">
|
||||
Default to a private JSON export. Never overwrite the current v5 resume. After the recipient and delivery channel are
|
||||
approved, deliver the recovered JSON privately so the owner can import it as a separate resume. Record source and
|
||||
delivered hashes outside Git and confirm they match.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Repository contributors can rehearse this decision with the pure comparator in
|
||||
`tooling/recovery/compare-resume.ts`. It accepts one serialized JSON comparison request and rejects object arguments.
|
||||
The request's source and target values must already conform exactly to the current v5 resume-data schema. It does not
|
||||
accept raw v4 exports or perform legacy conversion; review the historical converter at tag `v5.0.20` separately before
|
||||
processing legacy-format data. The comparator produces a deterministic dry-run manifest and has no database, network,
|
||||
or write path. Use synthetic IDs and content only; keep any operational manifest outside the repository.
|
||||
|
||||
## Manual migration (small instances)
|
||||
|
||||
If you have only a few resumes to migrate, the simplest approach is to use the **Import Dialog** feature in v5.
|
||||
@@ -213,8 +271,9 @@ Both migration scripts support graceful shutdown and resume:
|
||||
- **Resume migration**: Run the script again to continue from where you left off
|
||||
|
||||
<Tip>
|
||||
If you need to restart the migration from scratch, delete the progress files and the user ID mapping file before
|
||||
running the scripts again.
|
||||
Preserve progress files and the user ID mapping as recovery evidence. Do not delete them or replay the historical
|
||||
scripts against a populated target as a recovery shortcut. Review the `v5.0.20` scripts, source backup, mapping, and
|
||||
target state before any rerun.
|
||||
</Tip>
|
||||
|
||||
## Post-migration steps
|
||||
@@ -290,8 +349,9 @@ After completing the migration:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Resume data parsing fails">
|
||||
If a resume can't be parsed from v4 format, it will be created with default empty data. Check the console output for
|
||||
warnings about specific resumes, and consider manually importing those using the Import Dialog.
|
||||
Historical scripts can create default empty data when a v4 resume cannot be parsed. Treat that result as a failed
|
||||
conversion, not a recovered resume. Preserve the source export, review the `v5.0.20` converter, and use the Import
|
||||
Dialog only after valid source data is confirmed.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Migration is slow">
|
||||
|
||||
@@ -222,6 +222,7 @@ describe("parseReactiveResumeV4JSON — broad section mapping", () => {
|
||||
it("maps picture with border on", () => {
|
||||
expect(result.picture.url).toBe("https://example.com/pic.jpg");
|
||||
expect(result.picture.hidden).toBe(false);
|
||||
expect(result.picture.fit).toBe("cover");
|
||||
expect(result.picture.borderWidth).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData {
|
||||
const transformed: ResumeData = {
|
||||
picture: {
|
||||
hidden: v4Data.basics.picture?.effects?.hidden ?? false,
|
||||
fit: "cover",
|
||||
url: v4Data.basics.picture?.url ?? "",
|
||||
size: clamp(v4Data.basics.picture?.size ?? 80, 32, 512),
|
||||
rotation: 0,
|
||||
|
||||
@@ -135,7 +135,7 @@ export function createBaseTemplateStyles({
|
||||
picture: {
|
||||
width: picture.size,
|
||||
height: picture.size,
|
||||
objectFit: "cover",
|
||||
objectFit: picture.fit,
|
||||
aspectRatio: picture.aspectRatio,
|
||||
borderRadius: picture.borderRadius,
|
||||
borderColor: rgbaStringToHex(picture.borderColor),
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { renderToBuffer } from "@react-pdf/renderer";
|
||||
import { act, createElement } from "react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { ResumeDocument } from "../../document";
|
||||
import { rasterizePdf } from "../../semantic/test/rasterize-pdf";
|
||||
|
||||
type Fit = "cover" | "contain";
|
||||
type Orientation = "square" | "landscape" | "portrait";
|
||||
|
||||
const dimensions = {
|
||||
square: [300, 300],
|
||||
landscape: [400, 300],
|
||||
portrait: [300, 400],
|
||||
} as const;
|
||||
|
||||
const marker = {
|
||||
left: [255, 0, 0],
|
||||
right: [0, 255, 0],
|
||||
top: [0, 0, 255],
|
||||
bottom: [255, 255, 0],
|
||||
} as const;
|
||||
|
||||
const cropMarker = {
|
||||
start: [255, 128, 0],
|
||||
end: [0, 128, 128],
|
||||
} as const;
|
||||
|
||||
const frameColor = [255, 0, 255] as const;
|
||||
const pictureBounds = { left: 21, right: 170, top: 18, bottom: 167 } as const;
|
||||
|
||||
function markedImage(orientation: Orientation) {
|
||||
const [width, height] = dimensions[orientation];
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
const strip = Math.round(Math.min(width, height) * 0.08);
|
||||
context.fillStyle = "#ff0000";
|
||||
context.fillRect(0, 0, strip, height);
|
||||
context.fillStyle = "#00ff00";
|
||||
context.fillRect(width - strip, 0, strip, height);
|
||||
context.fillStyle = "#0000ff";
|
||||
context.fillRect(strip, 0, width - strip * 2, strip);
|
||||
context.fillStyle = "#ffff00";
|
||||
context.fillRect(strip, height - strip, width - strip * 2, strip);
|
||||
if (orientation === "landscape") {
|
||||
context.fillStyle = "#ff8000";
|
||||
context.fillRect(50, 0, 5, height);
|
||||
context.fillStyle = "#008080";
|
||||
context.fillRect(width - 55, 0, 5, height);
|
||||
}
|
||||
if (orientation === "portrait") {
|
||||
context.fillStyle = "#ff8000";
|
||||
context.fillRect(0, 50, width, 5);
|
||||
context.fillStyle = "#008080";
|
||||
context.fillRect(0, height - 55, width, 5);
|
||||
}
|
||||
context.fillStyle = "#000000";
|
||||
context.fillRect(Math.floor(width / 2) - 3, strip, 6, height - strip * 2);
|
||||
context.fillRect(strip, Math.floor(height / 2) - 3, width - strip * 2, 6);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
async function rasterPicture(
|
||||
orientation: Orientation,
|
||||
fit: Fit,
|
||||
stylesheet = "@version 1;",
|
||||
frame = { borderWidth: 6, shadowWidth: 8 },
|
||||
) {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.basics.name = "Picture fit";
|
||||
data.metadata.typography.body.fontFamily = "Helvetica";
|
||||
data.metadata.typography.heading.fontFamily = "Helvetica";
|
||||
data.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: stylesheet } };
|
||||
Object.assign(data.picture, {
|
||||
url: markedImage(orientation),
|
||||
hidden: false,
|
||||
fit,
|
||||
size: 100,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
borderColor: "rgba(255, 0, 255, 1)",
|
||||
borderWidth: frame.borderWidth,
|
||||
shadowColor: "rgba(0, 255, 255, 1)",
|
||||
shadowWidth: frame.shadowWidth,
|
||||
});
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<
|
||||
typeof renderToBuffer
|
||||
>[0];
|
||||
let bytes = new Uint8Array();
|
||||
await act(async () => {
|
||||
bytes = new Uint8Array(await renderToBuffer(element));
|
||||
});
|
||||
const [page] = await rasterizePdf(bytes);
|
||||
if (!page) throw new Error("Missing rendered page");
|
||||
return page;
|
||||
}
|
||||
|
||||
function colorBounds(page: Awaited<ReturnType<typeof rasterPicture>>, color: readonly number[]) {
|
||||
const points: { x: number; y: number }[] = [];
|
||||
for (let index = 0; index < page.data.length; index += 4) {
|
||||
if (color.every((channel, offset) => Math.abs((page.data[index + offset] ?? -255) - channel) <= 2)) {
|
||||
points.push({ x: (index / 4) % page.width, y: Math.floor(index / 4 / page.width) });
|
||||
}
|
||||
}
|
||||
if (points.length === 0) return undefined;
|
||||
return {
|
||||
left: Math.min(...points.map(({ x }) => x)),
|
||||
right: Math.max(...points.map(({ x }) => x)),
|
||||
top: Math.min(...points.map(({ y }) => y)),
|
||||
bottom: Math.max(...points.map(({ y }) => y)),
|
||||
};
|
||||
}
|
||||
|
||||
type Bounds = NonNullable<ReturnType<typeof colorBounds>>;
|
||||
|
||||
function requiredColorBounds(
|
||||
page: Awaited<ReturnType<typeof rasterPicture>>,
|
||||
color: readonly number[],
|
||||
label: string,
|
||||
): Bounds {
|
||||
const bounds = colorBounds(page, color);
|
||||
expect(bounds, label).toBeDefined();
|
||||
if (!bounds) throw new Error(`Missing ${label}`);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function mergedBounds(bounds: readonly Bounds[]): Bounds {
|
||||
return {
|
||||
left: Math.min(...bounds.map(({ left }) => left)),
|
||||
right: Math.max(...bounds.map(({ right }) => right)),
|
||||
top: Math.min(...bounds.map(({ top }) => top)),
|
||||
bottom: Math.max(...bounds.map(({ bottom }) => bottom)),
|
||||
};
|
||||
}
|
||||
|
||||
function expectBoundsWithinPixel(actual: Bounds, expected: Bounds) {
|
||||
for (const edge of ["left", "right", "top", "bottom"] as const) {
|
||||
expect(Math.abs(actual[edge] - expected[edge]), edge).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
|
||||
function boundsCenter(bounds: Bounds) {
|
||||
return {
|
||||
x: (bounds.left + bounds.right) / 2,
|
||||
y: (bounds.top + bounds.bottom) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function expectSameCenter(actual: Bounds, expected: Bounds) {
|
||||
const actualCenter = boundsCenter(actual);
|
||||
const expectedCenter = boundsCenter(expected);
|
||||
expect(Math.abs(actualCenter.x - expectedCenter.x), "center x").toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(actualCenter.y - expectedCenter.y), "center y").toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
function expectedContentBounds(borderWidth: number): Bounds {
|
||||
const inset = borderWidth * 1.5;
|
||||
return {
|
||||
left: pictureBounds.left + inset,
|
||||
right: pictureBounds.right - inset,
|
||||
top: pictureBounds.top + inset,
|
||||
bottom: pictureBounds.bottom - inset,
|
||||
};
|
||||
}
|
||||
|
||||
function expectedContainBounds(orientation: Orientation, borderWidth: number): Bounds {
|
||||
const content = expectedContentBounds(borderWidth);
|
||||
const contentWidth = content.right - content.left + 1;
|
||||
const contentHeight = content.bottom - content.top + 1;
|
||||
const [sourceWidth, sourceHeight] = dimensions[orientation];
|
||||
const scale = Math.min(contentWidth / sourceWidth, contentHeight / sourceHeight);
|
||||
const width = sourceWidth * scale;
|
||||
const height = sourceHeight * scale;
|
||||
const center = boundsCenter(content);
|
||||
return {
|
||||
left: center.x - (width - 1) / 2,
|
||||
right: center.x + (width - 1) / 2,
|
||||
top: center.y - (height - 1) / 2,
|
||||
bottom: center.y + (height - 1) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
describe("picture fit geometry (#2782)", () => {
|
||||
it.each(["square", "landscape", "portrait"] as const)(
|
||||
"keeps every %s source edge visible and centered in contain mode",
|
||||
async (orientation) => {
|
||||
const page = await rasterPicture(orientation, "contain");
|
||||
const edges = Object.entries(marker).map(([name, color]) => requiredColorBounds(page, color, name));
|
||||
const image = mergedBounds(edges);
|
||||
expectBoundsWithinPixel(image, expectedContainBounds(orientation, 6));
|
||||
expectSameCenter(image, requiredColorBounds(page, frameColor, "frame"));
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ orientation: "landscape", retained: ["top", "bottom"], cropped: ["left", "right"] },
|
||||
{ orientation: "portrait", retained: ["left", "right"], cropped: ["top", "bottom"] },
|
||||
] as const)(
|
||||
"preserves centered cover crop geometry for $orientation sources",
|
||||
async ({ orientation, retained, cropped }) => {
|
||||
const page = await rasterPicture(orientation, "cover");
|
||||
for (const edge of retained) expect(colorBounds(page, marker[edge]), `${edge} retained`).toBeDefined();
|
||||
for (const edge of cropped) expect(colorBounds(page, marker[edge]), `${edge} cropped`).toBeUndefined();
|
||||
|
||||
const content = expectedContentBounds(6);
|
||||
const start = requiredColorBounds(page, cropMarker.start, "start crop marker");
|
||||
const end = requiredColorBounds(page, cropMarker.end, "end crop marker");
|
||||
const visibleImage = mergedBounds([
|
||||
...retained.map((edge) => requiredColorBounds(page, marker[edge], edge)),
|
||||
start,
|
||||
end,
|
||||
]);
|
||||
expectBoundsWithinPixel(visibleImage, content);
|
||||
expectSameCenter(mergedBounds([start, end]), content);
|
||||
|
||||
if (orientation === "landscape") {
|
||||
expect(Math.abs(start.left - content.left)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(end.right - content.right)).toBeLessThanOrEqual(1);
|
||||
} else {
|
||||
expect(Math.abs(start.top - content.top)).toBeLessThanOrEqual(1);
|
||||
expect(Math.abs(end.bottom - content.bottom)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps square cover source edges uncropped", async () => {
|
||||
const page = await rasterPicture("square", "cover");
|
||||
const image = mergedBounds(Object.entries(marker).map(([name, color]) => requiredColorBounds(page, color, name)));
|
||||
expectBoundsWithinPixel(image, expectedContentBounds(6));
|
||||
expectSameCenter(image, requiredColorBounds(page, frameColor, "frame"));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ borderWidth: 0, shadowWidth: 0 },
|
||||
{ borderWidth: 6, shadowWidth: 0 },
|
||||
{ borderWidth: 0, shadowWidth: 8 },
|
||||
{ borderWidth: 6, shadowWidth: 8 },
|
||||
])("retains every landscape edge with border $borderWidth and shadow $shadowWidth", async (frame) => {
|
||||
const page = await rasterPicture("landscape", "contain", "@version 1;", frame);
|
||||
const image = mergedBounds(Object.entries(marker).map(([name, color]) => requiredColorBounds(page, color, name)));
|
||||
expectBoundsWithinPixel(image, expectedContainBounds("landscape", frame.borderWidth));
|
||||
expectSameCenter(image, expectedContentBounds(frame.borderWidth));
|
||||
});
|
||||
|
||||
it("lets semantic object-fit cover override selected contain", async () => {
|
||||
const cover = await rasterPicture("landscape", "cover");
|
||||
const overridden = await rasterPicture("landscape", "contain", "@version 1; picture { object-fit: cover; }");
|
||||
expect([...overridden.data]).toEqual([...cover.data]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { hasTemplatePicture } from "./picture";
|
||||
|
||||
const basePicture = {
|
||||
hidden: false,
|
||||
fit: "cover",
|
||||
url: "",
|
||||
size: 80,
|
||||
rotation: 0,
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"./icons": "./src/icons.ts",
|
||||
"./markdown": "./src/markdown.ts",
|
||||
"./patch": "./src/patch.ts",
|
||||
"./section-availability": "./src/section-availability.ts",
|
||||
"./social-meta": "./src/social-meta.ts",
|
||||
"./stylesheet": "./src/stylesheet/index.ts",
|
||||
"./stylesheet/registry": "./src/stylesheet/registry/index.ts",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { getSectionAvailability } from "./section-availability";
|
||||
|
||||
describe("section availability", () => {
|
||||
it("includes every printable section and records every authored location", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const customSection = data.customSections[0];
|
||||
const laterPage = data.metadata.layout.pages[2];
|
||||
if (!customSection || !laterPage) throw new Error("Sample resume lacks expected section fixtures.");
|
||||
const customSectionId = customSection.id;
|
||||
laterPage.sidebar.push(customSectionId, customSectionId, "unknown-section");
|
||||
|
||||
const availability = getSectionAvailability(data);
|
||||
const sectionIds = availability.map((entry) => entry.sectionId);
|
||||
|
||||
expect(sectionIds).toEqual([
|
||||
"summary",
|
||||
...Object.keys(data.sections),
|
||||
...data.customSections.map((section) => section.id),
|
||||
]);
|
||||
expect(sectionIds).not.toContain("picture");
|
||||
expect(sectionIds).not.toContain("basics");
|
||||
expect(sectionIds).not.toContain("custom");
|
||||
expect(sectionIds).not.toContain("unknown-section");
|
||||
expect(availability.find((entry) => entry.sectionId === customSectionId)?.locations).toEqual([
|
||||
{ pageIndex: 1, columnId: "main" },
|
||||
{ pageIndex: 2, columnId: "sidebar" },
|
||||
{ pageIndex: 2, columnId: "sidebar" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("derives hidden and placement state independently without mutation", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.sections.experience.hidden = true;
|
||||
data.sections.awards.items = [];
|
||||
for (const page of data.metadata.layout.pages) {
|
||||
page.main = page.main.filter((id) => id !== "experience");
|
||||
page.sidebar = page.sidebar.filter((id) => id !== "experience");
|
||||
page.main = page.main.filter((id) => id !== "projects");
|
||||
page.sidebar = page.sidebar.filter((id) => id !== "projects");
|
||||
}
|
||||
data.summary.hidden = true;
|
||||
const before = structuredClone(data);
|
||||
|
||||
const availability = getSectionAvailability(data);
|
||||
|
||||
expect(availability.find((entry) => entry.sectionId === "experience")).toEqual({
|
||||
sectionId: "experience",
|
||||
hidden: true,
|
||||
locations: [],
|
||||
});
|
||||
expect(availability.find((entry) => entry.sectionId === "summary")).toEqual({
|
||||
sectionId: "summary",
|
||||
hidden: true,
|
||||
locations: [{ pageIndex: 0, columnId: "main" }],
|
||||
});
|
||||
expect(availability.find((entry) => entry.sectionId === "projects")).toEqual({
|
||||
sectionId: "projects",
|
||||
hidden: false,
|
||||
locations: [],
|
||||
});
|
||||
expect(availability.find((entry) => entry.sectionId === "awards")).toEqual({
|
||||
sectionId: "awards",
|
||||
hidden: false,
|
||||
locations: [{ pageIndex: 1, columnId: "main" }],
|
||||
});
|
||||
expect(data).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export type SectionLocation = {
|
||||
pageIndex: number;
|
||||
columnId: "main" | "sidebar";
|
||||
};
|
||||
|
||||
export type SectionAvailability = {
|
||||
sectionId: string;
|
||||
hidden: boolean;
|
||||
locations: SectionLocation[];
|
||||
};
|
||||
|
||||
function getSectionLocations(data: ResumeData, sectionId: string): SectionLocation[] {
|
||||
const locations: SectionLocation[] = [];
|
||||
|
||||
for (const [pageIndex, page] of data.metadata.layout.pages.entries()) {
|
||||
for (const columnId of ["main", "sidebar"] as const) {
|
||||
for (const id of page[columnId]) {
|
||||
if (id === sectionId) locations.push({ pageIndex, columnId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return locations;
|
||||
}
|
||||
|
||||
export function getSectionAvailability(data: ResumeData): SectionAvailability[] {
|
||||
const sections = [
|
||||
{ sectionId: "summary", hidden: data.summary.hidden },
|
||||
...Object.entries(data.sections).map(([sectionId, section]) => ({ sectionId, hidden: section.hidden })),
|
||||
...data.customSections.map((section) => ({ sectionId: section.id, hidden: section.hidden })),
|
||||
];
|
||||
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
locations: getSectionLocations(data, section.sectionId),
|
||||
}));
|
||||
}
|
||||
@@ -241,6 +241,20 @@ describe("pictureSchema", () => {
|
||||
expect(pictureSchema.safeParse(defaultResumeData.picture).success).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults missing and invalid legacy fit values to cover", () => {
|
||||
const { fit: _fit, ...legacyPicture } = defaultResumeData.picture;
|
||||
|
||||
expect(pictureSchema.parse(legacyPicture).fit).toBe("cover");
|
||||
expect(pictureSchema.parse({ ...legacyPicture, fit: "stretch" }).fit).toBe("cover");
|
||||
});
|
||||
|
||||
it("preserves contain through parsing and JSON round-trip", () => {
|
||||
const picture = pictureSchema.parse({ ...defaultResumeData.picture, fit: "contain" });
|
||||
const roundTripped = pictureSchema.parse(JSON.parse(JSON.stringify(picture)));
|
||||
|
||||
expect(roundTripped.fit).toBe("contain");
|
||||
});
|
||||
|
||||
it("rejects size below 32", () => {
|
||||
const invalid = { ...defaultResumeData.picture, size: 16 };
|
||||
expect(pictureSchema.safeParse(invalid).success).toBe(false);
|
||||
|
||||
@@ -33,6 +33,10 @@ const itemWebsiteSchema = websiteSchema
|
||||
|
||||
export const pictureSchema = z.object({
|
||||
hidden: z.boolean().describe("Whether to hide the picture from the resume."),
|
||||
fit: z
|
||||
.enum(["cover", "contain"])
|
||||
.catch("cover")
|
||||
.describe("How the picture fits its frame: cover crops overflow, while contain preserves the whole image."),
|
||||
url: z
|
||||
.string()
|
||||
.describe(
|
||||
|
||||
@@ -20,6 +20,10 @@ describe("defaultResumeData", () => {
|
||||
expect(defaultResumeData.metadata.page.format).toBe("a4");
|
||||
});
|
||||
|
||||
it("uses cover picture fitting by default", () => {
|
||||
expect(defaultResumeData.picture.fit).toBe("cover");
|
||||
});
|
||||
|
||||
it("shows link underlines by default", () => {
|
||||
expect(defaultResumeData.metadata.page.hideLinkUnderline).toBe(false);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ const section = (icon: string) => ({
|
||||
export const defaultResumeData: ResumeData = {
|
||||
picture: {
|
||||
hidden: false,
|
||||
fit: "cover",
|
||||
url: "",
|
||||
size: 80,
|
||||
rotation: 0,
|
||||
|
||||
@@ -21,6 +21,16 @@ describe("createResumeDataJsonSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy input with omitted picture fit", () => {
|
||||
const schema = createResumeDataJsonSchema();
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
properties: {
|
||||
picture: { required: expect.not.arrayContaining(["fit"]) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces the custom-section type and item correlation", () => {
|
||||
const generatedSchema = z.fromJSONSchema(createResumeDataJsonSchema());
|
||||
const valid = {
|
||||
|
||||
@@ -9,7 +9,13 @@ const toInputJsonSchema = (schema: z.ZodType) =>
|
||||
});
|
||||
|
||||
export function createResumeDataJsonSchema() {
|
||||
return toInputJsonSchema(resumeDataSchema);
|
||||
const schema = toInputJsonSchema(resumeDataSchema);
|
||||
const picture = schema.properties?.picture;
|
||||
// Zod's catch accepts an omitted fit at runtime but still marks the property required in JSON Schema.
|
||||
if (picture && typeof picture !== "boolean" && Array.isArray(picture.required)) {
|
||||
picture.required = picture.required.filter((property) => property !== "fit");
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
export function createCustomSectionItemJsonSchemas() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ResumeData } from "./data";
|
||||
export const sampleResumeData: ResumeData = {
|
||||
picture: {
|
||||
hidden: false,
|
||||
fit: "cover",
|
||||
url: "/photos/sample-picture.jpg",
|
||||
size: 100,
|
||||
rotation: 0,
|
||||
|
||||
@@ -35,6 +35,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `picture` | `object` | yes | — | Configuration for photograph displayed on the resume |
|
||||
| `picture.hidden` | `boolean` | yes | — | Whether to hide the picture from the resume. |
|
||||
| `picture.fit` | `string` | no | enum: ["cover","contain"]; default: "cover" | How the picture fits its frame: cover crops overflow, while contain preserves the whole image. |
|
||||
| `picture.url` | `string` | yes | — | The URL to the picture to display on the resume. Prefer local app-served paths (for example /uploads/...) populated via upload. |
|
||||
| `picture.size` | `number` | yes | minimum: 32; maximum: 512 | The size of the picture to display on the resume, defined in points (pt). |
|
||||
| `picture.rotation` | `number` | yes | minimum: 0; maximum: 360 | The rotation of the picture to display on the resume, defined in degrees (°). |
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { createSampleResumeFromDashboard } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const updateUrl = "**/api/rpc/resume/update";
|
||||
function barrier() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function waitSave(page: Page) {
|
||||
return page.waitForResponse(
|
||||
(response) => new URL(response.url()).pathname === "/api/rpc/resume/update" && response.ok(),
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareNavigationTest(page: Page, testInfo: TestInfo) {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await page.reload();
|
||||
const warmup = waitSave(page);
|
||||
await page.getByLabel("Headline", { exact: true }).fill("Navigation fixture ready");
|
||||
await warmup;
|
||||
return page.url();
|
||||
}
|
||||
|
||||
test("retries a failed autosave before leaving the builder", async ({ authPage: page }, testInfo) => {
|
||||
const url = await prepareNavigationTest(page, testInfo);
|
||||
|
||||
await page.route(updateUrl, async (route) => {
|
||||
await route.abort("failed");
|
||||
});
|
||||
await page.getByLabel("Name", { exact: true }).fill("Draft recovered before leaving");
|
||||
await expect(page.getByText("Your latest changes could not be saved.", { exact: true })).toBeVisible();
|
||||
await page.unroute(updateUrl);
|
||||
const arrived = barrier();
|
||||
const release = barrier();
|
||||
await page.route(updateUrl, async (route) => {
|
||||
arrived.resolve();
|
||||
await release.promise;
|
||||
await route.continue();
|
||||
});
|
||||
await page.getByRole("button", { name: "Go to resumes dashboard", exact: true }).click();
|
||||
await arrived.promise;
|
||||
expect(page.url()).toBe(url);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue("Draft recovered before leaving");
|
||||
release.resolve();
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
await page.goto(url);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue("Draft recovered before leaving");
|
||||
});
|
||||
|
||||
test("retains the current draft when saving during navigation fails", async ({ authPage: page }, testInfo) => {
|
||||
const url = await prepareNavigationTest(page, testInfo);
|
||||
let attempts = 0;
|
||||
await page.route(updateUrl, async (route) => {
|
||||
attempts++;
|
||||
await route.abort("failed");
|
||||
});
|
||||
await page.getByLabel("Name", { exact: true }).fill("Keep unsaved draft");
|
||||
await expect(page.getByText("Your latest changes could not be saved.", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Go to resumes dashboard", exact: true }).click();
|
||||
await expect.poll(() => attempts).toBe(2);
|
||||
await expect(page.getByRole("status").filter({ hasText: "Couldn't save" })).toBeVisible();
|
||||
expect(page.url()).toBe(url);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue("Keep unsaved draft");
|
||||
await page.unroute(updateUrl);
|
||||
await page.getByRole("button", { name: "Go to resumes dashboard", exact: true }).click();
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
await page.goto(url);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue("Keep unsaved draft");
|
||||
});
|
||||
|
||||
test("stops waiting for a slow save while preserving late acknowledgements and queued edits", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const url = await prepareNavigationTest(page, testInfo);
|
||||
await page.clock.install();
|
||||
const arrived = barrier();
|
||||
const release = barrier();
|
||||
let attempts = 0;
|
||||
await page.route(updateUrl, async (route) => {
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
arrived.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
await page.getByLabel("Name", { exact: true }).fill("Slow save draft");
|
||||
await page.clock.fastForward(600);
|
||||
await arrived.promise;
|
||||
await page.getByRole("button", { name: "Go to resumes dashboard", exact: true }).click();
|
||||
await page.clock.fastForward(10000);
|
||||
const slowNotice = page.getByText("Saving is taking longer than expected. Your changes are still open.", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(slowNotice).toBeVisible();
|
||||
await expect(page.getByRole("status").filter({ hasText: "Saving" })).toBeVisible();
|
||||
expect(page.url()).toBe(url);
|
||||
expect(attempts).toBe(1);
|
||||
|
||||
await page.getByLabel("Headline", { exact: true }).fill("Latest edit during slow save");
|
||||
await page.clock.fastForward(600);
|
||||
expect(attempts).toBe(1);
|
||||
const latestSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/rpc/resume/update" &&
|
||||
response.ok() &&
|
||||
(response.request().postData() ?? "").includes("Latest edit during slow save"),
|
||||
);
|
||||
release.resolve();
|
||||
await latestSaved;
|
||||
await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
|
||||
await expect(slowNotice).toBeHidden();
|
||||
await page.getByRole("button", { name: "Go to resumes dashboard", exact: true }).click();
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
await page.goto(url);
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue("Slow save draft");
|
||||
await expect(page.getByLabel("Headline", { exact: true })).toHaveValue("Latest edit during slow save");
|
||||
expect(attempts).toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
type MarkerBounds = {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type MarkerMetrics = Record<"red" | "green" | "blue" | "yellow", MarkerBounds | null>;
|
||||
|
||||
const requireRoot = createRequire(`${process.cwd()}/package.json`);
|
||||
|
||||
async function installPdfJsRoute(page: Page) {
|
||||
await page.route("**/__picture_pdfjs/*", async (route) => {
|
||||
const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs");
|
||||
await route.fulfill({
|
||||
contentType: "text/javascript",
|
||||
path: requireRoot.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function markedLandscapePng(dataUrl: string) {
|
||||
return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
|
||||
}
|
||||
|
||||
function createMarkedLandscape(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 800, 600);
|
||||
context.fillStyle = "#ff0000";
|
||||
context.fillRect(0, 0, 64, 600);
|
||||
context.fillStyle = "#00ff00";
|
||||
context.fillRect(736, 0, 64, 600);
|
||||
context.fillStyle = "#0000ff";
|
||||
context.fillRect(64, 0, 672, 48);
|
||||
context.fillStyle = "#ffff00";
|
||||
context.fillRect(64, 552, 672, 48);
|
||||
context.fillStyle = "#000000";
|
||||
context.fillRect(394, 48, 12, 504);
|
||||
context.fillRect(64, 294, 672, 12);
|
||||
return canvas.toDataURL("image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function markerMetricsFromPdf(page: Page, bytes: Uint8Array): Promise<MarkerMetrics> {
|
||||
return page.evaluate(async (pdfBytes) => {
|
||||
const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(
|
||||
`${location.origin}/__picture_pdfjs/pdf.mjs`
|
||||
);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__picture_pdfjs/worker.mjs`;
|
||||
const task = pdfjs.getDocument({ data: Uint8Array.from(pdfBytes) });
|
||||
try {
|
||||
const pdfPage = await task.promise.then((pdf) => pdf.getPage(1));
|
||||
const viewport = pdfPage.getViewport({ scale: 1.5 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
await pdfPage.render({ canvas, canvasContext: context, viewport, background: "white" }).promise;
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const predicates = {
|
||||
red: (r: number, g: number, b: number) => r > 220 && g < 50 && b < 50,
|
||||
green: (r: number, g: number, b: number) => r < 50 && g > 220 && b < 50,
|
||||
blue: (r: number, g: number, b: number) => r < 50 && g < 50 && b > 220,
|
||||
yellow: (r: number, g: number, b: number) => r > 220 && g > 220 && b < 50,
|
||||
};
|
||||
const result = {} as Record<keyof typeof predicates, MarkerBounds | null>;
|
||||
for (const [name, predicate] of Object.entries(predicates) as Array<
|
||||
[keyof typeof predicates, (r: number, g: number, b: number) => boolean]
|
||||
>) {
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
let count = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (!predicate(pixels[index] ?? 0, pixels[index + 1] ?? 0, pixels[index + 2] ?? 0)) continue;
|
||||
const x = (index / 4) % canvas.width;
|
||||
const y = Math.floor(index / 4 / canvas.width);
|
||||
left = Math.min(left, x);
|
||||
right = Math.max(right, x);
|
||||
top = Math.min(top, y);
|
||||
bottom = Math.max(bottom, y);
|
||||
count++;
|
||||
}
|
||||
result[name] = count > 0 ? { left, right, top, bottom, count } : null;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
await task.destroy();
|
||||
}
|
||||
}, Array.from(bytes));
|
||||
}
|
||||
|
||||
async function exportBuilderFile(page: Page, testInfo: TestInfo, buttonName: "Download JSON" | "Download PDF") {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
||||
const download = await pending;
|
||||
const path = testInfo.outputPath(download.suggestedFilename());
|
||||
await download.saveAs(path);
|
||||
await page.keyboard.press("Escape");
|
||||
return path;
|
||||
}
|
||||
|
||||
test("uploads and persists full Contain image with browser/server PDF parity", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1920, height: 950 });
|
||||
await installPdfJsRoute(page);
|
||||
await createSampleResumeFromDashboard(page, info);
|
||||
const resumeId = page.url().split("/").at(-1);
|
||||
if (!resumeId) throw new Error("Missing resume id");
|
||||
|
||||
await openSidebarSection(page, "Picture");
|
||||
await page.getByRole("button", { name: "Contain", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Undo", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Cover", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Redo", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
const dataUrl = await createMarkedLandscape(page);
|
||||
const inputBytes = markedLandscapePng(dataUrl);
|
||||
await info.attach("marked-landscape.png", { body: inputBytes, contentType: "image/png" });
|
||||
await page.locator('#sidebar-picture input[type="file"]').setInputFiles({
|
||||
name: "marked-landscape.png",
|
||||
mimeType: "image/png",
|
||||
buffer: inputBytes,
|
||||
});
|
||||
await expect(page.getByRole("dialog", { name: "Crop picture" })).toHaveCount(0);
|
||||
const pictureUrl = page.locator("#sidebar-picture input[name=url]");
|
||||
await expect(pictureUrl).toHaveValue(/\/uploads\//);
|
||||
const sidebarPreview = page.getByRole("button", { name: "Delete picture" }).locator("img");
|
||||
await expect(sidebarPreview).toBeVisible();
|
||||
await expect(sidebarPreview).toHaveCSS("object-fit", "contain");
|
||||
const storedUrl = await pictureUrl.inputValue();
|
||||
const storedRequestUrl = storedUrl.startsWith("/uploads/") ? `/api${storedUrl}` : storedUrl;
|
||||
const storedResponse = await page.request.get(storedRequestUrl);
|
||||
expect(storedResponse.ok()).toBe(true);
|
||||
expect(storedResponse.headers()["content-type"]).toMatch(/^image\//);
|
||||
const storedBytes = await storedResponse.body();
|
||||
await info.attach("stored-landscape", { body: storedBytes, contentType: storedResponse.headers()["content-type"] });
|
||||
const storedGeometry = await page.evaluate(async (url) => {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.drawImage(image, 0, 0);
|
||||
const sample = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data.slice(0, 3)];
|
||||
return {
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
left: sample(Math.floor(image.naturalWidth * 0.02), Math.floor(image.naturalHeight / 2)),
|
||||
right: sample(Math.floor(image.naturalWidth * 0.98), Math.floor(image.naturalHeight / 2)),
|
||||
top: sample(Math.floor(image.naturalWidth / 4), Math.floor(image.naturalHeight * 0.02)),
|
||||
bottom: sample(Math.floor(image.naturalWidth / 4), Math.floor(image.naturalHeight * 0.98)),
|
||||
};
|
||||
}, storedRequestUrl);
|
||||
expect(storedGeometry).toMatchObject({ width: 800, height: 600 });
|
||||
expect(storedGeometry.left[0]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.left[1]).toBeLessThan(50);
|
||||
expect(storedGeometry.right[0]).toBeLessThan(50);
|
||||
expect(storedGeometry.right[1]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.top[2]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.bottom[0]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.bottom[1]).toBeGreaterThan(220);
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
let slug = "";
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const result = await pool.query<{ data: { picture: { fit?: string; url?: string } }; slug: string }>(
|
||||
"select data, slug from resume where id = $1",
|
||||
[resumeId],
|
||||
);
|
||||
slug = result.rows[0]?.slug ?? "";
|
||||
return result.rows[0]?.data.picture;
|
||||
})
|
||||
.toMatchObject({ fit: "contain", url: storedUrl });
|
||||
|
||||
await page.getByRole("button", { name: "Cover", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Contain", exact: true }).click();
|
||||
expect(await pictureUrl.inputValue()).toBe(storedUrl);
|
||||
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Picture");
|
||||
await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator("#sidebar-picture input[name=url]")).toHaveValue(storedUrl);
|
||||
|
||||
await pool.query("update resume set is_public = true, updated_at = now() where id = $1", [resumeId]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const jsonPath = await exportBuilderFile(page, info, "Download JSON");
|
||||
const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { picture: { fit?: string; url?: string } };
|
||||
expect(exported.picture).toMatchObject({ fit: "contain", url: storedUrl });
|
||||
|
||||
const browserPdfPath = await exportBuilderFile(page, info, "Download PDF");
|
||||
const browserPdf = new Uint8Array(await readFile(browserPdfPath));
|
||||
const serverResponse = await page.request.get(`/api/resumes/${account.username}/${slug}/pdf`);
|
||||
expect(serverResponse.ok()).toBe(true);
|
||||
const serverPdf = new Uint8Array(await serverResponse.body());
|
||||
const browserMarkers = await markerMetricsFromPdf(page, browserPdf);
|
||||
const serverMarkers = await markerMetricsFromPdf(page, serverPdf);
|
||||
for (const name of ["red", "green", "blue", "yellow"] as const) {
|
||||
expect(browserMarkers[name]?.count).toBeGreaterThan(100);
|
||||
expect(serverMarkers[name]?.count).toBeGreaterThan(100);
|
||||
expect(serverMarkers[name]).toEqual(browserMarkers[name]);
|
||||
}
|
||||
await info.attach("pdf-marker-metrics", {
|
||||
body: JSON.stringify({ browserMarkers, serverMarkers }),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openResumeCardMenu, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const summaryMarker = "RECOVERY_SUMMARY_MARKER";
|
||||
const builtInMarker = "RECOVERY_BUILTIN_MARKER";
|
||||
const customMarker = "RECOVERY_CUSTOM_MARKER";
|
||||
|
||||
type AuthoredLayout = {
|
||||
pages: Array<{ fullWidth: boolean; main: string[]; sidebar: string[] }>;
|
||||
};
|
||||
|
||||
async function seedRecoveryResume(resumeId: string): Promise<AuthoredLayout> {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ data: Record<string, unknown> }>('select data from "resume" where id = $1', [
|
||||
resumeId,
|
||||
]);
|
||||
const data = result.rows[0]?.data as {
|
||||
summary: { title: string; content: string; hidden: boolean };
|
||||
sections: { experience: { title: string; hidden: boolean; items: Array<{ company: string }> } };
|
||||
customSections: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
hidden: boolean;
|
||||
items: Array<{ company?: string }>;
|
||||
}>;
|
||||
metadata: {
|
||||
layout: AuthoredLayout;
|
||||
typography: { body: { fontFamily: string }; heading: { fontFamily: string } };
|
||||
};
|
||||
};
|
||||
if (!data?.sections.experience.items[0] || !data.customSections[0]?.items[0]) {
|
||||
throw new Error("Sample resume lacks recovery fixture sections.");
|
||||
}
|
||||
|
||||
data.summary.title = "Recovery Summary";
|
||||
data.summary.content = `<p>${summaryMarker}</p>`;
|
||||
data.summary.hidden = false;
|
||||
data.sections.experience.title = "Recovery Experience";
|
||||
data.sections.experience.items[0].company = builtInMarker;
|
||||
data.sections.experience.hidden = false;
|
||||
data.customSections[0].title = "Recovery Custom";
|
||||
data.customSections[0].items[0].company = customMarker;
|
||||
data.customSections[0].hidden = false;
|
||||
data.metadata.typography.body.fontFamily = "Helvetica";
|
||||
data.metadata.typography.heading.fontFamily = "Helvetica";
|
||||
const layout = structuredClone(data.metadata.layout);
|
||||
|
||||
await pool.query('update "resume" set data = $2, updated_at = now() where id = $1', [resumeId, data]);
|
||||
return layout;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function readRecoveryState(resumeId: string) {
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
const result = await pool.query<{
|
||||
data: {
|
||||
summary: { hidden: boolean };
|
||||
sections: { experience: { hidden: boolean } };
|
||||
customSections: Array<{ title: string; hidden: boolean }>;
|
||||
metadata: { layout: AuthoredLayout };
|
||||
};
|
||||
}>('select data from "resume" where id = $1', [resumeId]);
|
||||
const data = result.rows[0]?.data;
|
||||
if (!data) throw new Error(`Resume ${resumeId} was not found.`);
|
||||
return data;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
function waitForResumeSave(page: Page) {
|
||||
return page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/rpc") || response.request().method() !== "POST") return false;
|
||||
if (!response.ok()) return false;
|
||||
return response.request().postData()?.includes('"data"') ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
async function hideStandardSection(page: Page, navigationTitle: string, title: string) {
|
||||
await page.getByRole("button", { name: navigationTitle, exact: true }).first().click();
|
||||
const heading = page.getByRole("heading", { name: title, exact: true }).filter({ visible: true }).first();
|
||||
await expect(heading).toBeVisible();
|
||||
await heading.locator("xpath=../..").getByRole("button", { name: "Section options" }).click();
|
||||
const saved = waitForResumeSave(page);
|
||||
await page.getByRole("menuitem", { name: "Hide", exact: true }).click();
|
||||
await saved;
|
||||
}
|
||||
|
||||
async function hideCustomSection(page: Page, title: string) {
|
||||
await openSidebarSection(page, "Custom Sections");
|
||||
const titleElement = page.getByText(title, { exact: true }).filter({ visible: true }).first();
|
||||
const card = titleElement.locator("xpath=../../..");
|
||||
await card.getByRole("button", { name: "Section options" }).click();
|
||||
const saved = waitForResumeSave(page);
|
||||
await page.getByRole("menuitem", { name: "Hide", exact: true }).click();
|
||||
await saved;
|
||||
}
|
||||
|
||||
async function showSection(page: Page, title: string) {
|
||||
const saved = waitForResumeSave(page);
|
||||
await page.getByRole("button", { name: `Show ${title} section` }).click();
|
||||
await saved;
|
||||
}
|
||||
|
||||
async function downloadPdfText(page: Page, testInfo: TestInfo, name: string) {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download PDF", exact: true }).click();
|
||||
const download = await pending;
|
||||
const path = testInfo.outputPath(`${name}.pdf`);
|
||||
await download.saveAs(path);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true });
|
||||
try {
|
||||
const pdf = await loading.promise;
|
||||
const text: string[] = [];
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
|
||||
const pdfPage = await pdf.getPage(pageNumber);
|
||||
text.push(...(await pdfPage.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : [])));
|
||||
}
|
||||
return text.join(" ");
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
test("recovers hidden printable sections without changing authored placement", async ({ authPage: page }, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const resumeName = await createSampleResumeFromDashboard(page, testInfo);
|
||||
const resumeId = new URL(page.url()).pathname.split("/").at(-1);
|
||||
if (!resumeId) throw new Error("Builder URL lacks resume id.");
|
||||
const authoredLayout = await seedRecoveryResume(resumeId);
|
||||
await page.reload();
|
||||
|
||||
await hideStandardSection(page, "Summary", "Recovery Summary");
|
||||
await hideStandardSection(page, "Experience", "Recovery Experience");
|
||||
await hideCustomSection(page, "Recovery Custom");
|
||||
await page.reload();
|
||||
|
||||
for (const title of ["Recovery Summary", "Recovery Experience", "Recovery Custom"]) {
|
||||
await expect(page.getByRole("button", { name: `Show ${title} section` })).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Recovery Summary", exact: true }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Recovery Experience", exact: true }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByText("Recovery Custom", { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
|
||||
const hiddenPdf = await downloadPdfText(page, testInfo, "section-recovery-hidden");
|
||||
expect(hiddenPdf).not.toContain(summaryMarker);
|
||||
expect(hiddenPdf).not.toContain(builtInMarker);
|
||||
expect(hiddenPdf).not.toContain(customMarker);
|
||||
|
||||
await showSection(page, "Recovery Summary");
|
||||
await showSection(page, "Recovery Experience");
|
||||
await showSection(page, "Recovery Custom");
|
||||
const shownState = await readRecoveryState(resumeId);
|
||||
expect(shownState.metadata.layout).toEqual(authoredLayout);
|
||||
expect(shownState.summary.hidden).toBe(false);
|
||||
expect(shownState.sections.experience.hidden).toBe(false);
|
||||
expect(shownState.customSections.find((section) => section.title === "Recovery Custom")?.hidden).toBe(false);
|
||||
|
||||
const shownPdf = await downloadPdfText(page, testInfo, "section-recovery-shown");
|
||||
expect(shownPdf).toContain(summaryMarker);
|
||||
expect(shownPdf).toContain(builtInMarker);
|
||||
expect(shownPdf).toContain(customMarker);
|
||||
|
||||
await page.getByRole("button", { name: "Undo", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Redo", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toHaveCount(0);
|
||||
|
||||
const saved = waitForResumeSave(page);
|
||||
await page.getByRole("button", { name: "Undo", exact: true }).click();
|
||||
await saved;
|
||||
await openResumeCardMenu(page, resumeName);
|
||||
const locked = page.waitForResponse((response) => (response.request().postData() ?? "").includes('"isLocked":true'));
|
||||
await page.getByRole("menuitem", { name: "Lock" }).click();
|
||||
await page.getByRole("alertdialog").getByRole("button", { name: "Confirm" }).click();
|
||||
await locked;
|
||||
await page.goto(`/builder/${resumeId}`);
|
||||
|
||||
await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toBeDisabled();
|
||||
expect((await readRecoveryState(resumeId)).metadata.layout).toEqual(authoredLayout);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createSampleResumeFromDashboard } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
async function measureThumbnail(page: Page) {
|
||||
const image = page.locator('a[href^="/builder/"] [style*="background-image"]').first();
|
||||
await expect(image).toBeVisible({ timeout: 60_000 });
|
||||
return image.evaluate(async (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const url = style.backgroundImage.slice(5, -2);
|
||||
const blob = await (await fetch(url)).blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scale = Math.min(rect.width / bitmap.width, rect.height / bitmap.height);
|
||||
const result = {
|
||||
url,
|
||||
viewport: innerWidth,
|
||||
dpr: devicePixelRatio,
|
||||
cssWidth: rect.width,
|
||||
cssHeight: rect.height,
|
||||
pngWidth: bitmap.width,
|
||||
pngHeight: bitmap.height,
|
||||
coverage: 1 / (scale * devicePixelRatio),
|
||||
};
|
||||
bitmap.close();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSharpThumbnail(page: Page) {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect
|
||||
.poll(async () => (await measureThumbnail(page)).coverage, { timeout: 60_000 })
|
||||
.toBeGreaterThanOrEqual(0.99);
|
||||
return measureThumbnail(page);
|
||||
}
|
||||
|
||||
test("dashboard thumbnails cover physical pixels in fresh and cached Grid and Compact views", async ({
|
||||
authPage,
|
||||
browser,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await createSampleResumeFromDashboard(authPage, info);
|
||||
const storageState = await authPage.context().storageState();
|
||||
const measurements: unknown[] = [];
|
||||
for (const deviceScaleFactor of [1, 2, 3]) {
|
||||
const context = await browser.newContext({
|
||||
baseURL: String(info.project.use.baseURL),
|
||||
storageState,
|
||||
deviceScaleFactor,
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto("/dashboard/resumes?view=grid");
|
||||
const desktop = await expectSharpThumbnail(page);
|
||||
measurements.push({ scenario: "fresh-desktop-grid", ...desktop });
|
||||
// Immediately below sm, one Grid column is 607 CSS px wide.
|
||||
await page.setViewportSize({ width: 639, height: 1000 });
|
||||
const grown = await expectSharpThumbnail(page);
|
||||
expect(grown.pngWidth).toBeGreaterThan(desktop.pngWidth);
|
||||
measurements.push({ scenario: "grown-cached-grid", ...grown });
|
||||
await page.screenshot({ path: info.outputPath(`grid-dpr${deviceScaleFactor}.png`) });
|
||||
await page.getByRole("tab", { name: "Compact", exact: true }).click();
|
||||
const compact = await expectSharpThumbnail(page);
|
||||
expect(compact.url).toBe(grown.url);
|
||||
measurements.push({ scenario: "cached-compact", ...compact });
|
||||
await page.reload();
|
||||
measurements.push({ scenario: "fresh-compact", ...(await expectSharpThumbnail(page)) });
|
||||
await page.getByRole("tab", { name: "Grid", exact: true }).click();
|
||||
measurements.push({ scenario: "compact-to-grid", ...(await expectSharpThumbnail(page)) });
|
||||
await page.reload();
|
||||
measurements.push({ scenario: "fresh-mobile-grid", ...(await expectSharpThumbnail(page)) });
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
const phone = await expectSharpThumbnail(page);
|
||||
measurements.push({ scenario: "phone-grid", ...phone });
|
||||
if (deviceScaleFactor === 1) {
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 390,
|
||||
height: 1000,
|
||||
deviceScaleFactor: 3,
|
||||
mobile: false,
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(3);
|
||||
const higherDpr = await expectSharpThumbnail(page);
|
||||
expect(higherDpr.pngWidth).toBeGreaterThan(phone.pngWidth);
|
||||
measurements.push({ scenario: "changed-dpr-grid", ...higherDpr });
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await writeFile(info.outputPath("measurements.json"), JSON.stringify(measurements, null, 2));
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user