From 367b70681ac3b078177a8f4a901b39e8f6f42a8d Mon Sep 17 00:00:00 2001
From: cesnimda
Date: Mon, 24 Aug 2026 20:21:23 +0200
Subject: [PATCH] feat(cv): rebuild professional resume studio
---
JobTrackerApi.Tests/ApplicationAssetsTests.cs | 2 +-
JobTrackerApi.Tests/CvBuilderTests.cs | 63 ++++
JobTrackerApi.Tests/MigrationChainTests.cs | 4 +
.../Controllers/CvVariantController.cs | 49 ++-
JobTrackerApi/Models/CvVariantSettings.cs | 58 ++++
JobTrackerApi/Models/StructuredCvProfile.cs | 8 +
.../Models/StructuredCvProfileJson.cs | 30 ++
.../Services/AccountDataExportService.cs | 1 +
JobTrackerApi/Services/CvRenderModel.cs | 57 +++-
JobTrackerApi/Services/CvVariantService.cs | 43 ++-
JobTrackerApi/Services/ThemedCvRenderer.cs | 102 ++++---
docs/architecture/cv-builder.md | 24 +-
docs/architecture/cv-theme-engine.md | 12 +-
docs/verification/career-002-cv-builder.md | 22 +-
job-tracker-ui/e2e/smoke.spec.ts | 7 +
.../src/career-workspace-page.test.tsx | 2 +-
.../src/components/CvTemplateThumbnail.tsx | 65 ++++
.../src/cv-builder-deep-link.test.tsx | 32 +-
job-tracker-ui/src/cv-builder-page.test.tsx | 8 +-
job-tracker-ui/src/cvBuilder.ts | 28 +-
job-tracker-ui/src/i18n/translations.ts | 2 +
job-tracker-ui/src/profileCv.ts | 6 +-
job-tracker-ui/src/public-cv-page.test.tsx | 2 +-
.../src/views/CareerProfilePage.tsx | 5 +-
job-tracker-ui/src/views/CvBuilderEditor.tsx | 289 ++++++++++++------
job-tracker-ui/src/views/CvBuilderPage.tsx | 84 ++++-
.../views/career/CareerProfileSections.tsx | 9 +-
27 files changed, 827 insertions(+), 187 deletions(-)
create mode 100644 job-tracker-ui/src/components/CvTemplateThumbnail.tsx
diff --git a/JobTrackerApi.Tests/ApplicationAssetsTests.cs b/JobTrackerApi.Tests/ApplicationAssetsTests.cs
index 241bf23..5675d07 100644
--- a/JobTrackerApi.Tests/ApplicationAssetsTests.cs
+++ b/JobTrackerApi.Tests/ApplicationAssetsTests.cs
@@ -27,7 +27,7 @@ public sealed class ApplicationAssetsTests
.Setup(s => s.ListAsync(It.IsAny(), It.IsAny()))
.ReturnsAsync((string owner, CancellationToken _) => db.CvVariants
.Where(v => v.OwnerUserId == owner)
- .Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc))
+ .Select(v => new CvVariantSummary(v.Id, v.Name, "nordic", "en", v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc, null, null))
.ToList());
var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService());
diff --git a/JobTrackerApi.Tests/CvBuilderTests.cs b/JobTrackerApi.Tests/CvBuilderTests.cs
index 4ada67b..fba1f49 100644
--- a/JobTrackerApi.Tests/CvBuilderTests.cs
+++ b/JobTrackerApi.Tests/CvBuilderTests.cs
@@ -249,6 +249,54 @@ public sealed class CvBuilderTests
Assert.Contains("#123456", result.Html);
}
+ [Fact]
+ public void Design_and_layout_overrides_are_normalized_and_reach_the_shared_renderer()
+ {
+ var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings
+ {
+ ThemeId = "modern",
+ Layout = "sidebar-right",
+ TextColor = "#123456",
+ MutedColor = "#456789",
+ BackgroundColor = "#fafafa",
+ BaseFontSizePt = 99,
+ HeadingSizePt = 15,
+ LineHeight = 1.55,
+ PageMarginMm = 20,
+ SidebarWidthMm = 70,
+ SidebarSections = new() { "skills", "languages" },
+ SkillsStyle = "text",
+ });
+ var model = CvVariantResolver.Build(Rich(), settings, "F", null);
+ var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
+
+ Assert.Equal(13, settings.BaseFontSizePt);
+ Assert.Contains("grid-template-columns:minmax(0,1fr) 70mm", html);
+ Assert.Contains("color:#123456", html);
+ Assert.Contains("background:#fafafa", html);
+ Assert.Contains("line-height:1.55", html);
+ Assert.Contains("font-size:13pt", html);
+ Assert.Contains("class=\"skills-text\">C# · SQL
", html);
+ }
+
+ [Fact]
+ public void Norwegian_variant_localizes_default_labels_dates_and_keeps_custom_links_clickable()
+ {
+ var profile = Rich();
+ profile.Jobs[0].StartDate = "2020-01";
+ profile.Contact.GitHub = "github.com/ada";
+ profile.Contact.Links.Add(new StructuredCvLink { Label = "Portfolio", Url = "https://ada.example/work" });
+ var settings = new CvVariantSettings { Language = "no", DateFormat = "year" };
+
+ var model = CvVariantResolver.Build(profile, settings, "F", null);
+ var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve("modern"), settings).Html;
+
+ Assert.Equal("Arbeidserfaring", model.Sections.First(section => section.Key == "experience").Title);
+ Assert.Equal("2020 – nå", model.Sections.First(section => section.Key == "experience").Entries[0].Meta);
+ Assert.Contains("href=\"https://github.com/ada\"", html);
+ Assert.Contains("href=\"https://ada.example/work\">Portfolio", html);
+ }
+
// ---- Variant service (CRUD, autosave history, public, render) ----
private static (JobTrackerContext db, CvVariantService svc) NewService(string userId, string? dbName = null)
@@ -303,6 +351,21 @@ public sealed class CvBuilderTests
Assert.Equal("elegant", CvVariantSettingsJson.Deserialize(copy.SettingsJson).ThemeId);
}
+ [Fact]
+ public async Task Create_rejects_a_job_application_owned_by_another_user()
+ {
+ var (db, svc) = NewService("user-1");
+ await using var _ = db;
+ var company = new Company { OwnerUserId = "user-2", Name = "Other tenant" };
+ var foreignJob = new JobApplication { OwnerUserId = "user-2", JobTitle = "Private role", Company = company };
+ db.Add(foreignJob);
+ await db.SaveChangesAsync();
+
+ await Assert.ThrowsAsync(() =>
+ svc.CreateAsync("user-1", "CV", foreignJob.Id, new CvVariantSettings(), default));
+ Assert.Empty(await db.CvVariants.IgnoreQueryFilters().ToListAsync());
+ }
+
[Fact]
public async Task Delete_removes_the_variant_and_its_versions()
{
diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs
index 2eb1e51..1d0c120 100644
--- a/JobTrackerApi.Tests/MigrationChainTests.cs
+++ b/JobTrackerApi.Tests/MigrationChainTests.cs
@@ -36,6 +36,10 @@ public sealed class MigrationChainTests
'LastReminderEmailSentAt', 'RecruiterMessageDraft', 'SalaryMin', 'SalaryMax',
'SalaryCurrency', 'SalaryPeriod');
"""));
+ Assert.Equal(1, await ScalarAsync(connection, """
+ SELECT COUNT(*) FROM pragma_table_info('AspNetUsers')
+ WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true');
+ """));
}
[Fact]
diff --git a/JobTrackerApi/Controllers/CvVariantController.cs b/JobTrackerApi/Controllers/CvVariantController.cs
index d16b312..5ed9116 100644
--- a/JobTrackerApi/Controllers/CvVariantController.cs
+++ b/JobTrackerApi/Controllers/CvVariantController.cs
@@ -18,13 +18,15 @@ public sealed class CvVariantController : ControllerBase
private readonly ICvVariantService _variants;
private readonly ICvPdfExporter _pdf;
private readonly ISummarizerService _ai;
+ private readonly IThemedCvRenderer _renderer;
- public CvVariantController(UserManager users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai)
+ public CvVariantController(UserManager users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null)
{
_users = users;
_variants = variants;
_pdf = pdf;
_ai = ai;
+ _renderer = renderer ?? new ThemedCvRenderer();
}
public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
@@ -61,6 +63,20 @@ public sealed class CvVariantController : ControllerBase
return Ok(themes);
}
+ // Render-only sample for visual template thumbnails. This in-memory content is never written to
+ // the authenticated user's master profile or to a CV variant.
+ [HttpGet("themes/{themeId}/preview")]
+ public async Task> GetThemePreview(string themeId)
+ {
+ var user = await _users.GetUserAsync(User);
+ if (user is null) return Unauthorized();
+ if (!CvThemeCatalog.Exists(themeId)) return NotFound();
+ var theme = CvThemeCatalog.Resolve(themeId);
+ var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true });
+ var render = _renderer.Render(TemplatePreviewModel(), theme, settings);
+ return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
+ }
+
// The master profile as sections+entries (with ItemKeys) — the Content tab reads this to render
// editable rows without duplicating the profile shape on the client.
[HttpGet("outline")]
@@ -88,6 +104,8 @@ public sealed class CvVariantController : ControllerBase
return BadRequest("Unknown theme.");
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
+ if (request?.JobApplicationId is int jobApplicationId && !await _variants.CanAssociateJobAsync(user.Id, jobApplicationId, ct))
+ return BadRequest("The job application is unavailable.");
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
return Ok(ToDto(variant));
}
@@ -228,6 +246,7 @@ public sealed class CvVariantController : ControllerBase
"shorten" => "Make the text more concise without losing meaning.",
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
"grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.",
+ "impact" => "Strengthen the text by identifying where the user could add measurable impact. Use clearly marked placeholders for missing numbers and never invent a metric or result.",
"ats" => "Rewrite for ATS keyword optimisation: strong action verbs, quantified impact, clear phrasing. Do not invent facts.",
"bullets" => "Rewrite as 3–5 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.",
"summary" => "Write a concise professional summary (2–3 sentences) from this text. Do not invent facts.",
@@ -241,6 +260,34 @@ public sealed class CvVariantController : ControllerBase
private async Task CanUseThemeAsync(ApplicationUser user, string? themeId) =>
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
+ private static CvRenderModel TemplatePreviewModel() => new()
+ {
+ FullName = "Alex Morgan",
+ Headline = "Product & Platform Engineer",
+ Contact =
+ [
+ new() { Icon = "email", Value = "alex@example.com", Href = "mailto:alex@example.com" },
+ new() { Icon = "location", Value = "Oslo, Norway" },
+ new() { Icon = "web", Value = "alexmorgan.dev", Href = "https://alexmorgan.dev" },
+ ],
+ Sections =
+ [
+ new() { Key = "summary", Title = "Profile", Kind = "bullets", Bullets = ["Engineer focused on clear products, resilient systems and measurable delivery."] },
+ new()
+ {
+ Key = "experience", Title = "Experience", Kind = "entries",
+ Entries =
+ [
+ new() { Title = "Senior Platform Engineer", Subtitle = "Northstar Labs", Meta = "2022 – Present", Bullets = ["Led platform improvements across product teams.", "Reduced release lead time through safer automation."] },
+ new() { Title = "Software Engineer", Subtitle = "Studio Works", Meta = "2019 – 2022", Bullets = ["Built accessible customer workflows and APIs."] },
+ ],
+ },
+ new() { Key = "skills", Title = "Skills", Kind = "tags", Tags = ["C#", "React", "Cloud", "Design systems", "Delivery"] },
+ new() { Key = "education", Title = "Education", Kind = "entries", Entries = [new() { Title = "BSc Computer Science", Subtitle = "University of Oslo", Meta = "2016 – 2019" }] },
+ new() { Key = "languages", Title = "Languages", Kind = "tags", Tags = ["English", "Norwegian"] },
+ ],
+ };
+
private static CvRenderPerson Person(ApplicationUser user)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
diff --git a/JobTrackerApi/Models/CvVariantSettings.cs b/JobTrackerApi/Models/CvVariantSettings.cs
index 005adda..98a1e93 100644
--- a/JobTrackerApi/Models/CvVariantSettings.cs
+++ b/JobTrackerApi/Models/CvVariantSettings.cs
@@ -20,6 +20,25 @@ public sealed class CvVariantSettings
public string? Language { get; set; }
public string? Headline { get; set; } // override the contact headline for this variant
+ // Normalized design overrides. Null keeps the selected theme's value, which means old variants
+ // retain their exact appearance while new editor controls can be added without a schema change.
+ public string? TextColor { get; set; }
+ public string? MutedColor { get; set; }
+ public string? HeadingColor { get; set; }
+ public string? BackgroundColor { get; set; }
+ public double? BaseFontSizePt { get; set; }
+ public double? HeadingSizePt { get; set; }
+ public double? LineHeight { get; set; }
+ public double? PageMarginMm { get; set; }
+ public double? SectionGapMm { get; set; }
+ public double? EntryGapMm { get; set; }
+ public string? HeadingStyle { get; set; } // caps-rule | underline | plain | bar
+ public string? HeaderStyle { get; set; } // plain | band | centered | kicker
+ public string? SkillsStyle { get; set; } // tags | text
+ public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band
+ public double? SidebarWidthMm { get; set; }
+ public List? SidebarSections { get; set; }
+
public bool ShowPhoto { get; set; }
public bool ShowPageNumbers { get; set; }
public bool ShowIcons { get; set; } = true;
@@ -94,8 +113,36 @@ public static class CvVariantSettingsJson
s ??= new CvVariantSettings();
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
s.AccentColor = NormalizeColor(s.AccentColor);
+ s.TextColor = NormalizeColor(s.TextColor);
+ s.MutedColor = NormalizeColor(s.MutedColor);
+ s.HeadingColor = NormalizeColor(s.HeadingColor);
+ s.BackgroundColor = NormalizeColor(s.BackgroundColor);
s.HeadingFont = NormalizeFont(s.HeadingFont);
s.BodyFont = NormalizeFont(s.BodyFont);
+ s.BaseFontSizePt = Clamp(s.BaseFontSizePt, 7, 13);
+ s.HeadingSizePt = Clamp(s.HeadingSizePt, 9, 20);
+ s.LineHeight = Clamp(s.LineHeight, 1.1, 1.8);
+ s.PageMarginMm = Clamp(s.PageMarginMm, 8, 28);
+ s.SectionGapMm = Clamp(s.SectionGapMm, 2, 14);
+ s.EntryGapMm = Clamp(s.EntryGapMm, 1, 10);
+ s.SidebarWidthMm = Clamp(s.SidebarWidthMm, 45, 85);
+ s.HeadingStyle = NormalizeChoice(s.HeadingStyle, "caps-rule", "underline", "plain", "bar");
+ s.HeaderStyle = NormalizeChoice(s.HeaderStyle, "plain", "band", "centered", "kicker");
+ s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text");
+ s.Layout = NormalizeChoice(s.Layout, "single", "sidebar-left", "sidebar-right", "header-band");
+ s.DateFormat = NormalizeChoice(s.DateFormat, "long", "short", "numeric", "year");
+ if (!string.IsNullOrWhiteSpace(s.Language))
+ {
+ var language = s.Language.Trim().ToLowerInvariant();
+ s.Language = language[..Math.Min(12, language.Length)];
+ }
+ else s.Language = null;
+ s.SidebarSections = s.SidebarSections?
+ .Where(key => !string.IsNullOrWhiteSpace(key))
+ .Select(key => key.Trim().ToLowerInvariant())
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Take(20)
+ .ToList();
s.Sections ??= new();
s.Overrides ??= new();
s.CustomSections ??= new();
@@ -117,4 +164,15 @@ public static class CvVariantSettingsJson
var candidate = value?.Trim();
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
}
+
+ private static double? Clamp(double? value, double min, double max) =>
+ value is null || double.IsNaN(value.Value) || double.IsInfinity(value.Value)
+ ? null
+ : Math.Clamp(value.Value, min, max);
+
+ private static string? NormalizeChoice(string? value, params string[] choices)
+ {
+ var candidate = value?.Trim().ToLowerInvariant();
+ return candidate is not null && choices.Contains(candidate, StringComparer.Ordinal) ? candidate : null;
+ }
}
diff --git a/JobTrackerApi/Models/StructuredCvProfile.cs b/JobTrackerApi/Models/StructuredCvProfile.cs
index 6ed20f3..2c67c2a 100644
--- a/JobTrackerApi/Models/StructuredCvProfile.cs
+++ b/JobTrackerApi/Models/StructuredCvProfile.cs
@@ -49,6 +49,14 @@ public sealed class StructuredCvContact
public string? Location { get; set; }
public string? Website { get; set; }
public string? LinkedIn { get; set; }
+ public string? GitHub { get; set; }
+ public List Links { get; set; } = new();
+}
+
+public sealed class StructuredCvLink
+{
+ public string? Label { get; set; }
+ public string? Url { get; set; }
}
public sealed class StructuredCvJob
diff --git a/JobTrackerApi/Models/StructuredCvProfileJson.cs b/JobTrackerApi/Models/StructuredCvProfileJson.cs
index ef7a425..9fa77db 100644
--- a/JobTrackerApi/Models/StructuredCvProfileJson.cs
+++ b/JobTrackerApi/Models/StructuredCvProfileJson.cs
@@ -94,6 +94,8 @@ public static class StructuredCvProfileJson
profile.Contact.Location = TrimOrNull(profile.Contact.Location);
profile.Contact.Website = TrimOrNull(profile.Contact.Website);
profile.Contact.LinkedIn = TrimOrNull(profile.Contact.LinkedIn);
+ profile.Contact.GitHub = NormalizeExternalLink(profile.Contact.GitHub);
+ profile.Contact.Links = NormalizeLinks(profile.Contact.Links);
profile.Summary = CleanList(profile.Summary);
profile.Jobs = (profile.Jobs ?? new List())
@@ -213,6 +215,11 @@ public static class StructuredCvProfileJson
primary.Contact.Location ??= secondary.Contact.Location;
primary.Contact.Website ??= secondary.Contact.Website;
primary.Contact.LinkedIn ??= secondary.Contact.LinkedIn;
+ primary.Contact.GitHub ??= secondary.Contact.GitHub;
+ primary.Contact.Links ??= new();
+ foreach (var link in secondary.Contact.Links ?? new())
+ if (!primary.Contact.Links.Any(existing => string.Equals(existing.Url, link.Url, StringComparison.OrdinalIgnoreCase)))
+ primary.Contact.Links.Add(link);
primary.Summary = primary.Summary.Count == 0
? secondary.Summary
@@ -393,9 +400,20 @@ public static class StructuredCvProfileJson
contact.Location = NormalizeLocationValue(contact.Location);
contact.Website = NormalizeWebsite(contact.Website);
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
+ contact.GitHub = NormalizeExternalLink(contact.GitHub);
+ contact.Links = NormalizeLinks(contact.Links);
return contact;
}
+ private static List NormalizeLinks(List? links) =>
+ (links ?? new List())
+ .Select(link => new StructuredCvLink { Label = TrimOrNull(link?.Label), Url = NormalizeExternalLink(link?.Url) })
+ .Where(link => link.Url is not null)
+ .GroupBy(link => link.Url, StringComparer.OrdinalIgnoreCase)
+ .Select(group => group.First())
+ .Take(12)
+ .ToList();
+
private static StructuredCvJob NormalizeJob(StructuredCvJob? job)
{
job ??= new StructuredCvJob();
@@ -537,6 +555,16 @@ public static class StructuredCvProfileJson
return $"https://www.linkedin.com{path}";
}
+ private static string? NormalizeExternalLink(string? value)
+ {
+ var trimmed = TrimOrNull(value);
+ if (trimmed is null) return null;
+ var candidate = trimmed.Contains("://", StringComparison.Ordinal) ? trimmed : $"https://{trimmed}";
+ if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
+ if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) return null;
+ return uri.AbsoluteUri.TrimEnd('/');
+ }
+
private static string? NormalizeDateValue(string? value)
{
var trimmed = TrimOrNull(value);
@@ -726,6 +754,8 @@ public static class StructuredCvProfileJson
AddIf(contactLines, profile.Contact.Location);
AddIf(contactLines, profile.Contact.Website);
AddIf(contactLines, profile.Contact.LinkedIn);
+ AddIf(contactLines, profile.Contact.GitHub);
+ foreach (var link in profile.Contact.Links) AddIf(contactLines, link.Url);
AddSectionIfAny(sections, "Contact", contactLines);
AddSectionIfAny(sections, "Professional Summary", profile.Summary);
diff --git a/JobTrackerApi/Services/AccountDataExportService.cs b/JobTrackerApi/Services/AccountDataExportService.cs
index 6d2601a..fb86731 100644
--- a/JobTrackerApi/Services/AccountDataExportService.cs
+++ b/JobTrackerApi/Services/AccountDataExportService.cs
@@ -98,6 +98,7 @@ public sealed class AccountDataExportService(
user.StripeLastEventCreatedUtc,
user.AiEnabled,
user.ExternalAiProcessingAllowed,
+ user.EmailFollowUpRemindersEnabled,
Roles = roles,
Claims = claims,
ExternalLogins = logins,
diff --git a/JobTrackerApi/Services/CvRenderModel.cs b/JobTrackerApi/Services/CvRenderModel.cs
index a7dc6a1..d479ed5 100644
--- a/JobTrackerApi/Services/CvRenderModel.cs
+++ b/JobTrackerApi/Services/CvRenderModel.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
@@ -64,10 +65,17 @@ public static class CvVariantResolver
};
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
- AddContact(model.Contact, "phone", profile.Contact.Phone, null);
+ AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
AddContact(model.Contact, "location", profile.Contact.Location, null);
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
+ AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
+ foreach (var link in profile.Contact.Links)
+ {
+ var url = Trim(link.Url);
+ if (url is null) continue;
+ model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
+ }
var built = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
@@ -84,6 +92,7 @@ public static class CvVariantResolver
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
["references"] = BulletSection("references", "References", profile.References),
};
+ ApplyLanguageLabels(built, settings.Language);
// OtherSections from the master profile become body sections keyed other:.
for (var i = 0; i < profile.OtherSections.Count; i++)
@@ -160,7 +169,7 @@ public static class CvVariantResolver
Key = job.Id,
Title = Trim(ov?.Title) ?? Trim(job.Title),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
- Meta = DateRange(job.Start, job.End, job.IsCurrent),
+ Meta = DateRange(job.StartDate ?? job.Start, job.EndDate ?? job.End, job.IsCurrent, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
Tags = Clean(job.Skills),
});
@@ -181,7 +190,7 @@ public static class CvVariantResolver
Key = ed.Id,
Title = Trim(ov?.Title) ?? title,
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
- Meta = DateRange(ed.Start, ed.End, false),
+ Meta = DateRange(ed.StartDate ?? ed.Start, ed.EndDate ?? ed.End, false, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
});
}
@@ -200,7 +209,7 @@ public static class CvVariantResolver
Key = pr.Id,
Title = Trim(ov?.Title) ?? Trim(pr.Name),
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
- Meta = DateRange(pr.Start, pr.End, false),
+ Meta = DateRange(pr.StartDate ?? pr.Start, pr.EndDate ?? pr.End, false, settings),
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
Tags = Clean(pr.Skills),
});
@@ -279,13 +288,45 @@ public static class CvVariantResolver
return string.IsNullOrWhiteSpace(joined) ? null : joined;
}
- private static string? DateRange(string? start, string? end, bool isCurrent)
+ private static string? DateRange(string? start, string? end, bool isCurrent, CvVariantSettings settings)
{
- var s = Trim(start);
- var e = Trim(end);
+ var s = FormatDate(Trim(start), settings);
+ var e = FormatDate(Trim(end), settings);
if (s is null && e is null) return null;
if (s is null) return e;
- return $"{s} – {(isCurrent ? "Present" : e ?? "Present")}";
+ var present = IsNorwegian(settings.Language) ? "nå" : "Present";
+ return $"{s} – {(isCurrent ? present : e ?? present)}";
+ }
+
+ private static string? FormatDate(string? value, CvVariantSettings settings)
+ {
+ if (value is null || settings.DateFormat is null) return value;
+ if (!DateTime.TryParseExact(value, new[] { "yyyy-MM", "yyyy-M", "yyyy-MM-dd", "yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return value;
+ var culture = IsNorwegian(settings.Language) ? CultureInfo.GetCultureInfo("nb-NO") : CultureInfo.GetCultureInfo("en-GB");
+ return settings.DateFormat switch
+ {
+ "year" => date.ToString("yyyy", culture),
+ "numeric" => date.ToString("MM/yyyy", culture),
+ "long" => date.ToString("MMMM yyyy", culture),
+ _ => date.ToString("MMM yyyy", culture),
+ };
+ }
+
+ private static bool IsNorwegian(string? language) => language?.StartsWith("no", StringComparison.OrdinalIgnoreCase) == true
+ || language?.StartsWith("nb", StringComparison.OrdinalIgnoreCase) == true
+ || language?.StartsWith("nn", StringComparison.OrdinalIgnoreCase) == true;
+
+ private static void ApplyLanguageLabels(Dictionary sections, string? language)
+ {
+ if (!IsNorwegian(language)) return;
+ var labels = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["summary"] = "Profil", ["experience"] = "Arbeidserfaring", ["education"] = "Utdanning",
+ ["projects"] = "Prosjekter", ["skills"] = "Ferdigheter", ["certifications"] = "Sertifiseringer",
+ ["languages"] = "Språk", ["interests"] = "Interesser", ["awards"] = "Priser og utmerkelser",
+ ["publications"] = "Publikasjoner", ["organisations"] = "Organisasjoner", ["references"] = "Referanser",
+ };
+ foreach (var (key, label) in labels) if (sections.TryGetValue(key, out var section)) section.Title = label;
}
private static List Clean(IEnumerable? items) =>
diff --git a/JobTrackerApi/Services/CvVariantService.cs b/JobTrackerApi/Services/CvVariantService.cs
index 4f92267..4873c6b 100644
--- a/JobTrackerApi/Services/CvVariantService.cs
+++ b/JobTrackerApi/Services/CvVariantService.cs
@@ -4,7 +4,18 @@ using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
-public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
+public sealed record CvVariantSummary(
+ int Id,
+ string Name,
+ string ThemeId,
+ string Language,
+ string PublicSlug,
+ bool IsPublic,
+ int Version,
+ int? JobApplicationId,
+ DateTimeOffset UpdatedAtUtc,
+ string? JobTitle = null,
+ string? CompanyName = null);
public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
// Person + photo needed to render, resolved from ApplicationUser by the controller so this service
@@ -14,6 +25,7 @@ public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl);
public interface ICvVariantService
{
Task> ListAsync(string ownerUserId, CancellationToken ct);
+ Task CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task GetAsync(string ownerUserId, int id, CancellationToken ct);
Task CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
Task SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct);
@@ -49,18 +61,26 @@ public sealed class CvVariantService : ICvVariantService
public async Task> ListAsync(string ownerUserId, CancellationToken ct)
{
- var query = _db.CvVariants.Where(x => x.OwnerUserId == ownerUserId);
+ var query = _db.CvVariants
+ .Include(x => x.JobApplication)
+ .ThenInclude(x => x!.Company)
+ .Where(x => x.OwnerUserId == ownerUserId);
var variants = _db.Database.IsSqlite()
? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList()
: await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
return variants.Select(Summarize).ToList();
}
+ public Task CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
+ _db.JobApplications.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct);
+
public Task GetAsync(string ownerUserId, int id, CancellationToken ct) =>
_db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
public async Task CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct)
{
+ if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct))
+ throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId));
var now = DateTimeOffset.UtcNow;
var normalized = CvVariantSettingsJson.Normalize(settings);
var variant = new CvVariant
@@ -110,7 +130,11 @@ public sealed class CvVariantService : ICvVariantService
var source = await GetAsync(ownerUserId, id, ct);
if (source is null) return null;
var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson);
- return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct);
+ var associatedJobId = source.JobApplicationId is not null
+ && await CanAssociateJobAsync(ownerUserId, source.JobApplicationId.Value, ct)
+ ? source.JobApplicationId
+ : null;
+ return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, associatedJobId, settings, ct);
}
public async Task DeleteAsync(string ownerUserId, int id, CancellationToken ct)
@@ -202,7 +226,18 @@ public sealed class CvVariantService : ICvVariantService
private static CvVariantSummary Summarize(CvVariant v)
{
var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson);
- return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc);
+ return new CvVariantSummary(
+ v.Id,
+ v.Name,
+ settings.ThemeId,
+ settings.Language ?? "en",
+ v.PublicSlug,
+ v.IsPublic,
+ v.Version,
+ v.JobApplicationId,
+ v.UpdatedAtUtc,
+ v.JobApplication?.JobTitle,
+ v.JobApplication?.Company?.Name);
}
private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim();
diff --git a/JobTrackerApi/Services/ThemedCvRenderer.cs b/JobTrackerApi/Services/ThemedCvRenderer.cs
index 08c087f..e5e29a1 100644
--- a/JobTrackerApi/Services/ThemedCvRenderer.cs
+++ b/JobTrackerApi/Services/ThemedCvRenderer.cs
@@ -23,30 +23,34 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
settings = CvVariantSettingsJson.Normalize(settings);
var accent = Override(settings.AccentColor, theme.Accent);
var headerInk = ContrastInk(accent);
- var headingColor = theme.HeadingColor ?? accent;
+ var ink = Override(settings.TextColor, theme.Ink);
+ var muted = Override(settings.MutedColor, theme.Muted);
+ var paper = Override(settings.BackgroundColor, theme.Paper);
+ var headingColor = Override(settings.HeadingColor, theme.HeadingColor ?? accent);
var headingFont = Override(settings.HeadingFont, theme.HeadingFont);
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
var density = DensityScale(settings.Density);
+ var layout = Override(settings.Layout, theme.Layout);
+ var headingStyle = Override(settings.HeadingStyle, theme.HeadingStyle);
+ var headerStyle = Override(settings.HeaderStyle, theme.HeaderStyle);
+ var sidebarSections = settings.SidebarSections ?? theme.SidebarSections;
var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4";
var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm");
var showIcons = settings.ShowIcons && theme.DefaultIcons;
- var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right";
+ var twoColumn = layout is "sidebar-left" or "sidebar-right";
var (sidebarHtml, mainHtml) = twoColumn
- ? SplitColumns(model, theme, showIcons)
- : (string.Empty, RenderSections(model.Sections, theme));
+ ? SplitColumns(model, theme, sidebarSections, settings)
+ : (string.Empty, RenderSections(model.Sections, theme, settings));
- var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
- var header = RenderHeader(model, theme, showIcons, twoColumn);
- var body = theme.Layout switch
+ var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn);
+ var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
+ var body = layout switch
{
- "sidebar-left" => $@"{Sidebar(model, sidebarHtml, theme, showIcons)}
",
- "sidebar-right" => $@"{Sidebar(model, sidebarHtml, theme, showIcons)}
",
+ "sidebar-left" => $@"{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
",
+ "sidebar-right" => $@"{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}
",
_ => $@"{header}",
};
- // For two-column themes the header renders inside the sidebar; single/header-band render it on top.
- var page = twoColumn ? body : body;
-
var html = $@"
@@ -55,7 +59,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
-{page}
+{body}
";
@@ -63,39 +67,37 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return new ThemedCvRenderResult(theme.Id, fileName, html);
}
- private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons)
+ private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection sidebarKeys, CvVariantSettings settings)
{
- var sidebarKeys = theme.SidebarSections;
var sidebar = new StringBuilder();
var mainSections = new List();
foreach (var section in model.Sections)
{
- var baseKey = section.Key.Contains(':') ? section.Key : section.Key;
- if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase))
- sidebar.Append(RenderSection(section, theme));
+ if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase))
+ sidebar.Append(RenderSection(section, theme, settings));
else
mainSections.Add(section);
}
- return (sidebar.ToString(), RenderSections(mainSections, theme));
+ return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
}
- private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons)
+ private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection sidebarSections, string headerStyle)
{
- var header = RenderHeader(model, theme, showIcons, twoColumn: true);
- var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
+ var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
+ var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
: string.Empty;
return $@"";
}
- private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn)
+ private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle)
{
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
- var kicker = theme.HeaderStyle == "kicker" ? @"Curriculum Vitae
" : string.Empty;
+ var kicker = headerStyle == "kicker" ? @"Curriculum Vitae
" : string.Empty;
var name = $@"{Enc(model.FullName)}
";
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"{Enc(model.Headline)}
";
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
- var headerClass = twoColumn ? "hero" : $"header header-{theme.HeaderStyle}";
+ var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}";
return $@"";
}
@@ -118,19 +120,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return $@"{items}
";
}
- private static string RenderSections(IEnumerable sections, CvTheme theme)
+ private static string RenderSections(IEnumerable sections, CvTheme theme, CvVariantSettings settings)
{
var sb = new StringBuilder();
- foreach (var section in sections) sb.Append(RenderSection(section, theme));
+ foreach (var section in sections) sb.Append(RenderSection(section, theme, settings));
return sb.ToString();
}
- private static string RenderSection(CvRenderSection section, CvTheme theme)
+ private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
{
if (section.IsEmpty) return string.Empty;
var inner = section.Kind switch
{
"bullets" => $@"",
+ "tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"{string.Join(" · ", section.Tags.Select(Enc))}
",
"tags" => $@"{string.Join("", section.Tags.Select(t => $@"- {Enc(t)}
"))}
",
_ => string.Join("", section.Entries.Select(RenderEntry)),
};
@@ -190,21 +193,25 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return s;
}
- private static string BuildCss(CvTheme t, string accent, string headerInk, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
+ private static string BuildCss(CvTheme t, CvVariantSettings settings, string accent, string headerInk, string ink, string muted, string paper, string headingColor, string headingFont, string bodyFont, string headingStyle, double density, (string w, string h) page, string layout, bool twoColumn)
{
- var margin = F(t.PageMarginMm * density);
- var sectionGap = F(t.SectionGapMm * density);
- var entryGap = F(t.EntryGapMm * density);
- var headingCss = t.HeadingStyle switch
+ var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
+ var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
+ var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
+ var bodySize = settings.BaseFontSizePt ?? t.BodySizePt;
+ var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
+ var lineHeight = settings.LineHeight ?? t.LineHeight;
+ var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
+ var headingCss = headingStyle switch
{
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
"plain" => ".section-title{letter-spacing:.01em;}",
"bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}",
- _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
+ _ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
};
- var columnTemplate = t.Layout == "sidebar-right"
- ? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
- : $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)";
+ var columnTemplate = layout == "sidebar-right"
+ ? $"minmax(0,1fr) {F(sidebarWidth)}mm"
+ : $"{F(sidebarWidth)}mm minmax(0,1fr)";
var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
@@ -225,12 +232,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return $@"
*{{box-sizing:border-box;}}
html,body{{min-width:0;}}
-body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
-.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
+body{{margin:0;background:#e9edf2;color:{ink};font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
+.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
h1,h2{{font-family:{headingFont};}}
-.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;overflow-wrap:anywhere;}}
+.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{ink};line-height:1.1;overflow-wrap:anywhere;}}
.kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}}
-.headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}}
+.headline{{margin-top:1.5mm;color:{muted};font-size:{F(bodySize + 0.5)}pt;}}
.head-text,.main,.sidebar,.cols>*{{min-width:0;}}
.head-text{{flex:1;}}
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}}
@@ -238,7 +245,7 @@ h1,h2{{font-family:{headingFont};}}
.photo-rounded{{border-radius:5mm;}}
.photo-circle{{border-radius:50%;}}
.photo img{{width:100%;height:100%;object-fit:cover;display:block;}}
-.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}}
+.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{muted};font-size:{F(bodySize - 0.5)}pt;margin-top:2.5mm;}}
.contact-stacked{{flex-direction:column;gap:1.8mm;}}
.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;min-width:0;max-width:100%;overflow-wrap:anywhere;}}
.contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}}
@@ -247,18 +254,19 @@ h1,h2{{font-family:{headingFont};}}
.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}}
.section{{margin-top:{sectionGap}mm;}}
.section:first-child{{margin-top:0;}}
-.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}}
+.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(headingSize)}pt;font-weight:700;color:{headingColor};}}
{headingCss}
.bullets{{margin:0;padding-left:4.5mm;}}
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
.tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}}
-.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
+.skills-text{{margin:0;overflow-wrap:anywhere;}}
+.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
.entry{{margin-bottom:{entryGap}mm;}}
.entry:last-child{{margin-bottom:0;}}
.entry-head{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;break-after:avoid-page;page-break-after:avoid;}}
-.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
-.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
-.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
+.entry-title{{font-weight:700;font-size:{F(bodySize + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
+.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
+.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
diff --git a/docs/architecture/cv-builder.md b/docs/architecture/cv-builder.md
index 94f7716..501ac1a 100644
--- a/docs/architecture/cv-builder.md
+++ b/docs/architecture/cv-builder.md
@@ -29,7 +29,9 @@ The master profile is never written by the builder. A variant references career
`SettingsJson` blob (`CvVariantSettings`, `JobTrackerApi/Models/CvVariantSettings.cs`) because it is edited and saved
as a unit — never queried field-by-field. A variant stores:
-- `ThemeId` + overrides: accent, heading/body font, density, page size, photo/icons/page-numbers.
+- `ThemeId` + normalized overrides: printable colours, curated heading/body fonts, typography,
+ density, A4/Letter, margins/gaps, one/two-column layout, sidebar assignment, heading/header and
+ skills treatments, photo/icons and the retained page-number extension point.
- `Sections`: ordered list with per-section `Hidden` + optional renamed `Title`.
- `Overrides`: keyed by `ItemKey` → `{ Hidden, Title, Subtitle, Bullets }` (per-item, job-specific).
- `CustomSections`: variant-only sections not in the master profile.
@@ -46,7 +48,8 @@ as a unit — never queried field-by-field. A variant stores:
## API
-Authenticated (`/api/cv`, `CvVariantController`): `GET themes`; `GET/POST variants`;
+Authenticated (`/api/cv`, `CvVariantController`): `GET themes`; `GET themes/{id}/preview`;
+`GET/POST variants`;
`GET/PUT/DELETE variants/{id}`; `POST variants/{id}/duplicate`; `PUT variants/{id}/public`;
`GET variants/{id}/versions`, `POST …/versions/{v}/restore`; `GET variants/{id}/preview` and
`POST preview` (live preview of unsaved settings); `POST variants/{id}/export-pdf`; `POST ai/assist`.
@@ -61,10 +64,16 @@ The endpoint applies the identical public/private slug check; unknown, revoked,
404 without invoking the exporter. Anonymous PDF generation is limited to three requests per minute
per public link because it launches Chromium. `PublicCvPage` exposes it as a native **Download PDF** link.
+CV/job associations are authorized server-side during creation. A caller cannot attach a CV to
+another user's job merely by supplying its numeric ID. List cards resolve an associated job's title
+and company only through the owner-filtered relationship.
+
## Builder workflow (frontend)
-`/career/builder` lists variants (`CvBuilderPage`); the editor (`CvBuilderEditor`) is three tabs —
-**Content**, **Customize**, **AI Tools** (plus **History**) — beside an always-on live preview that
+`/career/builder` lists variants (`CvBuilderPage`) with real rendered template thumbnails, language,
+modified/version state and associated-job context. Creation asks only for a name and template. The
+editor (`CvBuilderEditor`) separates **Content**, **Template**, **Design**, **Layout**, **AI** and
+**History** beside an always-on desktop preview that
re-renders through `POST /api/cv/preview` on a 300 ms debounce. Edits autosave on an 800 ms debounce
(`source: autosave`), appending a version each save; the header shows Unsaved / Saving / Saved.
`/cv/:slug` (`PublicCvPage`) renders a public CV in a sandboxed iframe.
@@ -75,9 +84,12 @@ buttons (the keyboard-accessible path); entry order is stored per section as `It
variant, never on the profile. Each entry exposes hide, title/subtitle override, and rich-text bullet
editing (`RichTextField` — a markdown toolbar over a textarea; storage stays plain text, the server
renderer converts the `**bold** *italic* __underline__ [text](url)` whitelist to safe HTML).
+Contact details and career history remain master data; the Content tab explains this boundary and
+links to the master editor while variant-specific wording, order, headings and visibility stay local.
-**Preview** has zoom presets (±, slider, measured Fit), physical A4/Letter dimensions, a ceiling-based
-page count with prev/next navigation and page-break indicators, and an "updating…" chip. Three-page
+**Preview** displays separate labelled A4/Letter page sheets rather than one infinite document. It has
+zoom controls, measured Fit, a ceiling-based page count with prev/next navigation, and an "updating…"
+chip. Three-page
and longer documents receive content-focus guidance rather than automatic font shrinking. Preview
requests and autosaves are ordered so stale responses cannot replace newer edits; PDF/public actions
save the current variant before consuming the stored render. **Customize** badges ATS-friendly themes.
diff --git a/docs/architecture/cv-theme-engine.md b/docs/architecture/cv-theme-engine.md
index 038c89b..d81c47e 100644
--- a/docs/architecture/cv-theme-engine.md
+++ b/docs/architecture/cv-theme-engine.md
@@ -24,15 +24,19 @@ which is retained only for the legacy tailored-draft flow and is not used by the
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` |
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons` |
-The renderer computes CSS variables from these plus the variant's runtime overrides (accent, fonts,
-density, page size, photo, icons) and picks one of the layout wrappers. `SidebarSections` decides which
-section keys move to the sidebar for the two-column layouts.
+The renderer computes CSS from these plus normalized variant overrides: printable palette, curated
+fonts, body/heading size, line height, spacing, page size, layout, sidebar width/content, header and
+heading treatments, skills presentation, photo and icons. `SidebarSections` decides which section
+keys move to the sidebar for the two-column layouts. Changing a template or override never transforms
+the structured career content.
## Adding a theme
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the
fields that differ from the defaults.
-2. Nothing else. It appears in `GET /api/cv/themes`, the Customize tab picker, and renders.
+2. Nothing else. It appears in `GET /api/cv/themes`, the Template tab and dashboard create flow.
+ `GET /api/cv/themes/{id}/preview` renders isolated in-memory sample data for a real visual thumbnail;
+ preview data is never persisted.
Add a `CvBuilderTests.Every_catalog_theme_renders_valid_html` already loops the whole catalog, so a new
theme is smoke-tested automatically.
diff --git a/docs/verification/career-002-cv-builder.md b/docs/verification/career-002-cv-builder.md
index 953bb26..0c22f9d 100644
--- a/docs/verification/career-002-cv-builder.md
+++ b/docs/verification/career-002-cv-builder.md
@@ -1,9 +1,29 @@
# CAREER-002 CV Builder redesign
-Updated: 2026-08-15
+Updated: 2026-08-24
Status: `IMPLEMENTED — NOT VERIFIED`. Repository, automated and pathological Chromium/PDF gates are complete; authenticated application-browser and production gates remain.
+## 2026-08-24 professional-builder increment
+
+- Split the editor into Content, Template, Design, Layout, AI and History workspaces under a compact
+ document toolbar with editable name, autosave/retry state, undo/redo, duplicate, public link and PDF.
+- Added normalized print-safe colour, typography, spacing, A4/Letter, language/date, skills and
+ one/two-column controls. The same settings and renderer drive live preview, public output and PDF.
+- Replaced the infinite preview with labelled measured page sheets, mobile Edit/Preview switching,
+ fit/zoom/page navigation and horizontal-overflow reporting.
+- Added real rendered demo thumbnails for all eight templates. Demo content is constructed in memory
+ by a read-only endpoint and is never stored in a user's master profile or CV.
+- Expanded structured contacts with GitHub and validated custom links; added Norwegian default labels,
+ `nå` and configurable date formats.
+- Hardened CV/job association so a foreign tenant's numeric job ID is rejected by the service and API.
+ Dashboard cards now show the authorized associated role/company instead of only an internal ID.
+- Added explicit Original/Suggested AI review with Accept/Reject/Edit and a measurable-impact action
+ that asks for placeholders rather than inventing metrics.
+- Frontend gates after this increment: TypeScript pass, optimized production build pass, 61/61 Jest
+ suites and 246/246 tests. Backend/migration/PDF reruns are blocked locally by the missing .NET 9 SDK;
+ the new backend paths have focused tests ready for the normal SDK/CI gate.
+
## Revalidated capability matrix
| Requirement | Existing implementation | Current status |
diff --git a/job-tracker-ui/e2e/smoke.spec.ts b/job-tracker-ui/e2e/smoke.spec.ts
index a9168d8..cc37a2d 100644
--- a/job-tracker-ui/e2e/smoke.spec.ts
+++ b/job-tracker-ui/e2e/smoke.spec.ts
@@ -310,6 +310,8 @@ test("Career Workspace loads from the authenticated application shell", async ({
await page.getByRole("link", { name: "Open CV Builder" }).click();
await expect(page).toHaveURL(/\/career\/builder$/);
await page.getByRole("button", { name: "New CV" }).first().click();
+ await expect(page.getByRole("dialog", { name: "Create a CV" })).toBeVisible();
+ await page.getByRole("button", { name: "Create CV" }).click();
await expect(page).toHaveURL(/\/career\/builder\/\d+$/);
await expect(page.getByLabel("CV name")).toBeVisible();
@@ -319,6 +321,11 @@ test("Career Workspace loads from the authenticated application shell", async ({
for (const width of [375, 768, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.getByLabel("CV name")).toBeVisible();
+ if (width === 375) {
+ await page.getByRole("button", { name: "Preview" }).click();
+ await expect(page.getByTitle("CV preview page 1")).toBeVisible();
+ await page.getByRole("button", { name: "Edit" }).click();
+ }
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
}
diff --git a/job-tracker-ui/src/career-workspace-page.test.tsx b/job-tracker-ui/src/career-workspace-page.test.tsx
index ca5128f..a3dac27 100644
--- a/job-tracker-ui/src/career-workspace-page.test.tsx
+++ b/job-tracker-ui/src/career-workspace-page.test.tsx
@@ -68,7 +68,7 @@ test("returning workspace shows missing profile information and recent general a
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
expect(screen.getAllByText("General CV").length).toBeGreaterThan(1);
expect(screen.getByText("Job-specific")).toBeInTheDocument();
- expect(screen.getAllByRole("link", { name: "Open", exact: true })[0]).toHaveAttribute("href", "/career/builder/8");
+ expect(screen.getAllByRole("link", { name: /^Open$/ })[0]).toHaveAttribute("href", "/career/builder/8");
});
test.each([
diff --git a/job-tracker-ui/src/components/CvTemplateThumbnail.tsx b/job-tracker-ui/src/components/CvTemplateThumbnail.tsx
new file mode 100644
index 0000000..c0498f8
--- /dev/null
+++ b/job-tracker-ui/src/components/CvTemplateThumbnail.tsx
@@ -0,0 +1,65 @@
+import React, { useEffect, useState } from "react";
+import { Box } from "@mui/material";
+
+import { CvTheme, cvBuilderApi } from "../cvBuilder";
+
+const previewCache = new Map();
+
+export default function CvTemplateThumbnail({ theme, height = 150 }: { theme?: CvTheme; height?: number }) {
+ const [previewHtml, setPreviewHtml] = useState(() => theme ? previewCache.get(theme.id) ?? "" : "");
+ const accent = theme?.swatches?.[0] || "#3157d5";
+ const sidebar = theme?.swatches?.[1] || "#eef1f4";
+ const paper = theme?.swatches?.[2] || "#ffffff";
+ const layout = theme?.layout || "header-band";
+ const hasSidebar = layout === "sidebar-left" || layout === "sidebar-right";
+ const sidebarRight = layout === "sidebar-right";
+ const lines = [92, 72, 84, 61, 88, 76, 95, 67];
+
+ useEffect(() => {
+ if (!theme) return;
+ const cached = previewCache.get(theme.id);
+ if (cached) {
+ setPreviewHtml(cached);
+ return;
+ }
+ setPreviewHtml("");
+ let active = true;
+ void cvBuilderApi.themePreview(theme.id).then((render) => {
+ previewCache.set(theme.id, render.html);
+ if (active) setPreviewHtml(render.html);
+ }).catch(() => undefined);
+ return () => { active = false; };
+ }, [theme]);
+
+ if (previewHtml) {
+ const scale = height / (297 * 96 / 25.4);
+ return (
+
+
+
+
+
+ );
+ }
+
+ const body =
+
+ {lines.map((width, index) => )}
+ ;
+ const side =
+
+ {[66, 86, 54, 74, 62, 79].map((width) => )}
+ ;
+
+ return (
+
+ {!hasSidebar &&
+ {layout === "header-band" && <>>}
+ }
+
+ {hasSidebar && !sidebarRight ? side : null}{body}{hasSidebar && sidebarRight ? side : null}
+
+
+ );
+}
diff --git a/job-tracker-ui/src/cv-builder-deep-link.test.tsx b/job-tracker-ui/src/cv-builder-deep-link.test.tsx
index 1dfabe4..9252050 100644
--- a/job-tracker-ui/src/cv-builder-deep-link.test.tsx
+++ b/job-tracker-ui/src/cv-builder-deep-link.test.tsx
@@ -128,6 +128,36 @@ test('failed autosave is visible and can be retried with the latest data', async
}));
});
+test('session undo and redo restore content edits before autosave', async () => {
+ routeGet(() => Promise.resolve({ data: variant } as any));
+ mockedApi.put.mockResolvedValue({ data: variant } as any);
+ renderAt(3);
+
+ const headline = await screen.findByLabelText('Headline override');
+ fireEvent.change(headline, { target: { value: 'Platform Engineer' } });
+ expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
+ expect(headline).toHaveValue('');
+ expect(screen.getByRole('button', { name: 'Redo' })).toBeEnabled();
+ fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
+ expect(headline).toHaveValue('Platform Engineer');
+});
+
+test('professional editor separates template, design and layout controls', async () => {
+ routeGet(() => Promise.resolve({ data: variant } as any));
+ renderAt(3);
+
+ await screen.findByLabelText('Headline override');
+ fireEvent.click(screen.getByRole('tab', { name: 'Design' }));
+ expect(screen.getByText('Typography')).toBeInTheDocument();
+ expect(screen.getByLabelText('Body size')).toBeInTheDocument();
+ expect(screen.getByLabelText('Skills presentation')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('tab', { name: 'Layout' }));
+ expect(screen.getByLabelText('Page size')).toBeInTheDocument();
+ expect(screen.getByLabelText('Columns')).toBeInTheDocument();
+ expect(screen.getByLabelText('Language')).toBeInTheDocument();
+});
+
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
routeGet(() => Promise.resolve({ data: variant } as any));
renderAt(3);
@@ -137,7 +167,7 @@ test('internal navigation warns and can be cancelled before discarding a pending
const dialog = await screen.findByRole('dialog', { name: 'Discard unsaved CV changes?' });
expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
- await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 });
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
});
diff --git a/job-tracker-ui/src/cv-builder-page.test.tsx b/job-tracker-ui/src/cv-builder-page.test.tsx
index bd24c03..c73d084 100644
--- a/job-tracker-ui/src/cv-builder-page.test.tsx
+++ b/job-tracker-ui/src/cv-builder-page.test.tsx
@@ -32,7 +32,7 @@ const mockedApi = api as jest.Mocked;
function renderPage() {
return render(
-
+
@@ -53,7 +53,7 @@ beforeEach(() => {
test('lists existing CVs from the variants API', async () => {
mockedApi.get.mockResolvedValueOnce({
data: [
- { id: 1, name: 'Frontend CV', themeId: 'modern', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: null, updatedAtUtc: new Date().toISOString() },
+ { id: 1, name: 'Frontend CV', themeId: 'modern', language: 'en', publicSlug: 'abc', isPublic: true, version: 2, jobApplicationId: 17, jobTitle: 'Frontend Engineer', companyName: 'Northstar', updatedAtUtc: new Date().toISOString() },
],
} as any);
@@ -61,6 +61,7 @@ test('lists existing CVs from the variants API', async () => {
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
expect(screen.getByText('Public')).toBeInTheDocument();
+ expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument();
const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' });
cvCard.focus();
expect(cvCard).toHaveFocus();
@@ -79,6 +80,9 @@ test('shows the empty state and creates a CV then navigates to the editor', asyn
fireEvent.click(screen.getAllByRole('button', { name: /New CV/i })[0]);
+ expect(await screen.findByRole('dialog', { name: 'Create a CV' })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Create CV' }));
+
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' })));
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
});
diff --git a/job-tracker-ui/src/cvBuilder.ts b/job-tracker-ui/src/cvBuilder.ts
index b821648..4dce21a 100644
--- a/job-tracker-ui/src/cvBuilder.ts
+++ b/job-tracker-ui/src/cvBuilder.ts
@@ -15,6 +15,22 @@ export type CvVariantSettings = {
dateFormat?: string | null;
language?: string | null;
headline?: string | null;
+ textColor?: string | null;
+ mutedColor?: string | null;
+ headingColor?: string | null;
+ backgroundColor?: string | null;
+ baseFontSizePt?: number | null;
+ headingSizePt?: number | null;
+ lineHeight?: number | null;
+ pageMarginMm?: number | null;
+ sectionGapMm?: number | null;
+ entryGapMm?: number | null;
+ headingStyle?: "caps-rule" | "underline" | "plain" | "bar" | null;
+ headerStyle?: "plain" | "band" | "centered" | "kicker" | null;
+ skillsStyle?: "tags" | "text" | null;
+ layout?: "single" | "sidebar-left" | "sidebar-right" | "header-band" | null;
+ sidebarWidthMm?: number | null;
+ sidebarSections?: string[] | null;
showPhoto: boolean;
showPageNumbers: boolean;
showIcons: boolean;
@@ -47,10 +63,13 @@ export type CvVariantSummary = {
id: number;
name: string;
themeId: string;
+ language?: string | null;
publicSlug: string;
isPublic: boolean;
version: number;
jobApplicationId: number | null;
+ jobTitle?: string | null;
+ companyName?: string | null;
updatedAtUtc: string;
};
@@ -74,6 +93,7 @@ export const AI_ACTIONS: { key: string; label: string }[] = [
{ key: "shorten", label: "Shorten" },
{ key: "expand", label: "Expand" },
{ key: "grammar", label: "Fix grammar" },
+ { key: "impact", label: "Add measurable impact" },
{ key: "ats", label: "ATS optimise" },
{ key: "bullets", label: "Generate bullets" },
{ key: "summary", label: "Generate summary" },
@@ -82,7 +102,8 @@ export const AI_ACTIONS: { key: string; label: string }[] = [
];
export const DEFAULT_SECTION_ORDER = [
- "summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests",
+ "summary", "experience", "education", "projects", "skills", "certifications", "languages", "awards",
+ "publications", "organisations", "interests", "references",
];
export const SECTION_LABELS: Record = {
@@ -94,6 +115,10 @@ export const SECTION_LABELS: Record = {
certifications: "Certifications",
languages: "Languages",
interests: "Interests",
+ awards: "Awards",
+ publications: "Publications",
+ organisations: "Organisations",
+ references: "References",
};
const CSS_PIXELS_PER_MM = 96 / 25.4;
@@ -166,6 +191,7 @@ export function wrapSelection(
export const cvBuilderApi = {
themes: () => api.get("/cv/themes").then((r) => r.data),
+ themePreview: (themeId: string) => api.get(`/cv/themes/${encodeURIComponent(themeId)}/preview`).then((r) => r.data),
outline: () => api.get("/cv/outline").then((r) => r.data),
list: () => api.get("/cv/variants").then((r) => r.data),
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts
index d1a308b..c3e5316 100644
--- a/job-tracker-ui/src/i18n/translations.ts
+++ b/job-tracker-ui/src/i18n/translations.ts
@@ -23,6 +23,7 @@ export const translations = {
kanbanPageSubtitle: "Drag a card between stages to update its status.",
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
+ correspondenceInbox: "Job email",
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
account: "Account",
profile: "Profile",
@@ -1192,6 +1193,7 @@ export const translations = {
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
+ correspondenceInbox: "Jobb-e-post",
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
account: "Konto",
profile: "Profil",
diff --git a/job-tracker-ui/src/profileCv.ts b/job-tracker-ui/src/profileCv.ts
index b6df830..b661666 100644
--- a/job-tracker-ui/src/profileCv.ts
+++ b/job-tracker-ui/src/profileCv.ts
@@ -29,6 +29,8 @@ export type StructuredCvContact = {
location?: string;
website?: string;
linkedIn?: string;
+ gitHub?: string;
+ links?: { label?: string; url?: string }[];
};
export type StructuredCvJob = {
@@ -116,7 +118,7 @@ export function emptyStructuredCv(): StructuredCvProfile {
return {
version: "1",
metadata: { fields: {} },
- contact: {},
+ contact: { links: [] },
summary: [],
jobs: [],
education: [],
@@ -232,6 +234,8 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
location: normalizeString(source.contact?.location),
website: normalizeString(source.contact?.website),
linkedIn: normalizeString(source.contact?.linkedIn),
+ gitHub: normalizeString(source.contact?.gitHub),
+ links: Array.isArray(source.contact?.links) ? source.contact.links.map((link: any) => ({ label: normalizeString(link?.label), url: normalizeString(link?.url) })).filter((link: any) => link.url) : [],
},
summary: normalizeList(source.summary),
jobs: Array.isArray(source.jobs)
diff --git a/job-tracker-ui/src/public-cv-page.test.tsx b/job-tracker-ui/src/public-cv-page.test.tsx
index 605ae7a..0c98d02 100644
--- a/job-tracker-ui/src/public-cv-page.test.tsx
+++ b/job-tracker-ui/src/public-cv-page.test.tsx
@@ -21,7 +21,7 @@ test('public CV exposes the rendered CV and PDF download', async () => {
mockedApi.get.mockResolvedValueOnce({ data: { html: 'Public CV
', name: 'Ada Lovelace' } } as any);
render(
-
+
} />
,
);
diff --git a/job-tracker-ui/src/views/CareerProfilePage.tsx b/job-tracker-ui/src/views/CareerProfilePage.tsx
index 3393016..8c18b12 100644
--- a/job-tracker-ui/src/views/CareerProfilePage.tsx
+++ b/job-tracker-ui/src/views/CareerProfilePage.tsx
@@ -225,10 +225,11 @@ export default function CareerProfilePage() {
]);
setMe(meResponse.data);
setProfileCvText(careerResponse.data?.cvText ?? "");
- setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
+ const profile = normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv());
+ setStructuredCv(profile);
setCompleteness(careerResponse.data?.completeness ?? null);
setProfileDirty(false);
- setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
+ setHeadline(profile.contact.headline ?? "");
setLoadError(null);
} catch (error: any) {
setMe(null);
diff --git a/job-tracker-ui/src/views/CvBuilderEditor.tsx b/job-tracker-ui/src/views/CvBuilderEditor.tsx
index 4b4c939..7b1f5eb 100644
--- a/job-tracker-ui/src/views/CvBuilderEditor.tsx
+++ b/job-tracker-ui/src/views/CvBuilderEditor.tsx
@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useBlocker, useNavigate, useParams } from "react-router-dom";
+import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
import {
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
} from "@mui/material";
+import useMediaQuery from "@mui/material/useMediaQuery";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
@@ -20,6 +21,8 @@ import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ZoomInIcon from "@mui/icons-material/ZoomIn";
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
+import UndoIcon from "@mui/icons-material/Undo";
+import RedoIcon from "@mui/icons-material/Redo";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
@@ -46,12 +49,19 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "
const MIN_PREVIEW_ZOOM = 0.32;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
+function previewPageHtml(html: string, pageIndex: number, pageHeightPx: number): string {
+ const offset = pageIndex * pageHeightPx;
+ const previewCss = ``;
+ return html.includes("") ? html.replace("", `${previewCss}`) : `${previewCss}${html}`;
+}
+
export default function CvBuilderEditor() {
const { id } = useParams();
const variantId = Number(id);
const navigate = useNavigate();
const { toast } = useToast();
const { confirmAction } = useDialogActions();
+ const compactEditor = useMediaQuery("(max-width:899.95px)");
const [name, setName] = useState("");
const [settings, setSettings] = useState(null);
@@ -65,7 +75,6 @@ export default function CvBuilderEditor() {
const [previewing, setPreviewing] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [previewRevision, setPreviewRevision] = useState(0);
- const [previewHeight, setPreviewHeight] = useState(() => getCvPageMetrics("a4").heightPx);
const [previewOverflow, setPreviewOverflow] = useState(false);
const [pages, setPages] = useState(1);
const [page, setPage] = useState(1);
@@ -74,6 +83,8 @@ export default function CvBuilderEditor() {
const [publishing, setPublishing] = useState(false);
const [versions, setVersions] = useState([]);
const [loadError, setLoadError] = useState(null);
+ const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit");
+ const [historyRevision, setHistoryRevision] = useState(0);
const saveTimer = useRef | null>(null);
const saveRevision = useRef(0);
@@ -85,6 +96,8 @@ export default function CvBuilderEditor() {
const iframeRef = useRef(null);
const scrollRef = useRef(null);
const blockerPromptOpen = useRef(false);
+ const undoStack = useRef([]);
+ const redoStack = useRef([]);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
useEffect(() => {
@@ -120,6 +133,9 @@ export default function CvBuilderEditor() {
setIsPublic(variant.isPublic);
setPublicSlug(variant.publicSlug);
setSaveState("saved");
+ undoStack.current = [];
+ redoStack.current = [];
+ setHistoryRevision((value) => value + 1);
};
// Debounced live preview.
@@ -189,6 +205,31 @@ export default function CvBuilderEditor() {
setSettings((prev) => {
if (!prev) return prev;
const next = { ...prev, ...patch };
+ undoStack.current = [...undoStack.current.slice(-59), prev];
+ redoStack.current = [];
+ setHistoryRevision((value) => value + 1);
+ scheduleSave(next);
+ return next;
+ });
+ };
+
+ const undo = () => {
+ setSettings((current) => {
+ const previous = undoStack.current.pop();
+ if (!current || !previous) return current;
+ redoStack.current.push(current);
+ setHistoryRevision((value) => value + 1);
+ scheduleSave(previous);
+ return previous;
+ });
+ };
+
+ const redo = () => {
+ setSettings((current) => {
+ const next = redoStack.current.pop();
+ if (!current || !next) return current;
+ undoStack.current.push(current);
+ setHistoryRevision((value) => value + 1);
scheduleSave(next);
return next;
});
@@ -215,7 +256,6 @@ export default function CvBuilderEditor() {
confirmLabel: "Discard and leave",
destructive: true,
}).then((confirmed) => {
- blockerPromptOpen.current = false;
if (confirmed) blocker.proceed();
else blocker.reset();
});
@@ -279,6 +319,20 @@ export default function CvBuilderEditor() {
}
};
+ const duplicateVariant = async () => {
+ if (hasUnsavedChanges && !(await retrySave())) {
+ toast("Save the current CV before duplicating it.", "error");
+ return;
+ }
+ try {
+ const copy = await cvBuilderApi.duplicate(variantId, `${name || "Untitled CV"} copy`);
+ navigate(`/career/builder/${copy.id}`);
+ toast("CV duplicated. You are editing the copy.", "success");
+ } catch (err) {
+ toast(getApiErrorMessage(err, "Could not duplicate this CV."), "error");
+ }
+ };
+
const loadVersions = async () => {
try {
setVersions(await cvBuilderApi.versions(variantId));
@@ -316,7 +370,6 @@ export default function CvBuilderEditor() {
doc?.documentElement?.scrollHeight ?? 0,
);
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
- setPreviewHeight(h);
setPages(pageCount);
setPage((current) => Math.min(current, pageCount));
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
@@ -331,7 +384,8 @@ export default function CvBuilderEditor() {
const goToPage = (p: number) => {
const clamped = Math.min(Math.max(1, p), pages);
setPage(clamped);
- scrollRef.current?.scrollTo({ top: (clamped - 1) * pageMetrics.heightPx * zoom, behavior: "smooth" });
+ const target = scrollRef.current?.querySelector(`[data-cv-page="${clamped}"]`);
+ if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
};
const fitPreview = () => {
@@ -349,38 +403,57 @@ export default function CvBuilderEditor() {
}
if (!settings) return ;
+ const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
+ const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
+
return (
-
-
-
+
+
+
navigate("/career/builder")}>
- renameVariant(e.target.value)}
+ renameVariant(e.target.value)}
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
+ sx={{ minWidth: { xs: 150, sm: 220 }, flex: "1 1 240px", maxWidth: 420 }}
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
void retrySave()} />
-
-
- } disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Export PDF"}
+
+
+
+ {compactEditor &&
+
+
+ }
+
+ } disabled={!name.trim()} onClick={() => void duplicateVariant()}>Duplicate
} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
{publishing ? "Updating…" : isPublic ? "Public" : "Private"}
{isPublic && } onClick={copyPublicLink}>Copy link}
+ } disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Download PDF"}
+
- { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
+
+
+
+ { setTab(v); if (v === 5) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
-
-
+
+
+
+
{tab === 0 && }
- {tab === 1 && }
- {tab === 2 && }
- {tab === 3 && }
+ {tab === 1 && }
+ {tab === 2 && }
+ {tab === 3 && }
+ {tab === 4 && }
+ {tab === 5 && }
-
+
Live preview
{previewing && }
@@ -402,30 +475,27 @@ export default function CvBuilderEditor() {
{previewOverflow && The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.}
{pages >= 3 && This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.}
-
-
-
- {Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
-
+
+
+
+ {Array.from({ length: pages }).map((_, index) => (
+
+ Page {index + 1}
+
+
+
+
))}
-
+
-
+
+
);
}
@@ -478,6 +548,10 @@ function ContentTab({ settings, update, outline }: {
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
const have = new Set(base.map((s) => s.key));
for (const key of DEFAULT_SECTION_ORDER) if (!have.has(key)) base.push({ key });
+ for (const section of outline?.sections ?? []) if (!have.has(section.key)) {
+ base.push({ key: section.key, title: section.title });
+ have.add(section.key);
+ }
for (const custom of settings.customSections) {
const key = `custom:${custom.key}`;
if (!have.has(key)) {
@@ -486,7 +560,7 @@ function ContentTab({ settings, update, outline }: {
}
}
return base;
- }, [settings.customSections, settings.sections]);
+ }, [outline?.sections, settings.customSections, settings.sections]);
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
@@ -547,6 +621,9 @@ function ContentTab({ settings, update, outline }: {
return (
+ Edit master profile}>
+ Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
+
update({ headline: e.target.value || null })}
helperText="Blank uses the headline from your master profile." />
@@ -763,15 +840,23 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
// ---------- Customize tab ----------
-function CustomizeTab({ settings, update, themes }: {
+function CustomizeTab({ mode, settings, update, themes }: {
+ mode: "template" | "design" | "layout";
settings: CvVariantSettings;
update: (p: Partial) => void;
themes: CvTheme[];
}) {
+ const sidebarSections = settings.sidebarSections ?? ["contact", "skills", "languages"];
+ const toggleSidebarSection = (key: string) => update({
+ sidebarSections: sidebarSections.includes(key)
+ ? sidebarSections.filter((item) => item !== key)
+ : [...sidebarSections, key],
+ });
return (
-
+ {mode === "template" &&
Theme
+ Templates change presentation only. Your content and hidden-section choices stay intact.
{themes.map((t) => {
const active = t.id === settings.themeId;
@@ -781,9 +866,11 @@ function CustomizeTab({ settings, update, themes }: {
onClick={() => { if (!locked) update({ themeId: t.id }); }}
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: t.id }); } }}
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
-
- {t.swatches.map((s, i) => )}
-
+
+ {t.layout.startsWith("sidebar") && t.layout !== "sidebar-right" ? {[50, 72, 58, 68].map((w) => )} : null}
+ {[92, 74, 84, 64, 88, 78].map((w, index) => )}
+ {t.layout === "sidebar-right" ? {[64, 78, 52, 70, 58].map((w) => )} : null}
+
{t.name}
{t.category}
@@ -794,52 +881,62 @@ function CustomizeTab({ settings, update, themes }: {
);
})}
-
+ }
-
- Accent colour
- update({ accentColor: e.target.value })} />
- {settings.accentColor && }
-
+ {mode === "design" && <>
+ Colour
+
+ {["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
+ update({ accentColor: color })} sx={{ width: 34, height: 34, bgcolor: color, border: settings.accentColor === color ? "3px solid" : "1px solid", borderColor: settings.accentColor === color ? "text.primary" : "divider", "&:hover": { bgcolor: color } }} />
+ ))}
+
+ update({ accentColor: e.target.value })} style={{ width: 48, height: 48, border: 0, padding: 0, cursor: "pointer" }} />
+
+ {settings.accentColor && }
+
-
- Heading font
-
-
-
- Body font
-
-
+ Typography
+
+ Heading font
+ Body font
+
+ update({ baseFontSizePt: value })} />
+ update({ headingSizePt: value })} />
+ update({ lineHeight: value })} />
+ Heading treatment
+ Header treatment
+ Skills presentation
+ >}
-
- Density
-
-
-
- Page size
-
-
-
-
- update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
- update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
+ {mode === "layout" && <>
+ Document
+
+ Page size
+ Density
+ Language
+ Date format
+
+ Columns
+ update({ pageMarginMm: value })} />
+ update({ sectionGapMm: value })} />
+ update({ entryGapMm: value })} />
+ {(settings.layout === "sidebar-left" || settings.layout === "sidebar-right") &&
+ Sidebar content
+ update({ sidebarWidthMm: value })} />
+ {["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}
+ }
+
+ update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
+ update({ showIcons: e.target.checked })} />} label="Contact icons (supported templates)" />
+ >}
);
}
+function ControlSlider({ label, value, min, max, step, suffix = "", onChange }: { label: string; value: number; min: number; max: number; step: number; suffix?: string; onChange: (value: number) => void }) {
+ return {label}{Number(value.toFixed(2))}{suffix} onChange(next as number)} />;
+}
+
function AiToolsTab() {
const { toast } = useToast();
const { canUseAi } = useAccountPlan();
@@ -883,13 +980,21 @@ function AiToolsTab() {
))}
{result && (
-
-
- Suggestion
- } onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy
-
- {result}
-
+
+
+ Original
+ {text}
+
+
+ Suggested — review before using
+ setResult(event.target.value)} />
+
+
+
+ } onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy
+
+
+
)}
);
diff --git a/job-tracker-ui/src/views/CvBuilderPage.tsx b/job-tracker-ui/src/views/CvBuilderPage.tsx
index 14d2669..fec3cbb 100644
--- a/job-tracker-ui/src/views/CvBuilderPage.tsx
+++ b/job-tracker-ui/src/views/CvBuilderPage.tsx
@@ -2,30 +2,37 @@ import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
- Alert, Box, Button, Chip, IconButton, Menu, MenuItem, Paper, Stack, Typography,
+ Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import PublicIcon from "@mui/icons-material/Public";
-import { getApiErrorMessage } from "../api";
+import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
-import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
+import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
import { useDialogActions } from "../dialogs";
+import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
export default function CvBuilderPage() {
const navigate = useNavigate();
const { toast } = useToast();
- const { confirmAction } = useDialogActions();
+ const { confirmAction, promptForValue } = useDialogActions();
const [variants, setVariants] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
+ const [themes, setThemes] = useState([]);
+ const [createOpen, setCreateOpen] = useState(false);
+ const [newName, setNewName] = useState("Untitled CV");
+ const [newTheme, setNewTheme] = useState("modern");
+ const [creating, setCreating] = useState(false);
const load = async () => {
try {
setVariants(await cvBuilderApi.list());
+ try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
} catch (err) {
setError(getApiErrorMessage(err, "Could not load your CVs."));
} finally {
@@ -37,12 +44,19 @@ export default function CvBuilderPage() {
}, []);
const createNew = async () => {
+ setCreateOpen(true);
+ };
+
+ const confirmCreate = async () => {
+ if (!newName.trim()) return;
+ setCreating(true);
try {
- const variant = await cvBuilderApi.create({ name: "Untitled CV", settings: emptyCvVariantSettings() });
+ const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
+ setCreateOpen(false);
navigate(`/career/builder/${variant.id}`);
} catch (err) {
toast(getApiErrorMessage(err, "Could not create a CV."), "error");
- }
+ } finally { setCreating(false); }
};
const duplicate = async (id: number) => {
@@ -76,12 +90,38 @@ export default function CvBuilderPage() {
}
};
+ const rename = async (id: number) => {
+ const current = variants.find((item) => item.id === id);
+ setMenu(null);
+ const nextName = await promptForValue("Give this CV a clear name.", current?.name ?? "", { title: "Rename CV", confirmLabel: "Rename" });
+ if (!nextName?.trim() || nextName.trim() === current?.name) return;
+ try {
+ const variant = await cvBuilderApi.get(id);
+ const updated = await cvBuilderApi.save(id, { name: nextName.trim(), settings: variant.settings, source: "manual" });
+ setVariants((items) => items.map((item) => item.id === id ? { ...item, name: updated.name, updatedAtUtc: updated.updatedAtUtc, version: updated.version } : item));
+ toast("CV renamed.", "success");
+ } catch (err) { toast(getApiErrorMessage(err, "Rename failed."), "error"); }
+ };
+
+ const download = async (id: number, cvName: string) => {
+ setMenu(null);
+ try {
+ const response = await api.post(cvBuilderApi.exportPdfUrl(id), {}, { responseType: "blob" });
+ const url = URL.createObjectURL(response.data as Blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${cvName || "cv"}.pdf`;
+ link.click();
+ URL.revokeObjectURL(url);
+ } catch (err) { toast(getApiErrorMessage(err, "PDF download failed."), "error"); }
+ };
+
return (
-
+
CV Builder
- Build tailored CVs from your master profile. Content stays in your profile — each CV is a theme + a selection.
+ Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.
} onClick={createNew}>New CV
@@ -97,14 +137,14 @@ export default function CvBuilderPage() {
)}
-
+
{variants.map((v) => (
navigate(`/career/builder/${v.id}`)}
onKeyDown={(event) => {
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
@@ -112,19 +152,22 @@ export default function CvBuilderPage() {
navigate(`/career/builder/${v.id}`);
}
}}
- >
+ >
+ theme.id === v.themeId)} />
- {v.name}
+ {v.name}
{ e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
+
{v.isPublic && } label="Public" />}
- Updated {new Date(v.updatedAtUtc).toLocaleDateString()}
+ Updated {new Date(v.updatedAtUtc).toLocaleDateString()} · version {v.version}
+ {v.jobApplicationId ? ` · ${[v.jobTitle, v.companyName].filter(Boolean).join(" at ") || `job #${v.jobApplicationId}`}` : ""}
))}
@@ -133,8 +176,23 @@ export default function CvBuilderPage() {
+
+
);
}
diff --git a/job-tracker-ui/src/views/career/CareerProfileSections.tsx b/job-tracker-ui/src/views/career/CareerProfileSections.tsx
index 985a710..b2f147c 100644
--- a/job-tracker-ui/src/views/career/CareerProfileSections.tsx
+++ b/job-tracker-ui/src/views/career/CareerProfileSections.tsx
@@ -1,5 +1,7 @@
import React from "react";
-import { Box, Button, Chip, TextField, Typography } from "@mui/material";
+import { Box, Button, Chip, IconButton, Stack, TextField, Typography } from "@mui/material";
+import AddIcon from "@mui/icons-material/Add";
+import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import RichTextField from "../../components/RichTextField";
import { useI18n } from "../../i18n/I18nProvider";
@@ -87,6 +89,11 @@ export function PersonalInformationSection({
set({ website: e.target.value || undefined })} fullWidth />
set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
+ set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
+
+ Other links} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>Add link
+ {(value.links ?? []).map((link, index) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /> set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /> set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}>)}
+
);
}