feat(cv): rebuild professional resume studio
This commit is contained in:
@@ -27,7 +27,7 @@ public sealed class ApplicationAssetsTests
|
|||||||
.Setup(s => s.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
.Setup(s => s.ListAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync((string owner, CancellationToken _) => db.CvVariants
|
.ReturnsAsync((string owner, CancellationToken _) => db.CvVariants
|
||||||
.Where(v => v.OwnerUserId == owner)
|
.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());
|
.ToList());
|
||||||
|
|
||||||
var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService());
|
var intelligence = new ApplicationIntelligenceService(db, new JobCvMatchService());
|
||||||
|
|||||||
@@ -249,6 +249,54 @@ public sealed class CvBuilderTests
|
|||||||
Assert.Contains("#123456", result.Html);
|
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</p>", 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</a>", html);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Variant service (CRUD, autosave history, public, render) ----
|
// ---- Variant service (CRUD, autosave history, public, render) ----
|
||||||
|
|
||||||
private static (JobTrackerContext db, CvVariantService svc) NewService(string userId, string? dbName = null)
|
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);
|
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<ArgumentException>(() =>
|
||||||
|
svc.CreateAsync("user-1", "CV", foreignJob.Id, new CvVariantSettings(), default));
|
||||||
|
Assert.Empty(await db.CvVariants.IgnoreQueryFilters().ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Delete_removes_the_variant_and_its_versions()
|
public async Task Delete_removes_the_variant_and_its_versions()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ public sealed class MigrationChainTests
|
|||||||
'LastReminderEmailSentAt', 'RecruiterMessageDraft', 'SalaryMin', 'SalaryMax',
|
'LastReminderEmailSentAt', 'RecruiterMessageDraft', 'SalaryMin', 'SalaryMax',
|
||||||
'SalaryCurrency', 'SalaryPeriod');
|
'SalaryCurrency', 'SalaryPeriod');
|
||||||
"""));
|
"""));
|
||||||
|
Assert.Equal(1, await ScalarAsync<long>(connection, """
|
||||||
|
SELECT COUNT(*) FROM pragma_table_info('AspNetUsers')
|
||||||
|
WHERE name = 'EmailFollowUpRemindersEnabled' AND dflt_value IN ('1', 'true');
|
||||||
|
"""));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -18,13 +18,15 @@ public sealed class CvVariantController : ControllerBase
|
|||||||
private readonly ICvVariantService _variants;
|
private readonly ICvVariantService _variants;
|
||||||
private readonly ICvPdfExporter _pdf;
|
private readonly ICvPdfExporter _pdf;
|
||||||
private readonly ISummarizerService _ai;
|
private readonly ISummarizerService _ai;
|
||||||
|
private readonly IThemedCvRenderer _renderer;
|
||||||
|
|
||||||
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai)
|
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null)
|
||||||
{
|
{
|
||||||
_users = users;
|
_users = users;
|
||||||
_variants = variants;
|
_variants = variants;
|
||||||
_pdf = pdf;
|
_pdf = pdf;
|
||||||
_ai = ai;
|
_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);
|
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);
|
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<ActionResult<RenderDto>> 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
|
// 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.
|
// editable rows without duplicating the profile shape on the client.
|
||||||
[HttpGet("outline")]
|
[HttpGet("outline")]
|
||||||
@@ -88,6 +104,8 @@ public sealed class CvVariantController : ControllerBase
|
|||||||
return BadRequest("Unknown theme.");
|
return BadRequest("Unknown theme.");
|
||||||
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
|
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
|
||||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
|
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);
|
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
|
||||||
return Ok(ToDto(variant));
|
return Ok(ToDto(variant));
|
||||||
}
|
}
|
||||||
@@ -228,6 +246,7 @@ public sealed class CvVariantController : ControllerBase
|
|||||||
"shorten" => "Make the text more concise without losing meaning.",
|
"shorten" => "Make the text more concise without losing meaning.",
|
||||||
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
|
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
|
||||||
"grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.",
|
"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.",
|
"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.",
|
"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.",
|
"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<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
|
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
|
||||||
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
|
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)
|
private static CvRenderPerson Person(ApplicationUser user)
|
||||||
{
|
{
|
||||||
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||||
|
|||||||
@@ -20,6 +20,25 @@ public sealed class CvVariantSettings
|
|||||||
public string? Language { get; set; }
|
public string? Language { get; set; }
|
||||||
public string? Headline { get; set; } // override the contact headline for this variant
|
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<string>? SidebarSections { get; set; }
|
||||||
|
|
||||||
public bool ShowPhoto { get; set; }
|
public bool ShowPhoto { get; set; }
|
||||||
public bool ShowPageNumbers { get; set; }
|
public bool ShowPageNumbers { get; set; }
|
||||||
public bool ShowIcons { get; set; } = true;
|
public bool ShowIcons { get; set; } = true;
|
||||||
@@ -94,8 +113,36 @@ public static class CvVariantSettingsJson
|
|||||||
s ??= new CvVariantSettings();
|
s ??= new CvVariantSettings();
|
||||||
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
|
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
|
||||||
s.AccentColor = NormalizeColor(s.AccentColor);
|
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.HeadingFont = NormalizeFont(s.HeadingFont);
|
||||||
s.BodyFont = NormalizeFont(s.BodyFont);
|
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.Sections ??= new();
|
||||||
s.Overrides ??= new();
|
s.Overrides ??= new();
|
||||||
s.CustomSections ??= new();
|
s.CustomSections ??= new();
|
||||||
@@ -117,4 +164,15 @@ public static class CvVariantSettingsJson
|
|||||||
var candidate = value?.Trim();
|
var candidate = value?.Trim();
|
||||||
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ public sealed class StructuredCvContact
|
|||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
public string? Website { get; set; }
|
public string? Website { get; set; }
|
||||||
public string? LinkedIn { get; set; }
|
public string? LinkedIn { get; set; }
|
||||||
|
public string? GitHub { get; set; }
|
||||||
|
public List<StructuredCvLink> Links { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class StructuredCvLink
|
||||||
|
{
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public string? Url { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StructuredCvJob
|
public sealed class StructuredCvJob
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ public static class StructuredCvProfileJson
|
|||||||
profile.Contact.Location = TrimOrNull(profile.Contact.Location);
|
profile.Contact.Location = TrimOrNull(profile.Contact.Location);
|
||||||
profile.Contact.Website = TrimOrNull(profile.Contact.Website);
|
profile.Contact.Website = TrimOrNull(profile.Contact.Website);
|
||||||
profile.Contact.LinkedIn = TrimOrNull(profile.Contact.LinkedIn);
|
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.Summary = CleanList(profile.Summary);
|
||||||
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
|
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
|
||||||
@@ -213,6 +215,11 @@ public static class StructuredCvProfileJson
|
|||||||
primary.Contact.Location ??= secondary.Contact.Location;
|
primary.Contact.Location ??= secondary.Contact.Location;
|
||||||
primary.Contact.Website ??= secondary.Contact.Website;
|
primary.Contact.Website ??= secondary.Contact.Website;
|
||||||
primary.Contact.LinkedIn ??= secondary.Contact.LinkedIn;
|
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
|
primary.Summary = primary.Summary.Count == 0
|
||||||
? secondary.Summary
|
? secondary.Summary
|
||||||
@@ -393,9 +400,20 @@ public static class StructuredCvProfileJson
|
|||||||
contact.Location = NormalizeLocationValue(contact.Location);
|
contact.Location = NormalizeLocationValue(contact.Location);
|
||||||
contact.Website = NormalizeWebsite(contact.Website);
|
contact.Website = NormalizeWebsite(contact.Website);
|
||||||
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
|
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
|
||||||
|
contact.GitHub = NormalizeExternalLink(contact.GitHub);
|
||||||
|
contact.Links = NormalizeLinks(contact.Links);
|
||||||
return contact;
|
return contact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<StructuredCvLink> NormalizeLinks(List<StructuredCvLink>? links) =>
|
||||||
|
(links ?? new List<StructuredCvLink>())
|
||||||
|
.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)
|
private static StructuredCvJob NormalizeJob(StructuredCvJob? job)
|
||||||
{
|
{
|
||||||
job ??= new StructuredCvJob();
|
job ??= new StructuredCvJob();
|
||||||
@@ -537,6 +555,16 @@ public static class StructuredCvProfileJson
|
|||||||
return $"https://www.linkedin.com{path}";
|
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)
|
private static string? NormalizeDateValue(string? value)
|
||||||
{
|
{
|
||||||
var trimmed = TrimOrNull(value);
|
var trimmed = TrimOrNull(value);
|
||||||
@@ -726,6 +754,8 @@ public static class StructuredCvProfileJson
|
|||||||
AddIf(contactLines, profile.Contact.Location);
|
AddIf(contactLines, profile.Contact.Location);
|
||||||
AddIf(contactLines, profile.Contact.Website);
|
AddIf(contactLines, profile.Contact.Website);
|
||||||
AddIf(contactLines, profile.Contact.LinkedIn);
|
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, "Contact", contactLines);
|
||||||
AddSectionIfAny(sections, "Professional Summary", profile.Summary);
|
AddSectionIfAny(sections, "Professional Summary", profile.Summary);
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ public sealed class AccountDataExportService(
|
|||||||
user.StripeLastEventCreatedUtc,
|
user.StripeLastEventCreatedUtc,
|
||||||
user.AiEnabled,
|
user.AiEnabled,
|
||||||
user.ExternalAiProcessingAllowed,
|
user.ExternalAiProcessingAllowed,
|
||||||
|
user.EmailFollowUpRemindersEnabled,
|
||||||
Roles = roles,
|
Roles = roles,
|
||||||
Claims = claims,
|
Claims = claims,
|
||||||
ExternalLogins = logins,
|
ExternalLogins = logins,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
@@ -64,10 +65,17 @@ public static class CvVariantResolver
|
|||||||
};
|
};
|
||||||
|
|
||||||
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
|
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, "location", profile.Contact.Location, null);
|
||||||
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
|
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
|
||||||
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, 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<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
|
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -84,6 +92,7 @@ public static class CvVariantResolver
|
|||||||
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
|
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
|
||||||
["references"] = BulletSection("references", "References", profile.References),
|
["references"] = BulletSection("references", "References", profile.References),
|
||||||
};
|
};
|
||||||
|
ApplyLanguageLabels(built, settings.Language);
|
||||||
|
|
||||||
// OtherSections from the master profile become body sections keyed other:<n>.
|
// OtherSections from the master profile become body sections keyed other:<n>.
|
||||||
for (var i = 0; i < profile.OtherSections.Count; i++)
|
for (var i = 0; i < profile.OtherSections.Count; i++)
|
||||||
@@ -160,7 +169,7 @@ public static class CvVariantResolver
|
|||||||
Key = job.Id,
|
Key = job.Id,
|
||||||
Title = Trim(ov?.Title) ?? Trim(job.Title),
|
Title = Trim(ov?.Title) ?? Trim(job.Title),
|
||||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
|
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),
|
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
|
||||||
Tags = Clean(job.Skills),
|
Tags = Clean(job.Skills),
|
||||||
});
|
});
|
||||||
@@ -181,7 +190,7 @@ public static class CvVariantResolver
|
|||||||
Key = ed.Id,
|
Key = ed.Id,
|
||||||
Title = Trim(ov?.Title) ?? title,
|
Title = Trim(ov?.Title) ?? title,
|
||||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
|
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),
|
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -200,7 +209,7 @@ public static class CvVariantResolver
|
|||||||
Key = pr.Id,
|
Key = pr.Id,
|
||||||
Title = Trim(ov?.Title) ?? Trim(pr.Name),
|
Title = Trim(ov?.Title) ?? Trim(pr.Name),
|
||||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
|
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),
|
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
|
||||||
Tags = Clean(pr.Skills),
|
Tags = Clean(pr.Skills),
|
||||||
});
|
});
|
||||||
@@ -279,13 +288,45 @@ public static class CvVariantResolver
|
|||||||
return string.IsNullOrWhiteSpace(joined) ? null : joined;
|
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 s = FormatDate(Trim(start), settings);
|
||||||
var e = Trim(end);
|
var e = FormatDate(Trim(end), settings);
|
||||||
if (s is null && e is null) return null;
|
if (s is null && e is null) return null;
|
||||||
if (s is null) return e;
|
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<string, CvRenderSection> sections, string? language)
|
||||||
|
{
|
||||||
|
if (!IsNorwegian(language)) return;
|
||||||
|
var labels = new Dictionary<string, string>(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<string> Clean(IEnumerable<string>? items) =>
|
private static List<string> Clean(IEnumerable<string>? items) =>
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
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);
|
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
|
// 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
|
public interface ICvVariantService
|
||||||
{
|
{
|
||||||
Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct);
|
Task<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct);
|
||||||
|
Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||||
Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct);
|
Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct);
|
||||||
Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
|
Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct);
|
||||||
Task<CvVariant?> SaveAsync(string ownerUserId, int id, string? name, CvVariantSettings settings, string source, CancellationToken ct);
|
Task<CvVariant?> 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<IReadOnlyList<CvVariantSummary>> ListAsync(string ownerUserId, CancellationToken ct)
|
public async Task<IReadOnlyList<CvVariantSummary>> 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()
|
var variants = _db.Database.IsSqlite()
|
||||||
? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList()
|
? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList()
|
||||||
: await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
|
: await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
|
||||||
return variants.Select(Summarize).ToList();
|
return variants.Select(Summarize).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> CanAssociateJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
||||||
|
_db.JobApplications.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct);
|
||||||
|
|
||||||
public Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
|
public Task<CvVariant?> GetAsync(string ownerUserId, int id, CancellationToken ct) =>
|
||||||
_db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
|
_db.CvVariants.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == ownerUserId, ct);
|
||||||
|
|
||||||
public async Task<CvVariant> CreateAsync(string ownerUserId, string? name, int? jobApplicationId, CvVariantSettings? settings, CancellationToken ct)
|
public async Task<CvVariant> 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 now = DateTimeOffset.UtcNow;
|
||||||
var normalized = CvVariantSettingsJson.Normalize(settings);
|
var normalized = CvVariantSettingsJson.Normalize(settings);
|
||||||
var variant = new CvVariant
|
var variant = new CvVariant
|
||||||
@@ -110,7 +130,11 @@ public sealed class CvVariantService : ICvVariantService
|
|||||||
var source = await GetAsync(ownerUserId, id, ct);
|
var source = await GetAsync(ownerUserId, id, ct);
|
||||||
if (source is null) return null;
|
if (source is null) return null;
|
||||||
var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson);
|
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<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
|
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
|
||||||
@@ -202,7 +226,18 @@ public sealed class CvVariantService : ICvVariantService
|
|||||||
private static CvVariantSummary Summarize(CvVariant v)
|
private static CvVariantSummary Summarize(CvVariant v)
|
||||||
{
|
{
|
||||||
var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson);
|
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();
|
private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim();
|
||||||
|
|||||||
@@ -23,30 +23,34 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
|||||||
settings = CvVariantSettingsJson.Normalize(settings);
|
settings = CvVariantSettingsJson.Normalize(settings);
|
||||||
var accent = Override(settings.AccentColor, theme.Accent);
|
var accent = Override(settings.AccentColor, theme.Accent);
|
||||||
var headerInk = ContrastInk(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 headingFont = Override(settings.HeadingFont, theme.HeadingFont);
|
||||||
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
|
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
|
||||||
var density = DensityScale(settings.Density);
|
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 pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4";
|
||||||
var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm");
|
var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm");
|
||||||
var showIcons = settings.ShowIcons && theme.DefaultIcons;
|
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
|
var (sidebarHtml, mainHtml) = twoColumn
|
||||||
? SplitColumns(model, theme, showIcons)
|
? SplitColumns(model, theme, sidebarSections, settings)
|
||||||
: (string.Empty, RenderSections(model.Sections, theme));
|
: (string.Empty, RenderSections(model.Sections, theme, settings));
|
||||||
|
|
||||||
var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
|
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);
|
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
|
||||||
var body = theme.Layout switch
|
var body = layout switch
|
||||||
{
|
{
|
||||||
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons)}<section class=""main"">{mainHtml}</section></div>",
|
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}<section class=""main"">{mainHtml}</section></div>",
|
||||||
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons)}</div>",
|
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}</div>",
|
||||||
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
|
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
|
||||||
};
|
};
|
||||||
// For two-column themes the header renders inside the sidebar; single/header-band render it on top.
|
|
||||||
var page = twoColumn ? body : body;
|
|
||||||
|
|
||||||
var html = $@"<!DOCTYPE html>
|
var html = $@"<!DOCTYPE html>
|
||||||
<html lang=""{Attr(settings.Language ?? "en")}"">
|
<html lang=""{Attr(settings.Language ?? "en")}"">
|
||||||
<head>
|
<head>
|
||||||
@@ -55,7 +59,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
|||||||
<style>{css}</style>
|
<style>{css}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class=""page"">{page}</main>
|
<main class=""page"">{body}</main>
|
||||||
</body>
|
</body>
|
||||||
</html>";
|
</html>";
|
||||||
|
|
||||||
@@ -63,39 +67,37 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
|||||||
return new ThemedCvRenderResult(theme.Id, fileName, html);
|
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<string> sidebarKeys, CvVariantSettings settings)
|
||||||
{
|
{
|
||||||
var sidebarKeys = theme.SidebarSections;
|
|
||||||
var sidebar = new StringBuilder();
|
var sidebar = new StringBuilder();
|
||||||
var mainSections = new List<CvRenderSection>();
|
var mainSections = new List<CvRenderSection>();
|
||||||
foreach (var section in model.Sections)
|
foreach (var section in model.Sections)
|
||||||
{
|
{
|
||||||
var baseKey = section.Key.Contains(':') ? section.Key : section.Key;
|
if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase))
|
sidebar.Append(RenderSection(section, theme, settings));
|
||||||
sidebar.Append(RenderSection(section, theme));
|
|
||||||
else
|
else
|
||||||
mainSections.Add(section);
|
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<string> sidebarSections, string headerStyle)
|
||||||
{
|
{
|
||||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true);
|
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
|
||||||
var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
||||||
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
|
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
return $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
|
return $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
|
||||||
}
|
}
|
||||||
|
|
||||||
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 photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
|
||||||
var kicker = theme.HeaderStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
|
var kicker = headerStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
|
||||||
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
|
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
|
||||||
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
|
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
|
||||||
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
|
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 $@"<header class=""{headerClass}"">{photo}<div class=""head-text"">{kicker}{name}{headline}{contact}</div></header>";
|
return $@"<header class=""{headerClass}"">{photo}<div class=""head-text"">{kicker}{name}{headline}{contact}</div></header>";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,19 +120,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
|||||||
return $@"<div class=""contact {(sidebar ? "contact-stacked" : "contact-inline")}"">{items}</div>";
|
return $@"<div class=""contact {(sidebar ? "contact-stacked" : "contact-inline")}"">{items}</div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme)
|
private static string RenderSections(IEnumerable<CvRenderSection> sections, CvTheme theme, CvVariantSettings settings)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
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();
|
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;
|
if (section.IsEmpty) return string.Empty;
|
||||||
var inner = section.Kind switch
|
var inner = section.Kind switch
|
||||||
{
|
{
|
||||||
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
|
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
|
||||||
|
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>",
|
||||||
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
|
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
|
||||||
_ => string.Join("", section.Entries.Select(RenderEntry)),
|
_ => string.Join("", section.Entries.Select(RenderEntry)),
|
||||||
};
|
};
|
||||||
@@ -190,21 +193,25 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
|||||||
return s;
|
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 margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
|
||||||
var sectionGap = F(t.SectionGapMm * density);
|
var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
|
||||||
var entryGap = F(t.EntryGapMm * density);
|
var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
|
||||||
var headingCss = t.HeadingStyle switch
|
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;}}",
|
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
|
||||||
"plain" => ".section-title{letter-spacing:.01em;}",
|
"plain" => ".section-title{letter-spacing:.01em;}",
|
||||||
"bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}",
|
"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"
|
var columnTemplate = layout == "sidebar-right"
|
||||||
? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
|
? $"minmax(0,1fr) {F(sidebarWidth)}mm"
|
||||||
: $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)";
|
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
|
||||||
var layoutCss = twoColumn
|
var layoutCss = twoColumn
|
||||||
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
|
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
|
||||||
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
|
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
|
||||||
@@ -225,12 +232,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
|||||||
return $@"
|
return $@"
|
||||||
*{{box-sizing:border-box;}}
|
*{{box-sizing:border-box;}}
|
||||||
html,body{{min-width:0;}}
|
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;}}
|
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:{t.Paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
|
.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};}}
|
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;}}
|
.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,.main,.sidebar,.cols>*{{min-width:0;}}
|
||||||
.head-text{{flex:1;}}
|
.head-text{{flex:1;}}
|
||||||
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}}
|
.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-rounded{{border-radius:5mm;}}
|
||||||
.photo-circle{{border-radius:50%;}}
|
.photo-circle{{border-radius:50%;}}
|
||||||
.photo img{{width:100%;height:100%;object-fit:cover;display:block;}}
|
.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-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-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;}}
|
.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;}}
|
.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}}
|
||||||
.section{{margin-top:{sectionGap}mm;}}
|
.section{{margin-top:{sectionGap}mm;}}
|
||||||
.section:first-child{{margin-top:0;}}
|
.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}
|
{headingCss}
|
||||||
.bullets{{margin:0;padding-left:4.5mm;}}
|
.bullets{{margin:0;padding-left:4.5mm;}}
|
||||||
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
|
.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;}}
|
.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{{margin-bottom:{entryGap}mm;}}
|
||||||
.entry:last-child{{margin-bottom:0;}}
|
.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-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-title{{font-weight:700;font-size:{F(bodySize + 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-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:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
|
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
|
||||||
.entry-tags{{margin-top:1.4mm;}}
|
.entry-tags{{margin-top:1.4mm;}}
|
||||||
{layoutCss}
|
{layoutCss}
|
||||||
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
|
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
|
||||||
|
|||||||
@@ -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
|
`SettingsJson` blob (`CvVariantSettings`, `JobTrackerApi/Models/CvVariantSettings.cs`) because it is edited and saved
|
||||||
as a unit — never queried field-by-field. A variant stores:
|
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`.
|
- `Sections`: ordered list with per-section `Hidden` + optional renamed `Title`.
|
||||||
- `Overrides`: keyed by `ItemKey` → `{ Hidden, Title, Subtitle, Bullets }` (per-item, job-specific).
|
- `Overrides`: keyed by `ItemKey` → `{ Hidden, Title, Subtitle, Bullets }` (per-item, job-specific).
|
||||||
- `CustomSections`: variant-only sections not in the master profile.
|
- `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
|
## 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/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
|
`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`.
|
`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
|
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.
|
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)
|
## Builder workflow (frontend)
|
||||||
|
|
||||||
`/career/builder` lists variants (`CvBuilderPage`); the editor (`CvBuilderEditor`) is three tabs —
|
`/career/builder` lists variants (`CvBuilderPage`) with real rendered template thumbnails, language,
|
||||||
**Content**, **Customize**, **AI Tools** (plus **History**) — beside an always-on live preview that
|
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
|
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.
|
(`source: autosave`), appending a version each save; the header shows Unsaved / Saving / Saved.
|
||||||
`/cv/:slug` (`PublicCvPage`) renders a public CV in a sandboxed iframe.
|
`/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
|
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
|
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).
|
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
|
**Preview** displays separate labelled A4/Letter page sheets rather than one infinite document. It has
|
||||||
page count with prev/next navigation and page-break indicators, and an "updating…" chip. Three-page
|
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
|
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
|
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.
|
save the current variant before consuming the stored render. **Customize** badges ATS-friendly themes.
|
||||||
|
|||||||
@@ -24,15 +24,19 @@ which is retained only for the legacy tailored-draft flow and is not used by the
|
|||||||
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` |
|
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` |
|
||||||
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons` |
|
| 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,
|
The renderer computes CSS from these plus normalized variant overrides: printable palette, curated
|
||||||
density, page size, photo, icons) and picks one of the layout wrappers. `SidebarSections` decides which
|
fonts, body/heading size, line height, spacing, page size, layout, sidebar width/content, header and
|
||||||
section keys move to the sidebar for the two-column layouts.
|
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
|
## Adding a theme
|
||||||
|
|
||||||
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the
|
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the
|
||||||
fields that differ from the defaults.
|
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
|
Add a `CvBuilderTests.Every_catalog_theme_renders_valid_html` already loops the whole catalog, so a new
|
||||||
theme is smoke-tested automatically.
|
theme is smoke-tested automatically.
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
# CAREER-002 CV Builder redesign
|
# 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.
|
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
|
## Revalidated capability matrix
|
||||||
|
|
||||||
| Requirement | Existing implementation | Current status |
|
| Requirement | Existing implementation | Current status |
|
||||||
|
|||||||
@@ -310,6 +310,8 @@ test("Career Workspace loads from the authenticated application shell", async ({
|
|||||||
await page.getByRole("link", { name: "Open CV Builder" }).click();
|
await page.getByRole("link", { name: "Open CV Builder" }).click();
|
||||||
await expect(page).toHaveURL(/\/career\/builder$/);
|
await expect(page).toHaveURL(/\/career\/builder$/);
|
||||||
await page.getByRole("button", { name: "New CV" }).first().click();
|
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).toHaveURL(/\/career\/builder\/\d+$/);
|
||||||
await expect(page.getByLabel("CV name")).toBeVisible();
|
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]) {
|
for (const width of [375, 768, 1440]) {
|
||||||
await page.setViewportSize({ width, height: 900 });
|
await page.setViewportSize({ width, height: 900 });
|
||||||
await expect(page.getByLabel("CV name")).toBeVisible();
|
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);
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||||
expect(overflow).toBeLessThanOrEqual(1);
|
expect(overflow).toBeLessThanOrEqual(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ test("returning workspace shows missing profile information and recent general a
|
|||||||
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
expect(await screen.findByText("Backend CV")).toBeInTheDocument();
|
||||||
expect(screen.getAllByText("General CV").length).toBeGreaterThan(1);
|
expect(screen.getAllByText("General CV").length).toBeGreaterThan(1);
|
||||||
expect(screen.getByText("Job-specific")).toBeInTheDocument();
|
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([
|
test.each([
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
|
import { CvTheme, cvBuilderApi } from "../cvBuilder";
|
||||||
|
|
||||||
|
const previewCache = new Map<string, string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Box aria-hidden sx={{ height, bgcolor: "#dfe4ea", border: "1px solid", borderColor: "divider", borderRadius: 1.5, overflow: "hidden", display: "flex", justifyContent: "center" }}>
|
||||||
|
<Box sx={{ width: height * 210 / 297, height, bgcolor: "#fff", boxShadow: "0 5px 14px rgba(15,23,42,.15)", overflow: "hidden" }}>
|
||||||
|
<iframe title={`${theme?.name ?? "CV"} template preview`} srcDoc={previewHtml} sandbox="allow-same-origin" tabIndex={-1}
|
||||||
|
style={{ width: "210mm", height: "297mm", border: 0, display: "block", transform: `scale(${scale})`, transformOrigin: "top left", pointerEvents: "none" }} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = <Box sx={{ p: "7%", minWidth: 0 }}>
|
||||||
|
<Box sx={{ width: "48%", height: 5, borderRadius: 2, bgcolor: accent, mb: 1 }} />
|
||||||
|
{lines.map((width, index) => <Box key={index} sx={{ width: `${width}%`, height: index === 4 ? 4 : 2.5, borderRadius: 2, bgcolor: index === 4 ? accent : "rgba(51,65,85,.24)", mt: index === 4 ? 1.25 : 0, mb: 0.7 }} />)}
|
||||||
|
</Box>;
|
||||||
|
const side = <Box sx={{ bgcolor: sidebar, p: "13% 12%", minWidth: 0 }}>
|
||||||
|
<Box sx={{ width: 24, height: 24, borderRadius: theme?.photoShape === "circle" ? "50%" : 1, bgcolor: "rgba(255,255,255,.78)", border: "1px solid rgba(15,23,42,.1)", mb: 1.2 }} />
|
||||||
|
{[66, 86, 54, 74, 62, 79].map((width) => <Box key={width} sx={{ width: `${width}%`, height: 2.5, borderRadius: 2, bgcolor: "rgba(30,41,59,.3)", mb: 0.8 }} />)}
|
||||||
|
</Box>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box aria-hidden sx={{ height, bgcolor: paper, border: "1px solid", borderColor: "divider", borderRadius: 1.5, overflow: "hidden", boxShadow: "0 8px 20px rgba(15,23,42,.08)", display: "flex", flexDirection: "column" }}>
|
||||||
|
{!hasSidebar && <Box sx={{ height: layout === "header-band" ? "25%" : 8, bgcolor: layout === "header-band" ? accent : paper, borderBottom: layout === "header-band" ? 0 : `2px solid ${accent}`, p: layout === "header-band" ? "6%" : 0 }}>
|
||||||
|
{layout === "header-band" && <><Box sx={{ width: "42%", height: 5, bgcolor: "rgba(255,255,255,.9)", mb: 0.75 }} /><Box sx={{ width: "30%", height: 2.5, bgcolor: "rgba(255,255,255,.65)" }} /></>}
|
||||||
|
</Box>}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: hasSidebar ? (sidebarRight ? "1fr 32%" : "32% 1fr") : "1fr" }}>
|
||||||
|
{hasSidebar && !sidebarRight ? side : null}{body}{hasSidebar && sidebarRight ? side : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 () => {
|
test('internal navigation warns and can be cancelled before discarding a pending edit', async () => {
|
||||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||||
renderAt(3);
|
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?' });
|
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();
|
expect(within(dialog).getByText('This CV has unsaved changes. Leave and discard them?')).toBeInTheDocument();
|
||||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
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();
|
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const mockedApi = api as jest.Mocked<typeof api>;
|
|||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
@@ -53,7 +53,7 @@ beforeEach(() => {
|
|||||||
test('lists existing CVs from the variants API', async () => {
|
test('lists existing CVs from the variants API', async () => {
|
||||||
mockedApi.get.mockResolvedValueOnce({
|
mockedApi.get.mockResolvedValueOnce({
|
||||||
data: [
|
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);
|
} as any);
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ test('lists existing CVs from the variants API', async () => {
|
|||||||
|
|
||||||
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
|
expect(await screen.findByText('Frontend CV')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Frontend Engineer at Northstar/)).toBeInTheDocument();
|
||||||
const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' });
|
const cvCard = screen.getByRole('link', { name: 'Open Frontend CV' });
|
||||||
cvCard.focus();
|
cvCard.focus();
|
||||||
expect(cvCard).toHaveFocus();
|
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]);
|
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(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ name: 'Untitled CV' })));
|
||||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
|
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ export type CvVariantSettings = {
|
|||||||
dateFormat?: string | null;
|
dateFormat?: string | null;
|
||||||
language?: string | null;
|
language?: string | null;
|
||||||
headline?: 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;
|
showPhoto: boolean;
|
||||||
showPageNumbers: boolean;
|
showPageNumbers: boolean;
|
||||||
showIcons: boolean;
|
showIcons: boolean;
|
||||||
@@ -47,10 +63,13 @@ export type CvVariantSummary = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
|
language?: string | null;
|
||||||
publicSlug: string;
|
publicSlug: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
jobApplicationId: number | null;
|
jobApplicationId: number | null;
|
||||||
|
jobTitle?: string | null;
|
||||||
|
companyName?: string | null;
|
||||||
updatedAtUtc: string;
|
updatedAtUtc: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,6 +93,7 @@ export const AI_ACTIONS: { key: string; label: string }[] = [
|
|||||||
{ key: "shorten", label: "Shorten" },
|
{ key: "shorten", label: "Shorten" },
|
||||||
{ key: "expand", label: "Expand" },
|
{ key: "expand", label: "Expand" },
|
||||||
{ key: "grammar", label: "Fix grammar" },
|
{ key: "grammar", label: "Fix grammar" },
|
||||||
|
{ key: "impact", label: "Add measurable impact" },
|
||||||
{ key: "ats", label: "ATS optimise" },
|
{ key: "ats", label: "ATS optimise" },
|
||||||
{ key: "bullets", label: "Generate bullets" },
|
{ key: "bullets", label: "Generate bullets" },
|
||||||
{ key: "summary", label: "Generate summary" },
|
{ key: "summary", label: "Generate summary" },
|
||||||
@@ -82,7 +102,8 @@ export const AI_ACTIONS: { key: string; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_SECTION_ORDER = [
|
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<string, string> = {
|
export const SECTION_LABELS: Record<string, string> = {
|
||||||
@@ -94,6 +115,10 @@ export const SECTION_LABELS: Record<string, string> = {
|
|||||||
certifications: "Certifications",
|
certifications: "Certifications",
|
||||||
languages: "Languages",
|
languages: "Languages",
|
||||||
interests: "Interests",
|
interests: "Interests",
|
||||||
|
awards: "Awards",
|
||||||
|
publications: "Publications",
|
||||||
|
organisations: "Organisations",
|
||||||
|
references: "References",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CSS_PIXELS_PER_MM = 96 / 25.4;
|
const CSS_PIXELS_PER_MM = 96 / 25.4;
|
||||||
@@ -166,6 +191,7 @@ export function wrapSelection(
|
|||||||
|
|
||||||
export const cvBuilderApi = {
|
export const cvBuilderApi = {
|
||||||
themes: () => api.get<CvTheme[]>("/cv/themes").then((r) => r.data),
|
themes: () => api.get<CvTheme[]>("/cv/themes").then((r) => r.data),
|
||||||
|
themePreview: (themeId: string) => api.get<CvRender>(`/cv/themes/${encodeURIComponent(themeId)}/preview`).then((r) => r.data),
|
||||||
outline: () => api.get<CvOutline>("/cv/outline").then((r) => r.data),
|
outline: () => api.get<CvOutline>("/cv/outline").then((r) => r.data),
|
||||||
list: () => api.get<CvVariantSummary[]>("/cv/variants").then((r) => r.data),
|
list: () => api.get<CvVariantSummary[]>("/cv/variants").then((r) => r.data),
|
||||||
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
|
create: (body: { name?: string; jobApplicationId?: number | null; settings?: CvVariantSettings }) =>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const translations = {
|
|||||||
kanbanPageSubtitle: "Drag a card between stages to update its status.",
|
kanbanPageSubtitle: "Drag a card between stages to update its status.",
|
||||||
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
|
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
|
||||||
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
|
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
|
||||||
|
correspondenceInbox: "Job email",
|
||||||
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
|
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
|
||||||
account: "Account",
|
account: "Account",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
@@ -1192,6 +1193,7 @@ export const translations = {
|
|||||||
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
|
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
|
||||||
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
|
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
|
||||||
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
|
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.",
|
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
|
||||||
account: "Konto",
|
account: "Konto",
|
||||||
profile: "Profil",
|
profile: "Profil",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export type StructuredCvContact = {
|
|||||||
location?: string;
|
location?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
linkedIn?: string;
|
linkedIn?: string;
|
||||||
|
gitHub?: string;
|
||||||
|
links?: { label?: string; url?: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StructuredCvJob = {
|
export type StructuredCvJob = {
|
||||||
@@ -116,7 +118,7 @@ export function emptyStructuredCv(): StructuredCvProfile {
|
|||||||
return {
|
return {
|
||||||
version: "1",
|
version: "1",
|
||||||
metadata: { fields: {} },
|
metadata: { fields: {} },
|
||||||
contact: {},
|
contact: { links: [] },
|
||||||
summary: [],
|
summary: [],
|
||||||
jobs: [],
|
jobs: [],
|
||||||
education: [],
|
education: [],
|
||||||
@@ -232,6 +234,8 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
|||||||
location: normalizeString(source.contact?.location),
|
location: normalizeString(source.contact?.location),
|
||||||
website: normalizeString(source.contact?.website),
|
website: normalizeString(source.contact?.website),
|
||||||
linkedIn: normalizeString(source.contact?.linkedIn),
|
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),
|
summary: normalizeList(source.summary),
|
||||||
jobs: Array.isArray(source.jobs)
|
jobs: Array.isArray(source.jobs)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ test('public CV exposes the rendered CV and PDF download', async () => {
|
|||||||
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
mockedApi.get.mockResolvedValueOnce({ data: { html: '<p>Public CV</p>', name: 'Ada Lovelace' } } as any);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/cv/public-slug']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter initialEntries={['/cv/public-slug']}>
|
||||||
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
<Routes><Route path="/cv/:slug" element={<PublicCvPage />} /></Routes>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -225,10 +225,11 @@ export default function CareerProfilePage() {
|
|||||||
]);
|
]);
|
||||||
setMe(meResponse.data);
|
setMe(meResponse.data);
|
||||||
setProfileCvText(careerResponse.data?.cvText ?? "");
|
setProfileCvText(careerResponse.data?.cvText ?? "");
|
||||||
setStructuredCv(normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv()));
|
const profile = normalizeStructuredCv(careerResponse.data?.profile ?? emptyStructuredCv());
|
||||||
|
setStructuredCv(profile);
|
||||||
setCompleteness(careerResponse.data?.completeness ?? null);
|
setCompleteness(careerResponse.data?.completeness ?? null);
|
||||||
setProfileDirty(false);
|
setProfileDirty(false);
|
||||||
setHeadline(window.localStorage.getItem("profileHeadline") ?? "");
|
setHeadline(profile.contact.headline ?? "");
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMe(null);
|
setMe(null);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
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 {
|
import {
|
||||||
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
|
Alert, Box, Button, Chip, Collapse, Divider, FormControl, FormControlLabel, IconButton, InputLabel,
|
||||||
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
|
MenuItem, Paper, Select, Skeleton, Slider, Stack, Switch, Tab, Tabs, TextField, Tooltip, Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
|
||||||
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
|
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 ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||||
import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
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 { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
@@ -46,12 +49,19 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "
|
|||||||
const MIN_PREVIEW_ZOOM = 0.32;
|
const MIN_PREVIEW_ZOOM = 0.32;
|
||||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
|
||||||
|
|
||||||
|
function previewPageHtml(html: string, pageIndex: number, pageHeightPx: number): string {
|
||||||
|
const offset = pageIndex * pageHeightPx;
|
||||||
|
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}body{transform:translateY(-${offset}px);transform-origin:top left;}</style>`;
|
||||||
|
return html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CvBuilderEditor() {
|
export default function CvBuilderEditor() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const variantId = Number(id);
|
const variantId = Number(id);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction } = useDialogActions();
|
||||||
|
const compactEditor = useMediaQuery("(max-width:899.95px)");
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
|
const [settings, setSettings] = useState<CvVariantSettings | null>(null);
|
||||||
@@ -65,7 +75,6 @@ export default function CvBuilderEditor() {
|
|||||||
const [previewing, setPreviewing] = useState(false);
|
const [previewing, setPreviewing] = useState(false);
|
||||||
const [previewError, setPreviewError] = useState(false);
|
const [previewError, setPreviewError] = useState(false);
|
||||||
const [previewRevision, setPreviewRevision] = useState(0);
|
const [previewRevision, setPreviewRevision] = useState(0);
|
||||||
const [previewHeight, setPreviewHeight] = useState(() => getCvPageMetrics("a4").heightPx);
|
|
||||||
const [previewOverflow, setPreviewOverflow] = useState(false);
|
const [previewOverflow, setPreviewOverflow] = useState(false);
|
||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -74,6 +83,8 @@ export default function CvBuilderEditor() {
|
|||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
|
const [versions, setVersions] = useState<CvVariantVersionInfo[]>([]);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [mobilePane, setMobilePane] = useState<"edit" | "preview">("edit");
|
||||||
|
const [historyRevision, setHistoryRevision] = useState(0);
|
||||||
|
|
||||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const saveRevision = useRef(0);
|
const saveRevision = useRef(0);
|
||||||
@@ -85,6 +96,8 @@ export default function CvBuilderEditor() {
|
|||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const blockerPromptOpen = useRef(false);
|
const blockerPromptOpen = useRef(false);
|
||||||
|
const undoStack = useRef<CvVariantSettings[]>([]);
|
||||||
|
const redoStack = useRef<CvVariantSettings[]>([]);
|
||||||
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,6 +133,9 @@ export default function CvBuilderEditor() {
|
|||||||
setIsPublic(variant.isPublic);
|
setIsPublic(variant.isPublic);
|
||||||
setPublicSlug(variant.publicSlug);
|
setPublicSlug(variant.publicSlug);
|
||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
|
undoStack.current = [];
|
||||||
|
redoStack.current = [];
|
||||||
|
setHistoryRevision((value) => value + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debounced live preview.
|
// Debounced live preview.
|
||||||
@@ -189,6 +205,31 @@ export default function CvBuilderEditor() {
|
|||||||
setSettings((prev) => {
|
setSettings((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
const next = { ...prev, ...patch };
|
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);
|
scheduleSave(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -215,7 +256,6 @@ export default function CvBuilderEditor() {
|
|||||||
confirmLabel: "Discard and leave",
|
confirmLabel: "Discard and leave",
|
||||||
destructive: true,
|
destructive: true,
|
||||||
}).then((confirmed) => {
|
}).then((confirmed) => {
|
||||||
blockerPromptOpen.current = false;
|
|
||||||
if (confirmed) blocker.proceed();
|
if (confirmed) blocker.proceed();
|
||||||
else blocker.reset();
|
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 () => {
|
const loadVersions = async () => {
|
||||||
try {
|
try {
|
||||||
setVersions(await cvBuilderApi.versions(variantId));
|
setVersions(await cvBuilderApi.versions(variantId));
|
||||||
@@ -316,7 +370,6 @@ export default function CvBuilderEditor() {
|
|||||||
doc?.documentElement?.scrollHeight ?? 0,
|
doc?.documentElement?.scrollHeight ?? 0,
|
||||||
);
|
);
|
||||||
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
|
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
|
||||||
setPreviewHeight(h);
|
|
||||||
setPages(pageCount);
|
setPages(pageCount);
|
||||||
setPage((current) => Math.min(current, pageCount));
|
setPage((current) => Math.min(current, pageCount));
|
||||||
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
|
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
|
||||||
@@ -331,7 +384,8 @@ export default function CvBuilderEditor() {
|
|||||||
const goToPage = (p: number) => {
|
const goToPage = (p: number) => {
|
||||||
const clamped = Math.min(Math.max(1, p), pages);
|
const clamped = Math.min(Math.max(1, p), pages);
|
||||||
setPage(clamped);
|
setPage(clamped);
|
||||||
scrollRef.current?.scrollTo({ top: (clamped - 1) * pageMetrics.heightPx * zoom, behavior: "smooth" });
|
const target = scrollRef.current?.querySelector<HTMLElement>(`[data-cv-page="${clamped}"]`);
|
||||||
|
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const fitPreview = () => {
|
const fitPreview = () => {
|
||||||
@@ -349,38 +403,57 @@ export default function CvBuilderEditor() {
|
|||||||
}
|
}
|
||||||
if (!settings) return <EditorSkeleton />;
|
if (!settings) return <EditorSkeleton />;
|
||||||
|
|
||||||
|
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
|
||||||
|
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(360px, 460px) 1fr" }, gap: 2, alignItems: "start" }}>
|
<Stack spacing={1.5} sx={{ minWidth: 0 }}>
|
||||||
<Paper sx={{ p: 2, borderRadius: 4, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 24px)" }, overflowY: { md: "auto" } }}>
|
<Paper component="header" sx={{ px: { xs: 1, sm: 1.5 }, py: 1, borderRadius: 3, border: "1px solid", borderColor: "divider" }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
<Stack direction="row" alignItems="center" gap={1} flexWrap="wrap">
|
||||||
<Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
<Tooltip title="Back to CVs"><IconButton size="small" aria-label="Back to CVs" onClick={() => navigate("/career/builder")}><ArrowBackIcon fontSize="small" /></IconButton></Tooltip>
|
||||||
<TextField variant="standard" fullWidth value={name} onChange={(e) => renameVariant(e.target.value)}
|
<TextField variant="standard" value={name} onChange={(e) => renameVariant(e.target.value)}
|
||||||
error={!name.trim()} helperText={!name.trim() ? "Enter a name before saving." : undefined}
|
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" } }} />
|
slotProps={{ input: { style: { fontWeight: 800, fontSize: "1.05rem" } }, htmlInput: { "aria-label": "CV name" } }} />
|
||||||
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
<SaveBadge state={saveState} canRetry={!!name.trim()} onRetry={() => void retrySave()} />
|
||||||
</Stack>
|
<Divider orientation="vertical" flexItem sx={{ display: { xs: "none", sm: "block" } }} />
|
||||||
<Stack direction="row" spacing={1} sx={{ mb: 1, flexWrap: "wrap", gap: 1 }}>
|
<Tooltip title="Undo"><span><IconButton size="small" aria-label="Undo" disabled={!canUndo} onClick={undo}><UndoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||||
<Button size="small" variant="outlined" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Export PDF"}</Button>
|
<Tooltip title="Redo"><span><IconButton size="small" aria-label="Redo" disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||||
|
{compactEditor && <Stack direction="row" sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 0.25 }}>
|
||||||
|
<Button size="small" variant={mobilePane === "edit" ? "contained" : "text"} onClick={() => setMobilePane("edit")}>Edit</Button>
|
||||||
|
<Button size="small" variant={mobilePane === "preview" ? "contained" : "text"} onClick={() => setMobilePane("preview")}>Preview</Button>
|
||||||
|
</Stack>}
|
||||||
|
<Box sx={{ flex: { sm: 1 } }} />
|
||||||
|
<Button size="small" variant="text" startIcon={<ContentCopyIcon />} disabled={!name.trim()} onClick={() => void duplicateVariant()}>Duplicate</Button>
|
||||||
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
|
<Button size="small" variant={isPublic ? "contained" : "outlined"} startIcon={<PublicIcon />} disabled={publishing || exporting || !name.trim()} onClick={togglePublic}>
|
||||||
{publishing ? "Updating…" : isPublic ? "Public" : "Private"}
|
{publishing ? "Updating…" : isPublic ? "Public" : "Private"}
|
||||||
</Button>
|
</Button>
|
||||||
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
|
{isPublic && <Button size="small" startIcon={<ContentCopyIcon />} onClick={copyPublicLink}>Copy link</Button>}
|
||||||
|
<Button size="small" variant="contained" startIcon={<PictureAsPdfIcon />} disabled={exporting || publishing || !name.trim()} onClick={exportPdf}>{exporting ? "Exporting…" : "Download PDF"}</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 3) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(360px, 480px) minmax(0, 1fr)" }, gap: 1.5, alignItems: "start" }}>
|
||||||
|
<Paper sx={{ display: compactEditor && mobilePane !== "edit" ? "none" : "block", p: 2, borderRadius: 3, position: { md: "sticky" }, top: 12, minWidth: 0, maxHeight: { md: "calc(100vh - 104px)" }, overflowY: { md: "auto" }, border: "1px solid", borderColor: "divider" }}>
|
||||||
|
|
||||||
|
<Tabs value={tab} onChange={(_, v) => { setTab(v); if (v === 5) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ mb: 1.5, minHeight: 38 }}>
|
||||||
<Tab label="Content" />
|
<Tab label="Content" />
|
||||||
<Tab label="Customize" />
|
<Tab label="Template" />
|
||||||
<Tab label="AI Tools" />
|
<Tab label="Design" />
|
||||||
|
<Tab label="Layout" />
|
||||||
|
<Tab label="AI" />
|
||||||
<Tab label="History" />
|
<Tab label="History" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
|
{tab === 0 && <ContentTab settings={settings} update={update} outline={outline} />}
|
||||||
{tab === 1 && <CustomizeTab settings={settings} update={update} themes={themes} />}
|
{tab === 1 && <CustomizeTab mode="template" settings={settings} update={update} themes={themes} />}
|
||||||
{tab === 2 && <AiToolsTab />}
|
{tab === 2 && <CustomizeTab mode="design" settings={settings} update={update} themes={themes} />}
|
||||||
{tab === 3 && <HistoryTab versions={versions} onRestore={restore} />}
|
{tab === 3 && <CustomizeTab mode="layout" settings={settings} update={update} themes={themes} />}
|
||||||
|
{tab === 4 && <AiToolsTab />}
|
||||||
|
{tab === 5 && <HistoryTab versions={versions} onRestore={restore} />}
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper sx={{ p: 1.5, borderRadius: 4, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
<Paper sx={{ display: compactEditor && mobilePane !== "preview" ? "none" : "block", p: 1.5, borderRadius: 3, bgcolor: "action.hover", border: "1px solid", borderColor: "divider", minWidth: 0 }}>
|
||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1, px: 1, flexWrap: "wrap" }}>
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
|
<Typography variant="caption" sx={{ fontWeight: 700 }}>Live preview</Typography>
|
||||||
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
|
{previewing && <Chip size="small" label="updating…" variant="outlined" />}
|
||||||
@@ -402,30 +475,27 @@ export default function CvBuilderEditor() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
|
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
|
||||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
|
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
|
||||||
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", display: "flex", justifyContent: "center", p: 1 }}>
|
<iframe ref={iframeRef} title="CV preview" srcDoc={html} sandbox="allow-same-origin" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
|
||||||
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `${previewHeight * zoom}px`, flex: "0 0 auto" }}>
|
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
|
||||||
<iframe
|
<Stack spacing={3} alignItems="center">
|
||||||
ref={iframeRef}
|
{Array.from({ length: pages }).map((_, index) => (
|
||||||
title="CV preview"
|
<Box key={index} data-cv-page={index + 1} sx={{ flex: "0 0 auto" }}>
|
||||||
srcDoc={html}
|
<Typography variant="caption" sx={{ display: "block", mb: 0.75, color: "#475569", fontWeight: 700 }}>Page {index + 1}</Typography>
|
||||||
sandbox="allow-same-origin"
|
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
|
||||||
onLoad={onIframeLoad}
|
<iframe
|
||||||
style={{
|
title={`CV preview page ${index + 1}`}
|
||||||
width: `${pageMetrics.widthMm}mm`, height: `${previewHeight}px`, border: "none",
|
srcDoc={previewPageHtml(html, index, pageMetrics.heightPx)}
|
||||||
transform: `scale(${zoom})`, transformOrigin: "top left",
|
sandbox="allow-same-origin"
|
||||||
boxShadow: "0 8px 30px rgba(0,0,0,0.24)", background: "#fff", display: "block",
|
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
|
||||||
}}
|
/>
|
||||||
/>
|
</Box>
|
||||||
{Array.from({ length: Math.max(0, pages - 1) }).map((_, i) => (
|
</Box>
|
||||||
<Box key={i} aria-hidden sx={{
|
|
||||||
position: "absolute", left: 0, right: 0, top: `${(i + 1) * pageMetrics.heightPx * zoom}px`,
|
|
||||||
borderTop: "2px dashed", borderColor: "error.main", opacity: 0.72, pointerEvents: "none",
|
|
||||||
}} />
|
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,6 +548,10 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
|
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key }));
|
||||||
const have = new Set(base.map((s) => s.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 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) {
|
for (const custom of settings.customSections) {
|
||||||
const key = `custom:${custom.key}`;
|
const key = `custom:${custom.key}`;
|
||||||
if (!have.has(key)) {
|
if (!have.has(key)) {
|
||||||
@@ -486,7 +560,7 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}, [settings.customSections, settings.sections]);
|
}, [outline?.sections, settings.customSections, settings.sections]);
|
||||||
|
|
||||||
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
||||||
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
const sectionDrag = useDragReorder((from, to) => writeSections(moveItem(sectionRows, from, to)));
|
||||||
@@ -547,6 +621,9 @@ function ContentTab({ settings, update, outline }: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
<Alert severity="info" action={<Button component={RouterLink} to="/career/profile" size="small">Edit master profile</Button>}>
|
||||||
|
Contact details and career history are shared from your master profile. CV-specific headings, wording, order and visibility stay in this version.
|
||||||
|
</Alert>
|
||||||
<TextField label="Headline override" size="small" fullWidth value={settings.headline ?? ""}
|
<TextField label="Headline override" size="small" fullWidth value={settings.headline ?? ""}
|
||||||
onChange={(e) => update({ headline: e.target.value || null })}
|
onChange={(e) => update({ headline: e.target.value || null })}
|
||||||
helperText="Blank uses the headline from your master profile." />
|
helperText="Blank uses the headline from your master profile." />
|
||||||
@@ -763,15 +840,23 @@ function EntryEditor({ section, row, settings, onPatch, onUpdateSettings }: {
|
|||||||
|
|
||||||
// ---------- Customize tab ----------
|
// ---------- Customize tab ----------
|
||||||
|
|
||||||
function CustomizeTab({ settings, update, themes }: {
|
function CustomizeTab({ mode, settings, update, themes }: {
|
||||||
|
mode: "template" | "design" | "layout";
|
||||||
settings: CvVariantSettings;
|
settings: CvVariantSettings;
|
||||||
update: (p: Partial<CvVariantSettings>) => void;
|
update: (p: Partial<CvVariantSettings>) => void;
|
||||||
themes: CvTheme[];
|
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 (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Box>
|
{mode === "template" && <Box>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Theme</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>Templates change presentation only. Your content and hidden-section choices stay intact.</Typography>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
{themes.map((t) => {
|
{themes.map((t) => {
|
||||||
const active = t.id === settings.themeId;
|
const active = t.id === settings.themeId;
|
||||||
@@ -781,9 +866,11 @@ function CustomizeTab({ settings, update, themes }: {
|
|||||||
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
onClick={() => { if (!locked) update({ themeId: t.id }); }}
|
||||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); 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 } }}>
|
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 } }}>
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mb: 0.5 }}>
|
<Box sx={{ height: 86, mb: 1, bgcolor: t.swatches[2] || "#fff", border: "1px solid", borderColor: "divider", borderRadius: 1, overflow: "hidden", display: "grid", gridTemplateColumns: t.layout.startsWith("sidebar") ? (t.layout === "sidebar-right" ? "1fr 30%" : "30% 1fr") : "1fr" }}>
|
||||||
{t.swatches.map((s, i) => <Box key={i} sx={{ width: 14, height: 14, borderRadius: "3px", bgcolor: s, border: "1px solid rgba(0,0,0,0.1)" }} />)}
|
{t.layout.startsWith("sidebar") && t.layout !== "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}><Box sx={{ width: 18, height: 18, borderRadius: t.photoShape === "circle" ? "50%" : 0.5, bgcolor: "rgba(255,255,255,.75)", mb: 0.75 }} />{[50, 72, 58, 68].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(255,255,255,.6)", mb: 0.5 }} />)}</Box> : null}
|
||||||
</Stack>
|
<Box sx={{ p: 0.9 }}><Box sx={{ width: "55%", height: 5, bgcolor: t.swatches[0], mb: 0.75 }} />{[92, 74, 84, 64, 88, 78].map((w, index) => <Box key={index} sx={{ width: `${w}%`, height: index % 3 === 0 ? 3 : 2, bgcolor: index % 3 === 0 ? t.swatches[0] : "rgba(71,85,105,.28)", mb: 0.65 }} />)}</Box>
|
||||||
|
{t.layout === "sidebar-right" ? <Box sx={{ bgcolor: t.swatches[1], p: 0.75 }}>{[64, 78, 52, 70, 58].map((w) => <Box key={w} sx={{ width: `${w}%`, height: 2, bgcolor: "rgba(30,41,59,.34)", mb: 0.6 }} />)}</Box> : null}
|
||||||
|
</Box>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t.name}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{t.category}</Typography>
|
||||||
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5 }}>
|
||||||
@@ -794,52 +881,62 @@ function CustomizeTab({ settings, update, themes }: {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>}
|
||||||
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
{mode === "design" && <>
|
||||||
<Typography variant="body2" sx={{ flex: 1 }}>Accent colour</Typography>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Colour</Typography>
|
||||||
<input type="color" aria-label="Accent colour" value={settings.accentColor ?? "#2563eb"} onChange={(e) => update({ accentColor: e.target.value })} />
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||||
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Reset</Button>}
|
{["#3157d5", "#0f766e", "#9f1239", "#7c3aed", "#b45309", "#334155"].map((color) => (
|
||||||
</Stack>
|
<IconButton key={color} aria-label={`Use accent ${color}`} onClick={() => 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 } }} />
|
||||||
|
))}
|
||||||
|
<Box component="label" sx={{ width: 34, height: 34, borderRadius: "50%", border: "1px dashed", borderColor: "text.secondary", display: "grid", placeItems: "center", cursor: "pointer", overflow: "hidden" }}>
|
||||||
|
<input type="color" aria-label="Custom accent colour" value={settings.accentColor ?? "#3157d5"} onChange={(e) => update({ accentColor: e.target.value })} style={{ width: 48, height: 48, border: 0, padding: 0, cursor: "pointer" }} />
|
||||||
|
</Box>
|
||||||
|
{settings.accentColor && <Button size="small" onClick={() => update({ accentColor: null })}>Theme default</Button>}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<FormControl size="small" fullWidth>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mt: 0.5 }}>Typography</Typography>
|
||||||
<InputLabel>Heading font</InputLabel>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
<Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}>
|
<FormControl size="small" fullWidth><InputLabel>Heading font</InputLabel><Select label="Heading font" value={settings.headingFont ?? ""} onChange={(e) => update({ headingFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||||
<MenuItem value="">Theme default</MenuItem>
|
<FormControl size="small" fullWidth><InputLabel>Body font</InputLabel><Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}><MenuItem value="">Theme default</MenuItem>{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}</Select></FormControl>
|
||||||
{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}
|
</Box>
|
||||||
</Select>
|
<ControlSlider label="Body size" value={settings.baseFontSizePt ?? 10} min={7} max={13} step={0.25} suffix="pt" onChange={(value) => update({ baseFontSizePt: value })} />
|
||||||
</FormControl>
|
<ControlSlider label="Heading size" value={settings.headingSizePt ?? 12} min={9} max={20} step={0.5} suffix="pt" onChange={(value) => update({ headingSizePt: value })} />
|
||||||
<FormControl size="small" fullWidth>
|
<ControlSlider label="Line height" value={settings.lineHeight ?? 1.42} min={1.1} max={1.8} step={0.02} onChange={(value) => update({ lineHeight: value })} />
|
||||||
<InputLabel>Body font</InputLabel>
|
<FormControl size="small" fullWidth><InputLabel>Heading treatment</InputLabel><Select label="Heading treatment" value={settings.headingStyle ?? ""} onChange={(e) => update({ headingStyle: (e.target.value || null) as CvVariantSettings["headingStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="caps-rule">Uppercase divider</MenuItem><MenuItem value="underline">Underline</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="bar">Accent bar</MenuItem></Select></FormControl>
|
||||||
<Select label="Body font" value={settings.bodyFont ?? ""} onChange={(e) => update({ bodyFont: e.target.value || null })}>
|
<FormControl size="small" fullWidth><InputLabel>Header treatment</InputLabel><Select label="Header treatment" value={settings.headerStyle ?? ""} onChange={(e) => update({ headerStyle: (e.target.value || null) as CvVariantSettings["headerStyle"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="plain">Plain</MenuItem><MenuItem value="band">Colour band</MenuItem><MenuItem value="centered">Centred</MenuItem><MenuItem value="kicker">Editorial</MenuItem></Select></FormControl>
|
||||||
<MenuItem value="">Theme default</MenuItem>
|
<FormControl size="small" fullWidth><InputLabel>Skills presentation</InputLabel><Select inputProps={{ "aria-label": "Skills presentation" }} label="Skills presentation" value={settings.skillsStyle ?? "tags"} onChange={(e) => update({ skillsStyle: e.target.value as CvVariantSettings["skillsStyle"] })}><MenuItem value="tags">Tags</MenuItem><MenuItem value="text">Simple text</MenuItem></Select></FormControl>
|
||||||
{FONTS.map((f, i) => <MenuItem key={f} value={f}>{FONT_LABELS[i]}</MenuItem>)}
|
</>}
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormControl size="small" fullWidth>
|
{mode === "layout" && <>
|
||||||
<InputLabel>Density</InputLabel>
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Document</Typography>
|
||||||
<Select label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||||
<MenuItem value="compact">Compact</MenuItem>
|
<FormControl size="small" fullWidth><InputLabel>Page size</InputLabel><Select inputProps={{ "aria-label": "Page size" }} label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}><MenuItem value="a4">A4</MenuItem><MenuItem value="letter">US Letter</MenuItem></Select></FormControl>
|
||||||
<MenuItem value="balanced">Balanced</MenuItem>
|
<FormControl size="small" fullWidth><InputLabel>Density</InputLabel><Select inputProps={{ "aria-label": "Density" }} label="Density" value={settings.density ?? "balanced"} onChange={(e) => update({ density: e.target.value })}><MenuItem value="compact">Compact</MenuItem><MenuItem value="balanced">Balanced</MenuItem><MenuItem value="roomy">Roomy</MenuItem></Select></FormControl>
|
||||||
<MenuItem value="roomy">Roomy</MenuItem>
|
<FormControl size="small" fullWidth><InputLabel>Language</InputLabel><Select inputProps={{ "aria-label": "Language" }} label="Language" value={settings.language ?? "en"} onChange={(e) => update({ language: e.target.value })}><MenuItem value="en">English</MenuItem><MenuItem value="no">Norwegian</MenuItem></Select></FormControl>
|
||||||
</Select>
|
<FormControl size="small" fullWidth><InputLabel>Date format</InputLabel><Select inputProps={{ "aria-label": "Date format" }} label="Date format" value={settings.dateFormat ?? "short"} onChange={(e) => update({ dateFormat: e.target.value })}><MenuItem value="long">January 2020</MenuItem><MenuItem value="short">Jan 2020</MenuItem><MenuItem value="numeric">01/2020</MenuItem><MenuItem value="year">2020</MenuItem></Select></FormControl>
|
||||||
</FormControl>
|
</Box>
|
||||||
<FormControl size="small" fullWidth>
|
<FormControl size="small" fullWidth><InputLabel>Columns</InputLabel><Select inputProps={{ "aria-label": "Columns" }} label="Columns" value={settings.layout ?? ""} onChange={(e) => update({ layout: (e.target.value || null) as CvVariantSettings["layout"] })}><MenuItem value="">Template default</MenuItem><MenuItem value="single">One column</MenuItem><MenuItem value="header-band">One column with header band</MenuItem><MenuItem value="sidebar-left">Left sidebar</MenuItem><MenuItem value="sidebar-right">Right sidebar</MenuItem></Select></FormControl>
|
||||||
<InputLabel>Page size</InputLabel>
|
<ControlSlider label="Page margins" value={settings.pageMarginMm ?? 16} min={8} max={28} step={1} suffix="mm" onChange={(value) => update({ pageMarginMm: value })} />
|
||||||
<Select label="Page size" value={settings.pageSize ?? "a4"} onChange={(e) => update({ pageSize: e.target.value })}>
|
<ControlSlider label="Section spacing" value={settings.sectionGapMm ?? 6} min={2} max={14} step={0.5} suffix="mm" onChange={(value) => update({ sectionGapMm: value })} />
|
||||||
<MenuItem value="a4">A4</MenuItem>
|
<ControlSlider label="Entry spacing" value={settings.entryGapMm ?? 4.5} min={1} max={10} step={0.5} suffix="mm" onChange={(value) => update({ entryGapMm: value })} />
|
||||||
<MenuItem value="letter">Letter</MenuItem>
|
{(settings.layout === "sidebar-left" || settings.layout === "sidebar-right") && <Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||||
</Select>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Sidebar content</Typography>
|
||||||
</FormControl>
|
<ControlSlider label="Sidebar width" value={settings.sidebarWidthMm ?? 62} min={45} max={85} step={1} suffix="mm" onChange={(value) => update({ sidebarWidthMm: value })} />
|
||||||
|
<Stack>{["contact", "skills", "languages", "certifications", "projects", "interests"].map((key) => <FormControlLabel key={key} control={<Switch size="small" checked={sidebarSections.includes(key)} onChange={() => toggleSidebarSection(key)} />} label={SECTION_LABELS[key] ?? "Contact details"} />)}</Stack>
|
||||||
<Divider />
|
</Paper>}
|
||||||
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
|
<Divider />
|
||||||
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported themes)" />
|
<FormControlLabel control={<Switch checked={settings.showPhoto} onChange={(e) => update({ showPhoto: e.target.checked })} />} label="Show profile photo" />
|
||||||
|
<FormControlLabel control={<Switch checked={settings.showIcons} onChange={(e) => update({ showIcons: e.target.checked })} />} label="Contact icons (supported templates)" />
|
||||||
|
</>}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <Box><Stack direction="row" justifyContent="space-between" alignItems="baseline"><Typography variant="body2">{label}</Typography><Typography variant="caption" color="text.secondary">{Number(value.toFixed(2))}{suffix}</Typography></Stack><Slider size="small" aria-label={label} value={value} min={min} max={max} step={step} onChange={(_, next) => onChange(next as number)} /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
function AiToolsTab() {
|
function AiToolsTab() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { canUseAi } = useAccountPlan();
|
const { canUseAi } = useAccountPlan();
|
||||||
@@ -883,13 +980,21 @@ function AiToolsTab() {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
{result && (
|
{result && (
|
||||||
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
<Stack spacing={1}>
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 0.5 }}>
|
<Paper variant="outlined" sx={{ p: 1.5 }}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Suggestion</Typography>
|
<Typography variant="overline" color="text.secondary">Original</Typography>
|
||||||
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{text}</Typography>
|
||||||
</Stack>
|
</Paper>
|
||||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{result}</Typography>
|
<Paper variant="outlined" sx={{ p: 1.5, borderColor: "primary.main" }}>
|
||||||
</Paper>
|
<Typography variant="overline" color="primary.main">Suggested — review before using</Typography>
|
||||||
|
<TextField aria-label="Editable AI suggestion" multiline minRows={4} fullWidth variant="standard" value={result} onChange={(event) => setResult(event.target.value)} />
|
||||||
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }} flexWrap="wrap" useFlexGap>
|
||||||
|
<Button size="small" variant="contained" onClick={() => { setText(result); setResult(""); toast("Suggestion accepted into the working text.", "success"); }}>Accept</Button>
|
||||||
|
<Button size="small" onClick={() => setResult("")}>Reject</Button>
|
||||||
|
<Button size="small" startIcon={<ContentCopyIcon />} onClick={() => { navigator.clipboard?.writeText(result); toast("Copied.", "success"); }}>Copy</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,30 +2,37 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
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";
|
} from "@mui/material";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import PublicIcon from "@mui/icons-material/Public";
|
import PublicIcon from "@mui/icons-material/Public";
|
||||||
|
|
||||||
import { getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
|
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
|
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||||
|
|
||||||
export default function CvBuilderPage() {
|
export default function CvBuilderPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { confirmAction } = useDialogActions();
|
const { confirmAction, promptForValue } = useDialogActions();
|
||||||
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
|
const [variants, setVariants] = useState<CvVariantSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
|
const [menu, setMenu] = useState<{ anchor: HTMLElement; id: number } | null>(null);
|
||||||
|
const [themes, setThemes] = useState<CvTheme[]>([]);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("Untitled CV");
|
||||||
|
const [newTheme, setNewTheme] = useState("modern");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
setVariants(await cvBuilderApi.list());
|
setVariants(await cvBuilderApi.list());
|
||||||
|
try { setThemes(await cvBuilderApi.themes()); } catch { setThemes([]); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(getApiErrorMessage(err, "Could not load your CVs."));
|
setError(getApiErrorMessage(err, "Could not load your CVs."));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -37,12 +44,19 @@ export default function CvBuilderPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createNew = async () => {
|
const createNew = async () => {
|
||||||
|
setCreateOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCreate = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
try {
|
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}`);
|
navigate(`/career/builder/${variant.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(getApiErrorMessage(err, "Could not create a CV."), "error");
|
toast(getApiErrorMessage(err, "Could not create a CV."), "error");
|
||||||
}
|
} finally { setCreating(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const duplicate = async (id: number) => {
|
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 (
|
return (
|
||||||
<Box sx={{ display: "grid", gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2 }}>
|
||||||
<Paper sx={{ p: 2.5, borderRadius: 4, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 1 }}>
|
<Paper sx={{ p: { xs: 2, sm: 3 }, borderRadius: 3, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: 2, background: "linear-gradient(120deg, rgba(49,87,213,.09), transparent 58%)", border: "1px solid", borderColor: "divider" }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>CV Builder</Typography>
|
<Typography variant="h5" sx={{ fontWeight: 900 }}>CV Builder</Typography>
|
||||||
<Typography color="text.secondary">Build tailored CVs from your master profile. Content stays in your profile — each CV is a theme + a selection.</Typography>
|
<Typography color="text.secondary" sx={{ maxWidth: 720 }}>Build polished, job-specific resumes from one trusted career profile. Every version keeps its own template, content choices and history.</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={createNew}>New CV</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -97,14 +137,14 @@ export default function CvBuilderPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||||
{variants.map((v) => (
|
{variants.map((v) => (
|
||||||
<Paper
|
<Paper
|
||||||
key={v.id}
|
key={v.id}
|
||||||
role="link"
|
role="link"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Open ${v.name}`}
|
aria-label={`Open ${v.name}`}
|
||||||
sx={{ p: 2, borderRadius: 4, cursor: "pointer", "&:hover": { boxShadow: 4 }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
sx={{ p: 1.5, borderRadius: 3, cursor: "pointer", border: "1px solid", borderColor: "divider", transition: "transform 150ms ease, box-shadow 150ms ease", "&:hover": { boxShadow: 5, transform: "translateY(-2px)" }, "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main", outlineOffset: 2 } }}
|
||||||
onClick={() => navigate(`/career/builder/${v.id}`)}
|
onClick={() => navigate(`/career/builder/${v.id}`)}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
|
||||||
@@ -112,19 +152,22 @@ export default function CvBuilderPage() {
|
|||||||
navigate(`/career/builder/${v.id}`);
|
navigate(`/career/builder/${v.id}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<CvTemplateThumbnail theme={themes.find((theme) => theme.id === v.themeId)} />
|
||||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
<Stack direction="row" alignItems="flex-start" justifyContent="space-between">
|
||||||
<Typography sx={{ fontWeight: 800 }}>{v.name}</Typography>
|
<Typography sx={{ fontWeight: 800, mt: 1 }}>{v.name}</Typography>
|
||||||
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
<IconButton size="small" aria-label={`Actions for ${v.name}`} onClick={(e) => { e.stopPropagation(); setMenu({ anchor: e.currentTarget, id: v.id }); }}>
|
||||||
<MoreVertIcon fontSize="small" />
|
<MoreVertIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
<Chip size="small" label={v.themeId} />
|
<Chip size="small" label={v.themeId} />
|
||||||
|
<Chip size="small" variant="outlined" label={(v.language || "en").toUpperCase()} />
|
||||||
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label="Public" />}
|
{v.isPublic && <Chip size="small" color="primary" icon={<PublicIcon />} label="Public" />}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
|
||||||
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}`}` : ""}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
@@ -133,8 +176,23 @@ export default function CvBuilderPage() {
|
|||||||
<Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}>
|
<Menu anchorEl={menu?.anchor} open={!!menu} onClose={() => setMenu(null)}>
|
||||||
<MenuItem onClick={() => menu && navigate(`/career/builder/${menu.id}`)}>Open</MenuItem>
|
<MenuItem onClick={() => menu && navigate(`/career/builder/${menu.id}`)}>Open</MenuItem>
|
||||||
<MenuItem onClick={() => menu && duplicate(menu.id)}>Duplicate</MenuItem>
|
<MenuItem onClick={() => menu && duplicate(menu.id)}>Duplicate</MenuItem>
|
||||||
|
<MenuItem onClick={() => menu && void rename(menu.id)}>Rename</MenuItem>
|
||||||
|
<MenuItem onClick={() => { const cv = variants.find((item) => item.id === menu?.id); if (cv) void download(cv.id, cv.name); }}>Download PDF</MenuItem>
|
||||||
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete</MenuItem>
|
<MenuItem onClick={() => menu && remove(menu.id)} sx={{ color: "error.main" }}>Delete</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onClose={() => { if (!creating) setCreateOpen(false); }} fullWidth maxWidth="md" aria-labelledby="create-cv-title">
|
||||||
|
<DialogTitle id="create-cv-title">Create a CV</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 2 }}>Start with your saved career profile, choose a visual direction, then tailor what appears.</Typography>
|
||||||
|
<TextField autoFocus fullWidth label="CV name" value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? "Enter a name." : "For example: Backend Engineer — Acme"} sx={{ mb: 2 }} />
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Choose a template</Typography>
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr 1fr", sm: "repeat(4, 1fr)" }, gap: 1 }}>
|
||||||
|
{(themes.length ? themes.filter((theme) => theme.available) : [{ id: "modern", name: "Modern", category: "Professional", layout: "header-band", swatches: ["#3157d5", "#eef1f4", "#fff"] } as CvTheme]).map((theme) => <Paper key={theme.id} role="button" tabIndex={0} aria-pressed={newTheme === theme.id} onClick={() => setNewTheme(theme.id)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); setNewTheme(theme.id); } }} variant="outlined" sx={{ p: 0.75, cursor: "pointer", borderWidth: newTheme === theme.id ? 2 : 1, borderColor: newTheme === theme.id ? "primary.main" : "divider", "&:focus-visible": { outline: "3px solid", outlineColor: "primary.main" } }}><CvTemplateThumbnail theme={theme} height={105} /><Typography variant="body2" sx={{ fontWeight: 700, mt: 0.75 }}>{theme.name}</Typography><Typography variant="caption" color="text.secondary">{theme.category}</Typography></Paper>)}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>Cancel</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? "Creating…" : "Create CV"}</Button></DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import React from "react";
|
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 RichTextField from "../../components/RichTextField";
|
||||||
import { useI18n } from "../../i18n/I18nProvider";
|
import { useI18n } from "../../i18n/I18nProvider";
|
||||||
@@ -87,6 +89,11 @@ export function PersonalInformationSection({
|
|||||||
</Box>
|
</Box>
|
||||||
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
|
<TextField label={t("profileCvContactWebsite")} value={value.website ?? ""} onChange={(e) => set({ website: e.target.value || undefined })} fullWidth />
|
||||||
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
<TextField label={t("profileCvContactLinkedIn")} value={value.linkedIn ?? ""} onChange={(e) => set({ linkedIn: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||||
|
<TextField label="GitHub" value={value.gitHub ?? ""} onChange={(e) => set({ gitHub: e.target.value || undefined })} fullWidth sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }} />
|
||||||
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}><Typography variant="subtitle2">Other links</Typography><Button size="small" startIcon={<AddIcon />} onClick={() => set({ links: [...(value.links ?? []), { label: "", url: "" }] })}>Add link</Button></Stack>
|
||||||
|
<Stack spacing={1}>{(value.links ?? []).map((link, index) => <Stack key={index} direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ sm: "center" }}><TextField size="small" label="Label" value={link.label ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value || undefined } : item) })} sx={{ flex: "0 1 180px" }} /><TextField size="small" label="URL" value={link.url ?? ""} onChange={(event) => set({ links: (value.links ?? []).map((item, itemIndex) => itemIndex === index ? { ...item, url: event.target.value || undefined } : item) })} fullWidth /><IconButton aria-label={`Delete custom link ${index + 1}`} onClick={() => set({ links: (value.links ?? []).filter((_, itemIndex) => itemIndex !== index) })}><DeleteOutlineIcon fontSize="small" /></IconButton></Stack>)}</Stack>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user