feat(cv): rebuild professional resume studio
This commit is contained in:
@@ -18,13 +18,15 @@ public sealed class CvVariantController : ControllerBase
|
||||
private readonly ICvVariantService _variants;
|
||||
private readonly ICvPdfExporter _pdf;
|
||||
private readonly ISummarizerService _ai;
|
||||
private readonly IThemedCvRenderer _renderer;
|
||||
|
||||
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai)
|
||||
public CvVariantController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf, ISummarizerService ai, IThemedCvRenderer? renderer = null)
|
||||
{
|
||||
_users = users;
|
||||
_variants = variants;
|
||||
_pdf = pdf;
|
||||
_ai = ai;
|
||||
_renderer = renderer ?? new ThemedCvRenderer();
|
||||
}
|
||||
|
||||
public sealed record VariantDto(int Id, string Name, CvVariantSettings Settings, bool IsPublic, string PublicSlug, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
|
||||
@@ -61,6 +63,20 @@ public sealed class CvVariantController : ControllerBase
|
||||
return Ok(themes);
|
||||
}
|
||||
|
||||
// Render-only sample for visual template thumbnails. This in-memory content is never written to
|
||||
// the authenticated user's master profile or to a CV variant.
|
||||
[HttpGet("themes/{themeId}/preview")]
|
||||
public async Task<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
|
||||
// editable rows without duplicating the profile shape on the client.
|
||||
[HttpGet("outline")]
|
||||
@@ -88,6 +104,8 @@ public sealed class CvVariantController : ControllerBase
|
||||
return BadRequest("Unknown theme.");
|
||||
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro.");
|
||||
if (request?.JobApplicationId is int jobApplicationId && !await _variants.CanAssociateJobAsync(user.Id, jobApplicationId, ct))
|
||||
return BadRequest("The job application is unavailable.");
|
||||
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
|
||||
return Ok(ToDto(variant));
|
||||
}
|
||||
@@ -228,6 +246,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
"shorten" => "Make the text more concise without losing meaning.",
|
||||
"expand" => "Expand the text with more concrete, relevant detail — but never invent facts.",
|
||||
"grammar" => "Fix grammar, spelling and punctuation only. Keep wording and meaning.",
|
||||
"impact" => "Strengthen the text by identifying where the user could add measurable impact. Use clearly marked placeholders for missing numbers and never invent a metric or result.",
|
||||
"ats" => "Rewrite for ATS keyword optimisation: strong action verbs, quantified impact, clear phrasing. Do not invent facts.",
|
||||
"bullets" => "Rewrite as 3–5 tight achievement bullet points, each starting with a strong action verb. Do not invent facts.",
|
||||
"summary" => "Write a concise professional summary (2–3 sentences) from this text. Do not invent facts.",
|
||||
@@ -241,6 +260,34 @@ public sealed class CvVariantController : ControllerBase
|
||||
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
|
||||
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes);
|
||||
|
||||
private static CvRenderModel TemplatePreviewModel() => new()
|
||||
{
|
||||
FullName = "Alex Morgan",
|
||||
Headline = "Product & Platform Engineer",
|
||||
Contact =
|
||||
[
|
||||
new() { Icon = "email", Value = "alex@example.com", Href = "mailto:alex@example.com" },
|
||||
new() { Icon = "location", Value = "Oslo, Norway" },
|
||||
new() { Icon = "web", Value = "alexmorgan.dev", Href = "https://alexmorgan.dev" },
|
||||
],
|
||||
Sections =
|
||||
[
|
||||
new() { Key = "summary", Title = "Profile", Kind = "bullets", Bullets = ["Engineer focused on clear products, resilient systems and measurable delivery."] },
|
||||
new()
|
||||
{
|
||||
Key = "experience", Title = "Experience", Kind = "entries",
|
||||
Entries =
|
||||
[
|
||||
new() { Title = "Senior Platform Engineer", Subtitle = "Northstar Labs", Meta = "2022 – Present", Bullets = ["Led platform improvements across product teams.", "Reduced release lead time through safer automation."] },
|
||||
new() { Title = "Software Engineer", Subtitle = "Studio Works", Meta = "2019 – 2022", Bullets = ["Built accessible customer workflows and APIs."] },
|
||||
],
|
||||
},
|
||||
new() { Key = "skills", Title = "Skills", Kind = "tags", Tags = ["C#", "React", "Cloud", "Design systems", "Delivery"] },
|
||||
new() { Key = "education", Title = "Education", Kind = "entries", Entries = [new() { Title = "BSc Computer Science", Subtitle = "University of Oslo", Meta = "2016 – 2019" }] },
|
||||
new() { Key = "languages", Title = "Languages", Kind = "tags", Tags = ["English", "Norwegian"] },
|
||||
],
|
||||
};
|
||||
|
||||
private static CvRenderPerson Person(ApplicationUser user)
|
||||
{
|
||||
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
|
||||
@@ -20,6 +20,25 @@ public sealed class CvVariantSettings
|
||||
public string? Language { get; set; }
|
||||
public string? Headline { get; set; } // override the contact headline for this variant
|
||||
|
||||
// Normalized design overrides. Null keeps the selected theme's value, which means old variants
|
||||
// retain their exact appearance while new editor controls can be added without a schema change.
|
||||
public string? TextColor { get; set; }
|
||||
public string? MutedColor { get; set; }
|
||||
public string? HeadingColor { get; set; }
|
||||
public string? BackgroundColor { get; set; }
|
||||
public double? BaseFontSizePt { get; set; }
|
||||
public double? HeadingSizePt { get; set; }
|
||||
public double? LineHeight { get; set; }
|
||||
public double? PageMarginMm { get; set; }
|
||||
public double? SectionGapMm { get; set; }
|
||||
public double? EntryGapMm { get; set; }
|
||||
public string? HeadingStyle { get; set; } // caps-rule | underline | plain | bar
|
||||
public string? HeaderStyle { get; set; } // plain | band | centered | kicker
|
||||
public string? SkillsStyle { get; set; } // tags | text
|
||||
public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band
|
||||
public double? SidebarWidthMm { get; set; }
|
||||
public List<string>? SidebarSections { get; set; }
|
||||
|
||||
public bool ShowPhoto { get; set; }
|
||||
public bool ShowPageNumbers { get; set; }
|
||||
public bool ShowIcons { get; set; } = true;
|
||||
@@ -94,8 +113,36 @@ public static class CvVariantSettingsJson
|
||||
s ??= new CvVariantSettings();
|
||||
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
|
||||
s.AccentColor = NormalizeColor(s.AccentColor);
|
||||
s.TextColor = NormalizeColor(s.TextColor);
|
||||
s.MutedColor = NormalizeColor(s.MutedColor);
|
||||
s.HeadingColor = NormalizeColor(s.HeadingColor);
|
||||
s.BackgroundColor = NormalizeColor(s.BackgroundColor);
|
||||
s.HeadingFont = NormalizeFont(s.HeadingFont);
|
||||
s.BodyFont = NormalizeFont(s.BodyFont);
|
||||
s.BaseFontSizePt = Clamp(s.BaseFontSizePt, 7, 13);
|
||||
s.HeadingSizePt = Clamp(s.HeadingSizePt, 9, 20);
|
||||
s.LineHeight = Clamp(s.LineHeight, 1.1, 1.8);
|
||||
s.PageMarginMm = Clamp(s.PageMarginMm, 8, 28);
|
||||
s.SectionGapMm = Clamp(s.SectionGapMm, 2, 14);
|
||||
s.EntryGapMm = Clamp(s.EntryGapMm, 1, 10);
|
||||
s.SidebarWidthMm = Clamp(s.SidebarWidthMm, 45, 85);
|
||||
s.HeadingStyle = NormalizeChoice(s.HeadingStyle, "caps-rule", "underline", "plain", "bar");
|
||||
s.HeaderStyle = NormalizeChoice(s.HeaderStyle, "plain", "band", "centered", "kicker");
|
||||
s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text");
|
||||
s.Layout = NormalizeChoice(s.Layout, "single", "sidebar-left", "sidebar-right", "header-band");
|
||||
s.DateFormat = NormalizeChoice(s.DateFormat, "long", "short", "numeric", "year");
|
||||
if (!string.IsNullOrWhiteSpace(s.Language))
|
||||
{
|
||||
var language = s.Language.Trim().ToLowerInvariant();
|
||||
s.Language = language[..Math.Min(12, language.Length)];
|
||||
}
|
||||
else s.Language = null;
|
||||
s.SidebarSections = s.SidebarSections?
|
||||
.Where(key => !string.IsNullOrWhiteSpace(key))
|
||||
.Select(key => key.Trim().ToLowerInvariant())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
s.Sections ??= new();
|
||||
s.Overrides ??= new();
|
||||
s.CustomSections ??= new();
|
||||
@@ -117,4 +164,15 @@ public static class CvVariantSettingsJson
|
||||
var candidate = value?.Trim();
|
||||
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
private static double? Clamp(double? value, double min, double max) =>
|
||||
value is null || double.IsNaN(value.Value) || double.IsInfinity(value.Value)
|
||||
? null
|
||||
: Math.Clamp(value.Value, min, max);
|
||||
|
||||
private static string? NormalizeChoice(string? value, params string[] choices)
|
||||
{
|
||||
var candidate = value?.Trim().ToLowerInvariant();
|
||||
return candidate is not null && choices.Contains(candidate, StringComparer.Ordinal) ? candidate : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ public sealed class StructuredCvContact
|
||||
public string? Location { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? LinkedIn { get; set; }
|
||||
public string? GitHub { get; set; }
|
||||
public List<StructuredCvLink> Links { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvLink
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
public string? Url { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StructuredCvJob
|
||||
|
||||
@@ -94,6 +94,8 @@ public static class StructuredCvProfileJson
|
||||
profile.Contact.Location = TrimOrNull(profile.Contact.Location);
|
||||
profile.Contact.Website = TrimOrNull(profile.Contact.Website);
|
||||
profile.Contact.LinkedIn = TrimOrNull(profile.Contact.LinkedIn);
|
||||
profile.Contact.GitHub = NormalizeExternalLink(profile.Contact.GitHub);
|
||||
profile.Contact.Links = NormalizeLinks(profile.Contact.Links);
|
||||
|
||||
profile.Summary = CleanList(profile.Summary);
|
||||
profile.Jobs = (profile.Jobs ?? new List<StructuredCvJob>())
|
||||
@@ -213,6 +215,11 @@ public static class StructuredCvProfileJson
|
||||
primary.Contact.Location ??= secondary.Contact.Location;
|
||||
primary.Contact.Website ??= secondary.Contact.Website;
|
||||
primary.Contact.LinkedIn ??= secondary.Contact.LinkedIn;
|
||||
primary.Contact.GitHub ??= secondary.Contact.GitHub;
|
||||
primary.Contact.Links ??= new();
|
||||
foreach (var link in secondary.Contact.Links ?? new())
|
||||
if (!primary.Contact.Links.Any(existing => string.Equals(existing.Url, link.Url, StringComparison.OrdinalIgnoreCase)))
|
||||
primary.Contact.Links.Add(link);
|
||||
|
||||
primary.Summary = primary.Summary.Count == 0
|
||||
? secondary.Summary
|
||||
@@ -393,9 +400,20 @@ public static class StructuredCvProfileJson
|
||||
contact.Location = NormalizeLocationValue(contact.Location);
|
||||
contact.Website = NormalizeWebsite(contact.Website);
|
||||
contact.LinkedIn = NormalizeLinkedIn(contact.LinkedIn);
|
||||
contact.GitHub = NormalizeExternalLink(contact.GitHub);
|
||||
contact.Links = NormalizeLinks(contact.Links);
|
||||
return contact;
|
||||
}
|
||||
|
||||
private static List<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)
|
||||
{
|
||||
job ??= new StructuredCvJob();
|
||||
@@ -537,6 +555,16 @@ public static class StructuredCvProfileJson
|
||||
return $"https://www.linkedin.com{path}";
|
||||
}
|
||||
|
||||
private static string? NormalizeExternalLink(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
if (trimmed is null) return null;
|
||||
var candidate = trimmed.Contains("://", StringComparison.Ordinal) ? trimmed : $"https://{trimmed}";
|
||||
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) return null;
|
||||
if (uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host) || !string.IsNullOrEmpty(uri.UserInfo)) return null;
|
||||
return uri.AbsoluteUri.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string? NormalizeDateValue(string? value)
|
||||
{
|
||||
var trimmed = TrimOrNull(value);
|
||||
@@ -726,6 +754,8 @@ public static class StructuredCvProfileJson
|
||||
AddIf(contactLines, profile.Contact.Location);
|
||||
AddIf(contactLines, profile.Contact.Website);
|
||||
AddIf(contactLines, profile.Contact.LinkedIn);
|
||||
AddIf(contactLines, profile.Contact.GitHub);
|
||||
foreach (var link in profile.Contact.Links) AddIf(contactLines, link.Url);
|
||||
AddSectionIfAny(sections, "Contact", contactLines);
|
||||
AddSectionIfAny(sections, "Professional Summary", profile.Summary);
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class AccountDataExportService(
|
||||
user.StripeLastEventCreatedUtc,
|
||||
user.AiEnabled,
|
||||
user.ExternalAiProcessingAllowed,
|
||||
user.EmailFollowUpRemindersEnabled,
|
||||
Roles = roles,
|
||||
Claims = claims,
|
||||
ExternalLogins = logins,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
@@ -64,10 +65,17 @@ public static class CvVariantResolver
|
||||
};
|
||||
|
||||
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
|
||||
AddContact(model.Contact, "phone", profile.Contact.Phone, null);
|
||||
AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
|
||||
AddContact(model.Contact, "location", profile.Contact.Location, null);
|
||||
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
|
||||
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
|
||||
AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
|
||||
foreach (var link in profile.Contact.Links)
|
||||
{
|
||||
var url = Trim(link.Url);
|
||||
if (url is null) continue;
|
||||
model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
|
||||
}
|
||||
|
||||
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -84,6 +92,7 @@ public static class CvVariantResolver
|
||||
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
|
||||
["references"] = BulletSection("references", "References", profile.References),
|
||||
};
|
||||
ApplyLanguageLabels(built, settings.Language);
|
||||
|
||||
// OtherSections from the master profile become body sections keyed other:<n>.
|
||||
for (var i = 0; i < profile.OtherSections.Count; i++)
|
||||
@@ -160,7 +169,7 @@ public static class CvVariantResolver
|
||||
Key = job.Id,
|
||||
Title = Trim(ov?.Title) ?? Trim(job.Title),
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
|
||||
Meta = DateRange(job.Start, job.End, job.IsCurrent),
|
||||
Meta = DateRange(job.StartDate ?? job.Start, job.EndDate ?? job.End, job.IsCurrent, settings),
|
||||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
|
||||
Tags = Clean(job.Skills),
|
||||
});
|
||||
@@ -181,7 +190,7 @@ public static class CvVariantResolver
|
||||
Key = ed.Id,
|
||||
Title = Trim(ov?.Title) ?? title,
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
|
||||
Meta = DateRange(ed.Start, ed.End, false),
|
||||
Meta = DateRange(ed.StartDate ?? ed.Start, ed.EndDate ?? ed.End, false, settings),
|
||||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
|
||||
});
|
||||
}
|
||||
@@ -200,7 +209,7 @@ public static class CvVariantResolver
|
||||
Key = pr.Id,
|
||||
Title = Trim(ov?.Title) ?? Trim(pr.Name),
|
||||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
|
||||
Meta = DateRange(pr.Start, pr.End, false),
|
||||
Meta = DateRange(pr.StartDate ?? pr.Start, pr.EndDate ?? pr.End, false, settings),
|
||||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
|
||||
Tags = Clean(pr.Skills),
|
||||
});
|
||||
@@ -279,13 +288,45 @@ public static class CvVariantResolver
|
||||
return string.IsNullOrWhiteSpace(joined) ? null : joined;
|
||||
}
|
||||
|
||||
private static string? DateRange(string? start, string? end, bool isCurrent)
|
||||
private static string? DateRange(string? start, string? end, bool isCurrent, CvVariantSettings settings)
|
||||
{
|
||||
var s = Trim(start);
|
||||
var e = Trim(end);
|
||||
var s = FormatDate(Trim(start), settings);
|
||||
var e = FormatDate(Trim(end), settings);
|
||||
if (s is null && e is null) return null;
|
||||
if (s is null) return e;
|
||||
return $"{s} – {(isCurrent ? "Present" : e ?? "Present")}";
|
||||
var present = IsNorwegian(settings.Language) ? "nå" : "Present";
|
||||
return $"{s} – {(isCurrent ? present : e ?? present)}";
|
||||
}
|
||||
|
||||
private static string? FormatDate(string? value, CvVariantSettings settings)
|
||||
{
|
||||
if (value is null || settings.DateFormat is null) return value;
|
||||
if (!DateTime.TryParseExact(value, new[] { "yyyy-MM", "yyyy-M", "yyyy-MM-dd", "yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return value;
|
||||
var culture = IsNorwegian(settings.Language) ? CultureInfo.GetCultureInfo("nb-NO") : CultureInfo.GetCultureInfo("en-GB");
|
||||
return settings.DateFormat switch
|
||||
{
|
||||
"year" => date.ToString("yyyy", culture),
|
||||
"numeric" => date.ToString("MM/yyyy", culture),
|
||||
"long" => date.ToString("MMMM yyyy", culture),
|
||||
_ => date.ToString("MMM yyyy", culture),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsNorwegian(string? language) => language?.StartsWith("no", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| language?.StartsWith("nb", StringComparison.OrdinalIgnoreCase) == true
|
||||
|| language?.StartsWith("nn", StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
private static void ApplyLanguageLabels(Dictionary<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) =>
|
||||
|
||||
@@ -4,7 +4,18 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record CvVariantSummary(int Id, string Name, string ThemeId, string PublicSlug, bool IsPublic, int Version, int? JobApplicationId, DateTimeOffset UpdatedAtUtc);
|
||||
public sealed record CvVariantSummary(
|
||||
int Id,
|
||||
string Name,
|
||||
string ThemeId,
|
||||
string Language,
|
||||
string PublicSlug,
|
||||
bool IsPublic,
|
||||
int Version,
|
||||
int? JobApplicationId,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
string? JobTitle = null,
|
||||
string? CompanyName = null);
|
||||
public sealed record CvVariantVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
|
||||
|
||||
// Person + photo needed to render, resolved from ApplicationUser by the controller so this service
|
||||
@@ -14,6 +25,7 @@ public sealed record CvRenderPerson(string FallbackName, string? PhotoDataUrl);
|
||||
public interface ICvVariantService
|
||||
{
|
||||
Task<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> 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);
|
||||
@@ -49,18 +61,26 @@ public sealed class CvVariantService : ICvVariantService
|
||||
|
||||
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()
|
||||
? (await query.ToListAsync(ct)).OrderByDescending(x => x.UpdatedAtUtc).ToList()
|
||||
: await query.OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(ct);
|
||||
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) =>
|
||||
_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)
|
||||
{
|
||||
if (jobApplicationId is not null && !await CanAssociateJobAsync(ownerUserId, jobApplicationId.Value, ct))
|
||||
throw new ArgumentException("The job application is unavailable.", nameof(jobApplicationId));
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var normalized = CvVariantSettingsJson.Normalize(settings);
|
||||
var variant = new CvVariant
|
||||
@@ -110,7 +130,11 @@ public sealed class CvVariantService : ICvVariantService
|
||||
var source = await GetAsync(ownerUserId, id, ct);
|
||||
if (source is null) return null;
|
||||
var settings = CvVariantSettingsJson.Deserialize(source.SettingsJson);
|
||||
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, source.JobApplicationId, settings, ct);
|
||||
var associatedJobId = source.JobApplicationId is not null
|
||||
&& await CanAssociateJobAsync(ownerUserId, source.JobApplicationId.Value, ct)
|
||||
? source.JobApplicationId
|
||||
: null;
|
||||
return await CreateAsync(ownerUserId, string.IsNullOrWhiteSpace(newName) ? $"{source.Name} (copy)" : newName, associatedJobId, settings, ct);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string ownerUserId, int id, CancellationToken ct)
|
||||
@@ -202,7 +226,18 @@ public sealed class CvVariantService : ICvVariantService
|
||||
private static CvVariantSummary Summarize(CvVariant v)
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Deserialize(v.SettingsJson);
|
||||
return new CvVariantSummary(v.Id, v.Name, settings.ThemeId, v.PublicSlug, v.IsPublic, v.Version, v.JobApplicationId, v.UpdatedAtUtc);
|
||||
return new CvVariantSummary(
|
||||
v.Id,
|
||||
v.Name,
|
||||
settings.ThemeId,
|
||||
settings.Language ?? "en",
|
||||
v.PublicSlug,
|
||||
v.IsPublic,
|
||||
v.Version,
|
||||
v.JobApplicationId,
|
||||
v.UpdatedAtUtc,
|
||||
v.JobApplication?.JobTitle,
|
||||
v.JobApplication?.Company?.Name);
|
||||
}
|
||||
|
||||
private static string CleanName(string? name) => string.IsNullOrWhiteSpace(name) ? "Untitled CV" : name.Trim();
|
||||
|
||||
@@ -23,30 +23,34 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
settings = CvVariantSettingsJson.Normalize(settings);
|
||||
var accent = Override(settings.AccentColor, theme.Accent);
|
||||
var headerInk = ContrastInk(accent);
|
||||
var headingColor = theme.HeadingColor ?? accent;
|
||||
var ink = Override(settings.TextColor, theme.Ink);
|
||||
var muted = Override(settings.MutedColor, theme.Muted);
|
||||
var paper = Override(settings.BackgroundColor, theme.Paper);
|
||||
var headingColor = Override(settings.HeadingColor, theme.HeadingColor ?? accent);
|
||||
var headingFont = Override(settings.HeadingFont, theme.HeadingFont);
|
||||
var bodyFont = Override(settings.BodyFont, theme.BodyFont);
|
||||
var density = DensityScale(settings.Density);
|
||||
var layout = Override(settings.Layout, theme.Layout);
|
||||
var headingStyle = Override(settings.HeadingStyle, theme.HeadingStyle);
|
||||
var headerStyle = Override(settings.HeaderStyle, theme.HeaderStyle);
|
||||
var sidebarSections = settings.SidebarSections ?? theme.SidebarSections;
|
||||
var pageSize = string.Equals(Override(settings.PageSize, "a4"), "letter", StringComparison.OrdinalIgnoreCase) ? "Letter" : "A4";
|
||||
var pageDims = pageSize == "Letter" ? ("215.9mm", "279.4mm") : ("210mm", "297mm");
|
||||
var showIcons = settings.ShowIcons && theme.DefaultIcons;
|
||||
|
||||
var twoColumn = theme.Layout is "sidebar-left" or "sidebar-right";
|
||||
var twoColumn = layout is "sidebar-left" or "sidebar-right";
|
||||
var (sidebarHtml, mainHtml) = twoColumn
|
||||
? SplitColumns(model, theme, showIcons)
|
||||
: (string.Empty, RenderSections(model.Sections, theme));
|
||||
? SplitColumns(model, theme, sidebarSections, settings)
|
||||
: (string.Empty, RenderSections(model.Sections, theme, settings));
|
||||
|
||||
var css = BuildCss(theme, accent, headerInk, headingColor, headingFont, bodyFont, density, pageDims, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn);
|
||||
var body = theme.Layout switch
|
||||
var css = BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, headingFont, bodyFont, headingStyle, density, pageDims, layout, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
|
||||
var body = layout switch
|
||||
{
|
||||
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons)}<section class=""main"">{mainHtml}</section></div>",
|
||||
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons)}</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, sidebarSections, headerStyle)}</div>",
|
||||
_ => $@"{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>
|
||||
<html lang=""{Attr(settings.Language ?? "en")}"">
|
||||
<head>
|
||||
@@ -55,7 +59,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">{page}</main>
|
||||
<main class=""page"">{body}</main>
|
||||
</body>
|
||||
</html>";
|
||||
|
||||
@@ -63,39 +67,37 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
return new ThemedCvRenderResult(theme.Id, fileName, html);
|
||||
}
|
||||
|
||||
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, bool showIcons)
|
||||
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection<string> sidebarKeys, CvVariantSettings settings)
|
||||
{
|
||||
var sidebarKeys = theme.SidebarSections;
|
||||
var sidebar = new StringBuilder();
|
||||
var mainSections = new List<CvRenderSection>();
|
||||
foreach (var section in model.Sections)
|
||||
{
|
||||
var baseKey = section.Key.Contains(':') ? section.Key : section.Key;
|
||||
if (sidebarKeys.Contains(baseKey, StringComparer.OrdinalIgnoreCase))
|
||||
sidebar.Append(RenderSection(section, theme));
|
||||
if (sidebarKeys.Contains(section.Key, StringComparer.OrdinalIgnoreCase))
|
||||
sidebar.Append(RenderSection(section, theme, settings));
|
||||
else
|
||||
mainSections.Add(section);
|
||||
}
|
||||
return (sidebar.ToString(), RenderSections(mainSections, theme));
|
||||
return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
|
||||
}
|
||||
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons)
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle)
|
||||
{
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true);
|
||||
var contact = theme.SidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
|
||||
var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
||||
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
|
||||
: string.Empty;
|
||||
return $@"<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 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 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 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>";
|
||||
}
|
||||
|
||||
@@ -118,19 +120,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
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();
|
||||
foreach (var section in sections) sb.Append(RenderSection(section, theme));
|
||||
foreach (var section in sections) sb.Append(RenderSection(section, theme, settings));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string RenderSection(CvRenderSection section, CvTheme theme)
|
||||
private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
|
||||
{
|
||||
if (section.IsEmpty) return string.Empty;
|
||||
var inner = section.Kind switch
|
||||
{
|
||||
"bullets" => $@"<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>",
|
||||
_ => string.Join("", section.Entries.Select(RenderEntry)),
|
||||
};
|
||||
@@ -190,21 +193,25 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string BuildCss(CvTheme t, string accent, string headerInk, string headingColor, string headingFont, string bodyFont, double density, (string w, string h) page, bool twoColumn)
|
||||
private static string BuildCss(CvTheme t, CvVariantSettings settings, string accent, string headerInk, string ink, string muted, string paper, string headingColor, string headingFont, string bodyFont, string headingStyle, double density, (string w, string h) page, string layout, bool twoColumn)
|
||||
{
|
||||
var margin = F(t.PageMarginMm * density);
|
||||
var sectionGap = F(t.SectionGapMm * density);
|
||||
var entryGap = F(t.EntryGapMm * density);
|
||||
var headingCss = t.HeadingStyle switch
|
||||
var margin = F((settings.PageMarginMm ?? t.PageMarginMm) * density);
|
||||
var sectionGap = F((settings.SectionGapMm ?? t.SectionGapMm) * density);
|
||||
var entryGap = F((settings.EntryGapMm ?? t.EntryGapMm) * density);
|
||||
var bodySize = settings.BaseFontSizePt ?? t.BodySizePt;
|
||||
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
|
||||
var lineHeight = settings.LineHeight ?? t.LineHeight;
|
||||
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
|
||||
var headingCss = headingStyle switch
|
||||
{
|
||||
"underline" => $".section-title{{border-bottom:1.5px solid {t.Line};padding-bottom:1.5mm;}}",
|
||||
"plain" => ".section-title{letter-spacing:.01em;}",
|
||||
"bar" => $".section-title{{padding-left:2.5mm;border-left:3px solid {accent};}}",
|
||||
_ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(t.HeadingSizePt * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
|
||||
_ => $".section-title{{text-transform:uppercase;letter-spacing:.14em;font-size:{F(headingSize * 0.85)}pt;color:{accent};border-bottom:1px solid {t.Line};padding-bottom:1.4mm;}}",
|
||||
};
|
||||
var columnTemplate = t.Layout == "sidebar-right"
|
||||
? $"minmax(0,1fr) {F(t.SidebarWidthMm)}mm"
|
||||
: $"{F(t.SidebarWidthMm)}mm minmax(0,1fr)";
|
||||
var columnTemplate = layout == "sidebar-right"
|
||||
? $"minmax(0,1fr) {F(sidebarWidth)}mm"
|
||||
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
|
||||
var layoutCss = twoColumn
|
||||
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
|
||||
.sidebar{{background:{t.SidebarBg};color:{t.SidebarInk};padding:{margin}mm;}}
|
||||
@@ -225,12 +232,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
return $@"
|
||||
*{{box-sizing:border-box;}}
|
||||
html,body{{min-width:0;}}
|
||||
body{{margin:0;background:#e9edf2;color:{t.Ink};font-family:{bodyFont};font-size:{F(t.BodySizePt)}pt;line-height:{F(t.LineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
|
||||
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{t.Paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
|
||||
body{{margin:0;background:#e9edf2;color:{ink};font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
|
||||
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:{paper};overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
|
||||
h1,h2{{font-family:{headingFont};}}
|
||||
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{t.Ink};line-height:1.1;overflow-wrap:anywhere;}}
|
||||
.name{{margin:0;font-size:{F(t.NameSizePt)}pt;color:{ink};line-height:1.1;overflow-wrap:anywhere;}}
|
||||
.kicker{{text-transform:uppercase;letter-spacing:.3em;font-size:7.5pt;color:{accent};margin-bottom:1.5mm;}}
|
||||
.headline{{margin-top:1.5mm;color:{t.Muted};font-size:{F(t.BodySizePt + 0.5)}pt;}}
|
||||
.headline{{margin-top:1.5mm;color:{muted};font-size:{F(bodySize + 0.5)}pt;}}
|
||||
.head-text,.main,.sidebar,.cols>*{{min-width:0;}}
|
||||
.head-text{{flex:1;}}
|
||||
.photo{{width:30mm;height:30mm;overflow:hidden;flex:0 0 auto;border:1px solid {t.Line};}}
|
||||
@@ -238,7 +245,7 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.photo-rounded{{border-radius:5mm;}}
|
||||
.photo-circle{{border-radius:50%;}}
|
||||
.photo img{{width:100%;height:100%;object-fit:cover;display:block;}}
|
||||
.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;margin-top:2.5mm;}}
|
||||
.contact{{display:flex;gap:3mm;flex-wrap:wrap;color:{muted};font-size:{F(bodySize - 0.5)}pt;margin-top:2.5mm;}}
|
||||
.contact-stacked{{flex-direction:column;gap:1.8mm;}}
|
||||
.contact-item{{display:inline-flex;align-items:center;gap:1.2mm;min-width:0;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.contact a{{color:inherit;text-decoration:none;min-width:0;overflow-wrap:anywhere;word-break:break-word;}}
|
||||
@@ -247,18 +254,19 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.hero .name{{font-size:{F(t.NameSizePt - 3)}pt;}}
|
||||
.section{{margin-top:{sectionGap}mm;}}
|
||||
.section:first-child{{margin-top:0;}}
|
||||
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(t.HeadingSizePt)}pt;font-weight:700;color:{headingColor};}}
|
||||
.section-title{{margin:0 0 {F(2.6 * density)}mm 0;font-size:{F(headingSize)}pt;font-weight:700;color:{headingColor};}}
|
||||
{headingCss}
|
||||
.bullets{{margin:0;padding-left:4.5mm;}}
|
||||
.bullets li{{margin:0 0 {F(1.6 * density)}mm 0;}}
|
||||
.tags{{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:1.8mm;}}
|
||||
.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(t.BodySizePt - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.skills-text{{margin:0;overflow-wrap:anywhere;}}
|
||||
.tag{{border:1px solid {t.Line};border-radius:999px;padding:.7mm 2.2mm;font-size:{F(bodySize - 0.5)}pt;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.entry{{margin-bottom:{entryGap}mm;}}
|
||||
.entry:last-child{{margin-bottom:0;}}
|
||||
.entry-head{{display:flex;justify-content:space-between;gap:1.5mm 4mm;align-items:baseline;flex-wrap:wrap;break-after:avoid-page;page-break-after:avoid;}}
|
||||
.entry-title{{font-weight:700;font-size:{F(t.BodySizePt + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
|
||||
.entry-meta{{color:{t.Muted};font-size:{F(t.BodySizePt - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.entry-subtitle{{color:{t.Muted};font-size:{F(t.BodySizePt)}pt;margin:.4mm 0 1.2mm 0;}}
|
||||
.entry-title{{font-weight:700;font-size:{F(bodySize + 1)}pt;min-width:0;flex:1 1 50mm;overflow-wrap:anywhere;}}
|
||||
.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
|
||||
.entry-tags{{margin-top:1.4mm;}}
|
||||
{layoutCss}
|
||||
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
|
||||
|
||||
Reference in New Issue
Block a user