Compare commits

...

7 Commits

Author SHA1 Message Date
cesnimda dbb15804a3 fix(frontend): relax npm peer-dep resolution for react-scripts vs TS 5.9
CI and Deploy / test (pull_request) Successful in 2m6s
CI and Deploy / deploy (pull_request) Has been skipped
CI's npm ci (strict peer resolution) rejected the TypeScript 5.9 bump
from the Next.js migration: react-scripts still declares typescript
^3.2.1||^4 as a peer. Local `npm install` didn't catch this -- it
resolves peer conflicts leniently by default; only `npm ci` enforces
them. react-scripts is kept solely as the Jest test runner now (it
doesn't type-check), so relaxing this one peer constraint is safe.
2026-07-12 01:22:18 +02:00
cesnimda acf60c2a07 build(frontend): migrate CRA to Next.js (CSR lift-and-shift)
CI and Deploy / test (pull_request) Failing after 50s
CI and Deploy / deploy (pull_request) Has been skipped
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while
keeping the app's actual routing/rendering model unchanged -- the app
is almost entirely behind auth with no proven SSR/SEO need, so a real
App Router rewrite would touch ~90 files for zero user-visible benefit.

- next.config.js: output:'export' (static HTML+JS, same "single
  index.html served by nginx with try_files fallback" deploy as CRA).
- app/layout.tsx + app/page.tsx: root shell ports public/index.html's
  <head>, mounts the whole existing App tree client-only (ssr:false)
  since it reads window/localStorage during initial render and Next's
  static prerender would otherwise execute that on the server.
- Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects
  any `pages/` dir under the app root and tried to build our React
  Router page components as its own routes).
- REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development,
  Dockerfile, docker-compose.yml build args.
- Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported
  under Turbopack) with a small inline JobbjaktMark component.
- TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax
  4.9's parser rejects; CRA never hit this because babel doesn't
  type-check).
- Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts,
  public/index.html); kept react-scripts as the Jest test runner only
  (next/jest migration not needed -- the existing config already works).

Verified: `next build` static export succeeds, `next dev` serves the
landing page and client-side routes (login etc.) correctly, all 57
frontend tests + 172 backend tests still green.

Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s
in `next dev` since there's no server route for it -- the app only
ever mounts at "/". Production is unaffected: nginx's existing
try_files fallback still serves index.html for any path.
2026-07-12 00:50:45 +02:00
cesnimda fc62a659ef Merge pull request 'fix(jobs): derive attachment checklist flags from actual Attachments' (#19) from refactor/computed-attachment-flags into main
CI and Deploy / test (push) Successful in 2m2s
CI and Deploy / deploy (push) Successful in 1m1s
2026-07-11 21:14:10 +02:00
cesnimda b4fd5e2f96 fix(jobs): derive attachment checklist flags from actual Attachments
CI and Deploy / test (pull_request) Successful in 2m4s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 4 (Wave 3, first sub-item). HasResume/HasCoverLetter/HasPortfolio/
HasOtherAttachment were manually-editable checkboxes in EditJobDialog,
completely independent of whether a file was actually attached -- classic
drift: mark 'resume ready' by hand, later delete the resume attachment, flag
stays stuck true forever. User confirmed (asked directly, since removing the
manual-override capability is a product decision, not purely technical):
make them fully computed from Attachments, no manual override.

- AttachmentsController.RecomputeAttachmentFlagsAsync: the single place these
  four fields get written now, called after every attachment mutation
  (upload, delete, Purpose change) that could affect them. Deliberately kept
  as persisted columns (not [NotMapped] computed properties reading the
  Attachments navigation collection) -- ~15 query sites build JobApplication
  DTOs without .Include(Attachments), so a live-computed property would
  silently return false everywhere instead of throwing, the worst kind of
  bug. Recomputing at the one write funnel avoids touching any read path.
- Removed HasResume/etc from CreateJobApplicationRequest/
  UpdateJobApplicationRequest -- no longer client-settable.
- EditJobDialog: removed the manual checkboxes, kept the (now genuinely
  accurate) read-only status chips.
- AddJobModal: stopped sending has*-flags at job-creation time; the
  follow-up attachment upload call now sets them correctly via the same
  recompute path.

Caught a real bug while testing this: the Purpose-change path recomputed
before saving the Purpose change, so a fresh query missed the pending edit
and the flags never updated. Fixed by committing the mutation before
recomputing.

3 new backend tests (purpose-change sets flag, delete clears flag,
non-primary purpose counts as "other"). 172/172 backend, 25/25 frontend
suites (57 tests) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 21:10:39 +02:00
cesnimda 37ea1f98bb Merge pull request 'refactor(gmail): extract DTOs and static helpers from GmailController' (#18) from refactor/wave2-gmail-dtos-helpers into main
CI and Deploy / test (push) Successful in 2m11s
CI and Deploy / deploy (push) Successful in 46s
2026-07-11 20:52:28 +02:00
cesnimda ab79072e52 refactor(gmail): extract DTOs and static helpers from GmailController
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction,
no behaviour change:

- GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so
  every existing GmailController.XyzDto reference (tests included) keeps
  working unchanged.
- GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary,
  LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/
  CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach.

GmailController.cs: 1200 -> 1022 lines. 169/169 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:45:48 +02:00
cesnimda abe23b799a Merge pull request 'perf(gmail): narrow review-decision lookup to the single ThreadId' (#17) from fix/gmail-review-decision-load-all into main
CI and Deploy / test (push) Successful in 2m34s
CI and Deploy / deploy (push) Failing after 45s
2026-07-11 20:42:18 +02:00
50 changed files with 1313 additions and 350 deletions
@@ -0,0 +1,111 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
// Attachment rows (backlog Wave 3), not manually settable. These tests exercise the single
// place they're written: AttachmentsController's Purpose-change and Delete paths.
public sealed class AttachmentFlagsRecomputeTests
{
[Fact]
public async Task Changing_purpose_to_resume_sets_HasResume()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "other");
var controller = CreateController(db);
var result = await controller.Rename(attachment.Id, new AttachmentsController.UpdateAttachmentRequest(null, "resume", null), CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
Assert.True(updated.HasResume);
Assert.True(updated.HasOtherAttachment == false);
}
[Fact]
public async Task Deleting_the_only_resume_attachment_clears_HasResume()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "resume");
var controller = CreateController(db);
var result = await controller.Delete(attachment.Id, CancellationToken.None);
Assert.IsType<NoContentResult>(result);
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
Assert.False(updated.HasResume);
}
[Fact]
public async Task Attachment_with_case_study_purpose_counts_as_other()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "resume");
var controller = CreateController(db);
await controller.Rename(attachment.Id, new AttachmentsController.UpdateAttachmentRequest(null, "case-study", null), CancellationToken.None);
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
Assert.False(updated.HasResume);
Assert.True(updated.HasOtherAttachment);
}
private static async Task<(JobApplication Job, Attachment Attachment)> SeedJobWithAttachmentAsync(JobTrackerContext db, string purpose)
{
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var attachment = new Attachment
{
JobApplicationId = job.Id,
FileName = "file.pdf",
FilePath = Path.Combine(Path.GetTempPath(), $"jobtracker-attachment-test-{Guid.NewGuid():N}.pdf"),
FileType = "application/pdf",
FileSize = 100,
Purpose = purpose,
};
db.Attachments.Add(attachment);
await db.SaveChangesAsync();
job.HasResume = purpose == "resume";
job.HasOtherAttachment = purpose is not ("resume" or "cover-letter" or "portfolio");
await db.SaveChangesAsync();
return (job, attachment);
}
private static AttachmentsController CreateController(JobTrackerContext db)
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-attachments-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = tempRoot })
.Build();
var env = new Mock<IHostEnvironment>();
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
var paths = new AppPaths(config, env.Object);
return new AttachmentsController(paths, db)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
}
}
@@ -202,11 +202,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
CoverLetterText: null,
JobUrl: null,
DateApplied: null,
FeedbackRequestedAt: null,
HasResume: null,
HasCoverLetter: null,
HasPortfolio: null,
HasOtherAttachment: null);
FeedbackRequestedAt: null);
var result = await controller.Create(request, CancellationToken.None);
@@ -255,10 +251,6 @@ public sealed class JobApplicationsEndpointBehaviorTests
SalaryPeriod: "fortnight",
NextAction: null,
FollowUpAt: null,
HasResume: null,
HasCoverLetter: null,
HasPortfolio: null,
HasOtherAttachment: null,
Notes: null,
Description: null,
TranslatedDescription: null,
@@ -59,6 +59,26 @@ namespace JobTrackerApi.Controllers
return "other";
}
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived
// from actual Attachment rows, not manually settable -- this is the single place they're
// written, called after every attachment mutation (upload/delete/purpose change) so they
// can never drift from what's actually attached.
private async Task RecomputeAttachmentFlagsAsync(int jobId, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
if (job is null) return;
var purposes = await _db.Attachments
.Where(a => a.JobApplicationId == jobId)
.Select(a => a.Purpose)
.ToListAsync(cancellationToken);
job.HasResume = purposes.Any(p => p == "resume");
job.HasCoverLetter = purposes.Any(p => p == "cover-letter");
job.HasPortfolio = purposes.Any(p => p == "portfolio");
job.HasOtherAttachment = purposes.Any(p => p is not ("resume" or "cover-letter" or "portfolio"));
}
[HttpGet("{jobId:int}")]
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
{
@@ -102,15 +122,23 @@ namespace JobTrackerApi.Controllers
att.UseForAi = request.UseForAi.Value;
}
if (!string.IsNullOrWhiteSpace(request.Purpose))
var purposeChanged = !string.IsNullOrWhiteSpace(request.Purpose);
if (purposeChanged)
{
att.Purpose = request.Purpose.Trim().ToLowerInvariant();
att.Purpose = request.Purpose!.Trim().ToLowerInvariant();
}
var rawName = (request.FileName ?? string.Empty).Trim();
if (rawName.Length == 0)
{
await _db.SaveChangesAsync(cancellationToken);
if (purposeChanged)
{
// Recompute needs the Purpose change committed first -- a fresh query
// wouldn't see the pending change yet.
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
}
return NoContent();
}
@@ -130,6 +158,11 @@ namespace JobTrackerApi.Controllers
att.FileName = name;
att.FilePath = newPath;
await _db.SaveChangesAsync(cancellationToken);
if (purposeChanged)
{
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
}
return NoContent();
}
@@ -141,8 +174,11 @@ namespace JobTrackerApi.Controllers
if (att is null) return NotFound();
var path = att.FilePath;
var jobId = att.JobApplicationId;
_db.Attachments.Remove(att);
await _db.SaveChangesAsync(cancellationToken);
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
try
{
@@ -200,6 +236,8 @@ namespace JobTrackerApi.Controllers
});
}
await _db.SaveChangesAsync(cancellationToken);
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
return Ok();
}
+1 -179
View File
@@ -13,7 +13,7 @@ namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/gmail")]
[Authorize]
public sealed class GmailController : ControllerBase
public sealed partial class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
private readonly IGmailJobMatchingService _matching;
@@ -37,77 +37,6 @@ public sealed class GmailController : ControllerBase
private IEmailProvider Email => _providers.Get("gmail")
?? throw new InvalidOperationException("Gmail email provider is not registered.");
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
[HttpGet("status")]
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
{
@@ -1016,40 +945,6 @@ public sealed class GmailController : ControllerBase
return _matching.BuildJobQueries(job, queryOverride);
}
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
if (!decisions.TryGetValue(threadId, out var existing))
@@ -1090,54 +985,6 @@ public sealed class GmailController : ControllerBase
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private static string ToConfidence(int score)
{
return score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
@@ -1172,29 +1019,4 @@ public sealed class GmailController : ControllerBase
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
}
private static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
var title = success ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail connection</title>
</head>
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
<h2>{title}</h2>
<p>{escaped}</p>
<p>You can close this window.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
+79
View File
@@ -0,0 +1,79 @@
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers;
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
// change; these were previously nested inline in the controller file).
public partial class GmailController
{
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
}
+116
View File
@@ -0,0 +1,116 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers;
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
// refactor -- no behaviour change). All are static and side-effect free.
public sealed partial class GmailController
{
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private static string ToConfidence(int score)
{
return score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
var title = success ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail connection</title>
</head>
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
<h2>{title}</h2>
<p>{escaped}</p>
<p>You can close this window.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
@@ -1376,11 +1376,7 @@ Canonical profile:
string? CoverLetterText,
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt,
bool? HasResume,
bool? HasCoverLetter,
bool? HasPortfolio,
bool? HasOtherAttachment
DateTime? FeedbackRequestedAt
);
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
@@ -1422,10 +1418,9 @@ Canonical profile:
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
FollowUpAt = request.FollowUpAt,
FeedbackRequestedAt = request.FeedbackRequestedAt,
HasResume = request.HasResume ?? false,
HasCoverLetter = request.HasCoverLetter ?? false,
HasPortfolio = request.HasPortfolio ?? false,
HasOtherAttachment = request.HasOtherAttachment ?? false,
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
// Attachment rows (see AttachmentsController.RecomputeAttachmentFlagsAsync), not
// settable here -- they start false and get set correctly once files are uploaded.
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
@@ -1486,10 +1481,6 @@ Canonical profile:
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
bool? HasResume,
bool? HasCoverLetter,
bool? HasPortfolio,
bool? HasOtherAttachment,
string? Notes,
string? Description,
string? TranslatedDescription,
@@ -1529,10 +1520,8 @@ Canonical profile:
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
job.FollowUpAt = request.FollowUpAt;
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
if (request.HasResume is not null) job.HasResume = request.HasResume.Value;
if (request.HasCoverLetter is not null) job.HasCoverLetter = request.HasCoverLetter.Value;
if (request.HasPortfolio is not null) job.HasPortfolio = request.HasPortfolio.Value;
if (request.HasOtherAttachment is not null) job.HasOtherAttachment = request.HasOtherAttachment.Value;
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
// Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync.
job.Notes = request.Notes;
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;
+3 -1
View File
@@ -24,7 +24,9 @@ public class JobApplication
public DateTime? FeedbackRequestedAt { get; set; }
public string? RecruiterMessageDraft { get; set; }
// Attachment checklist
// Attachment checklist. Derived from Attachment rows, not directly settable by API
// consumers -- see AttachmentsController.RecomputeAttachmentFlagsAsync, the single place
// these are written, so they can't drift from what's actually attached.
public bool HasResume { get; set; } = false;
public bool HasCoverLetter { get; set; } = false;
public bool HasPortfolio { get; set; } = false;
+4 -4
View File
@@ -59,13 +59,13 @@ services:
frontend:
build:
context: ./job-tracker-ui
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
# Next's build type-checker needs more than Docker's default 64MB /dev/shm; too little
# causes a SIGSEGV during `npm run build`.
shm_size: '1gb'
args:
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
# Optional override; default in production is `/api`
- REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL}
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
ports:
- "3000:80"
depends_on:
+3
View File
@@ -10,6 +10,9 @@
# production
/build
/out
/.next
next-env.d.ts
# misc
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
# react-scripts (kept only as the Jest test runner, see package.json) still declares a
# typescript ^3.2.1||^4 peer constraint that's stale for our actual (Next.js-driven) TS 5.x --
# it doesn't type-check via that peer path, so the conflict is safe to relax.
legacy-peer-deps=true
+5 -5
View File
@@ -2,11 +2,11 @@ FROM node:20-alpine AS build
WORKDIR /app
ARG REACT_APP_GOOGLE_CLIENT_ID
ARG REACT_APP_API_BASE_URL
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_API_BASE_URL
ENV REACT_APP_GOOGLE_CLIENT_ID=$REACT_APP_GOOGLE_CLIENT_ID
ENV REACT_APP_API_BASE_URL=$REACT_APP_API_BASE_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY package*.json ./
RUN npm ci
@@ -17,7 +17,7 @@ RUN npm run build
FROM nginx:1.29.8-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/build /usr/share/nginx/html
COPY --from=build /app/out /usr/share/nginx/html
EXPOSE 80
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from "next";
import "../src/index.css";
export const metadata: Metadata = {
title: "Jobbjakt",
description: "Jobbjakt — track and manage job applications",
manifest: "/manifest.json",
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico" },
],
apple: "/logo192.png",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#15803d",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">{children}</div>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import dynamic from "next/dynamic";
// The whole app is a client-side React Router SPA whose providers read window/localStorage
// during their initial render -- ssr:false keeps Next's static prerender from ever executing
// any of it on the server.
const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false });
export default function Page() {
return <ClientApp />;
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// The whole app is client-rendered React Router behind auth (see app/page.tsx) -- static
// export keeps the same "one index.html + JS bundle, served by nginx" deploy as CRA had.
output: "export",
reactStrictMode: true,
};
module.exports = nextConfig;
+790 -5
View File
@@ -26,11 +26,12 @@
"@types/react-dom": "^19.2.3",
"axios": "^1.15.0",
"date-fns": "^4.1.0",
"next": "^16.2.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"typescript": "^5.9.3",
"web-vitals": "^2.1.4"
}
},
@@ -2401,6 +2402,16 @@
"postcss-selector-parser": "^6.0.10"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emotion/babel-plugin": {
"version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -2659,6 +2670,472 @@
"deprecated": "Use @eslint/object-schema instead",
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -3467,6 +3944,140 @@
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@next/env": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz",
"integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz",
"integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz",
"integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz",
"integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz",
"integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz",
"integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz",
"integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz",
"integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz",
"integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
"version": "5.1.1-v1",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
@@ -3957,6 +4568,15 @@
"url": "https://github.com/sponsors/gregberge"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
@@ -6242,6 +6862,12 @@
"node": ">=0.10.0"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
@@ -7215,6 +7841,16 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -12198,6 +12834,87 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/next": {
"version": "16.2.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz",
"integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.2.10",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.2.10",
"@next/swc-darwin-x64": "16.2.10",
"@next/swc-linux-arm64-gnu": "16.2.10",
"@next/swc-linux-arm64-musl": "16.2.10",
"@next/swc-linux-x64-gnu": "16.2.10",
"@next/swc-linux-x64-musl": "16.2.10",
"@next/swc-win32-arm64-msvc": "16.2.10",
"@next/swc-win32-x64-msvc": "16.2.10",
"sharp": "^0.34.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -15519,6 +16236,51 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -16076,6 +16838,29 @@
"webpack": "^5.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/stylehacks": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz",
@@ -16932,16 +17717,16 @@
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/unbox-primitive": {
+6 -5
View File
@@ -21,18 +21,19 @@
"@types/react-dom": "^19.2.3",
"axios": "^1.15.0",
"date-fns": "^4.1.0",
"next": "^16.2.10",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^6.30.3",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
"typescript": "^5.9.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "node --max-old-space-size=4096 ./node_modules/react-scripts/bin/react-scripts.js build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"dev": "next dev",
"start": "next dev",
"build": "node --max-old-space-size=4096 ./node_modules/next/dist/bin/next build",
"test": "react-scripts test"
},
"eslintConfig": {
"extends": [
-21
View File
@@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<meta name="theme-color" content="#15803d" />
<meta name="description" content="Jobbjakt — track and manage job applications" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Jobbjakt</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+12 -12
View File
@@ -27,11 +27,11 @@ import { PromptProvider } from "./prompt";
import JobTable from "./components/JobTable";
import type { JobTableColumns } from "./components/JobTable";
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import LoginPage from "./pages/LoginPage";
import LandingPage from "./pages/LandingPage";
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import RouteErrorPage from "./pages/RouteErrorPage";
import LoginPage from "./views/LoginPage";
import LandingPage from "./views/LandingPage";
import ForgotPasswordPage from "./views/ForgotPasswordPage";
import ResetPasswordPage from "./views/ResetPasswordPage";
import RouteErrorPage from "./views/RouteErrorPage";
import { api } from "./api";
import { resolveCaptureUrl } from "./captureUrl";
import { clearAuthClientState, setAuthUserKey } from "./auth";
@@ -45,13 +45,13 @@ const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
const SettingsView = lazy(() => import("./components/SettingsView"));
const RemindersView = lazy(() => import("./components/RemindersView"));
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
const ProfilePage = lazy(() => import("./views/ProfilePage"));
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./views/GmailReviewPage"));
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
type MeResponse = {
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import { I18nProvider } from "./i18n/I18nProvider";
export default function ClientApp() {
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</LocalizationProvider>
);
}
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api";
import LandingPage from "./pages/LandingPage";
import LandingPage from "./views/LandingPage";
jest.mock("./api", () => ({
api: {
@@ -1,7 +1,7 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import AdminSystemPage from './pages/AdminSystemPage';
import AdminSystemPage from './views/AdminSystemPage';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
+1 -1
View File
@@ -35,7 +35,7 @@ export function getApiErrorMessage(error: any, fallback = "Request failed.") {
return fallback;
}
const envBaseUrl = process.env.REACT_APP_API_BASE_URL;
const envBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
const defaultBaseUrl =
window.location.hostname === "localhost"
? "http://localhost:5202/api"
@@ -0,0 +1,21 @@
import React from "react";
export default function JobbjaktMark(props: React.SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker" {...props}>
<defs>
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="#3b82f6" />
<stop offset="100%" stopColor="#14b8a6" />
</linearGradient>
</defs>
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a" />
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" strokeWidth="4" />
<path d="M14 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<path d="M36 31h14" stroke="url(#briefcase-track)" strokeWidth="4" strokeLinecap="round" />
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0" />
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Job tracker">
<defs>
<linearGradient id="briefcase-track" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#3b82f6"/>
<stop offset="100%" stop-color="#14b8a6"/>
</linearGradient>
</defs>
<rect x="8" y="12" width="48" height="40" rx="12" fill="#0f172a"/>
<path d="M22 20v-2c0-3.3 2.7-6 6-6h8c3.3 0 6 2.7 6 6v2" fill="none" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<rect x="14" y="22" width="36" height="26" rx="8" fill="none" stroke="url(#briefcase-track)" stroke-width="4"/>
<path d="M14 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<path d="M36 31h14" stroke="url(#briefcase-track)" stroke-width="4" stroke-linecap="round"/>
<circle cx="32" cy="31" r="4.5" fill="#e2e8f0"/>
<path d="M24 40l5 5 11-12" fill="none" stroke="#e2e8f0" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1002 B

@@ -327,10 +327,6 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
notes,
coverLetterText: null,
dateApplied,
hasResume: attachments.resume.length > 0,
hasCoverLetter: attachments.coverLetter.length > 0,
hasPortfolio: attachments.portfolio.length > 0,
hasOtherAttachment: attachments.other.length > 0,
});
if (response.data?.id && attachmentCount > 0) {
@@ -158,10 +158,6 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
salaryPeriod: salaryPeriod || null,
nextAction: nextAction.trim() || null,
followUpAt: followUpAt || null,
hasResume,
hasCoverLetter,
hasPortfolio,
hasOtherAttachment,
notes: notes || null,
description: description || null,
translatedDescription: translatedDescription || null,
@@ -243,16 +239,13 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
<Paper variant="outlined" sx={{ p: 2 }}>
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobAttachmentsChecklist")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1, mb: 1.5 }}>
{/* Derived from actual uploaded attachments (see the Attachments panel) -- not
manually editable, so this can never drift from what's really attached. */}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
<Chip size="small" label={hasResume ? t("editJobResumeReady") : t("editJobResumeMissing")} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
<Chip size="small" label={hasCoverLetter ? t("editJobCoverLetterReady") : t("editJobCoverLetterMissing")} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
<Chip size="small" label={hasPortfolio ? t("editJobPortfolioReady") : t("editJobPortfolioOptional")} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
</Box>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}>
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label={t("editJobResume")} />
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label={t("editJobCoverLetter")} />
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label={t("editJobPortfolio")} />
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label={t("editJobOtherAttachment")} />
{hasOtherAttachment && <Chip size="small" label={t("editJobOtherAttachment")} color="success" variant="filled" />}
</Box>
</Paper>
</Box>
@@ -53,7 +53,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
const [working, setWorking] = useState(false);
const hostRef = useRef<HTMLDivElement | null>(null);
const clientId = (process.env.REACT_APP_GOOGLE_CLIENT_ID || "").trim();
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
const signedIn = Boolean(me?.provider);
const actionLabel = !signedIn
? t("continueWithGoogle")
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage';
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
import { api } from './api';
jest.mock('./api', () => ({
@@ -4,7 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import GmailReviewPage from './pages/GmailReviewPage';
import GmailReviewPage from './views/GmailReviewPage';
import { api } from './api';
jest.mock('./api', () => ({
+2 -2
View File
@@ -608,7 +608,7 @@ export const translations = {
adminSystemCpuMode: "CPU mode",
adminSystemNoSmtpHost: "No SMTP host configured",
googleAccountTitle: "Google account",
googleSetupHint: "Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleSetupHint: "Set `NEXT_PUBLIC_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking.",
googleLinked: "Linked",
googleAvailableToLink: "Available to link",
googleLinkedDate: "Linked {date}",
@@ -1552,7 +1552,7 @@ export const translations = {
adminSystemCpuMode: "CPU-modus",
adminSystemNoSmtpHost: "Ingen SMTP-vert konfigurert",
googleAccountTitle: "Google-konto",
googleSetupHint: "Sett `REACT_APP_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleSetupHint: "Sett `NEXT_PUBLIC_GOOGLE_CLIENT_ID` i UI-miljøet ditt for å aktivere Google-innlogging og kontokobling.",
googleLinked: "Koblet",
googleAvailableToLink: "Tilgjengelig for kobling",
googleLinkedDate: "Koblet {date}",
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import ErrorBoundary from "./components/ErrorBoundary";
import { I18nProvider } from './i18n/I18nProvider';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<I18nProvider>
<ErrorBoundary>
<App />
</ErrorBoundary>
</I18nProvider>
</LocalizationProvider>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
+1 -1
View File
@@ -25,7 +25,7 @@ import MenuOpenIcon from "@mui/icons-material/MenuOpen";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import SettingsOutlinedIcon from "@mui/icons-material/SettingsOutlined";
import { ReactComponent as JobbjaktMark } from "../assets/jobbbjakt-mark.svg";
import JobbjaktMark from "../assets/JobbjaktMark";
import { useI18n } from "../i18n/I18nProvider";
export type NavItem = {
+1 -1
View File
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import LoginPage from './views/LoginPage';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import { api } from './api';
+1 -1
View File
@@ -3,7 +3,7 @@ import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import ProfilePage from './pages/ProfilePage';
import ProfilePage from './views/ProfilePage';
import { api } from './api';
const createObjectURLMock = jest.fn(() => 'blob:mock-pdf');
-1
View File
@@ -1 +0,0 @@
/// <reference types="react-scripts" />
-15
View File
@@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;
+17 -4
View File
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es5",
"target": "es2017",
"lib": [
"dom",
"dom.iterable",
@@ -14,13 +14,26 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"plugins": [
{
"name": "next"
}
]
},
"include": [
"src"
"src",
"app",
"next-env.d.ts",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}