diff --git a/.env.example b/.env.example index 074343e..981637b 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET AUTH_ADMIN_EMAIL=admin@example.com AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD -AUTH_GOOGLE_CLIENT_ID=723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com +AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET # Optional. If omitted, the backend uses https:///api/gmail/oauth/callback GOOGLE_GMAIL_REDIRECT_URI= @@ -25,7 +25,7 @@ EMAIL_SMTP_PORT=587 EMAIL_SMTP_USER=CHANGE_ME_GMAIL_ADDRESS EMAIL_SMTP_PASSWORD=CHANGE_ME_GOOGLE_APP_PASSWORD EMAIL_FROM=CHANGE_ME_GMAIL_ADDRESS -EMAIL_FROM_NAME=Job Tracker +EMAIL_FROM_NAME=Jobbjakt EMAIL_SMTP_ENABLE_SSL=true EMAIL_SMTP_TIMEOUT_MS=15000 diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 1909027..fba42d9 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -57,6 +57,7 @@ public sealed class AuthController : ControllerBase string? LastName, string? DisplayName, string? ProfileCvText, + string? AvatarImageDataUrl, IList Roles, GoogleLinkDto? GoogleLink); public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText); @@ -172,6 +173,7 @@ public sealed class AuthController : ControllerBase LastName: User.FindFirstValue("family_name"), DisplayName: User.FindFirstValue("name"), ProfileCvText: null, + AvatarImageDataUrl: null, Roles: Array.Empty(), GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null)); } @@ -277,6 +279,70 @@ public sealed class AuthController : ControllerBase return NoContent(); } + [HttpPost("avatar")] + [Authorize(AuthenticationSchemes = "local")] + [RequestSizeLimit(5_000_000)] + public async Task UploadAvatar([FromForm] IFormFile? file) + { + var user = await _users.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + if (file is null || file.Length == 0) + { + return BadRequest("Image file is required."); + } + + if (!string.Equals(file.ContentType, "image/png", StringComparison.OrdinalIgnoreCase) + && !string.Equals(file.ContentType, "image/jpeg", StringComparison.OrdinalIgnoreCase) + && !string.Equals(file.ContentType, "image/webp", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Only PNG, JPEG, or WebP images are supported."); + } + + if (file.Length > 5_000_000) + { + return BadRequest("Avatar image is too large."); + } + + await using var stream = file.OpenReadStream(); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + var bytes = memory.ToArray(); + var base64 = Convert.ToBase64String(bytes); + user.AvatarImageDataUrl = $"data:{file.ContentType};base64,{base64}"; + + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl }); + } + + [HttpDelete("avatar")] + [Authorize(AuthenticationSchemes = "local")] + public async Task DeleteAvatar() + { + var user = await _users.GetUserAsync(User); + if (user is null) + { + return Unauthorized(); + } + + user.AvatarImageDataUrl = null; + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return NoContent(); + } + public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); [HttpPost("change-password")] @@ -374,6 +440,7 @@ public sealed class AuthController : ControllerBase LastName: user.LastName, DisplayName: user.DisplayName, ProfileCvText: user.ProfileCvText, + AvatarImageDataUrl: user.AvatarImageDataUrl, Roles: roles, GoogleLink: new GoogleLinkDto( Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject), diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index a6f3b11..244a763 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -1,5 +1,4 @@ // -using System; using JobTrackerApi.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,301 +14,7 @@ namespace JobTrackerApi.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.17"); - - modelBuilder.Entity("JobTrackerApi.Models.Company", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Location") - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Source") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.ToTable("Companies"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("FileName") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("FilePath") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UploadDate") - .HasColumnType("TEXT"); - - b.Property("FileType") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("FileSize") - .HasColumnType("INTEGER"); - - b.Property("JobApplicationId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("JobApplicationId"); - - b.ToTable("Attachments"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Content") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Date") - .HasColumnType("TEXT"); - - b.Property("From") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("JobApplicationId") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("JobApplicationId"); - - b.ToTable("Correspondences"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CompanyId") - .HasColumnType("INTEGER"); - - b.Property("CoverLetterText") - .HasColumnType("TEXT"); - - b.Property("DateApplied") - .HasColumnType("TEXT"); - - b.Property("DeletedAt") - .HasColumnType("TEXT"); - - b.Property("Deadline") - .HasColumnType("TEXT"); - - b.Property("Description") - .HasColumnType("TEXT"); - - b.Property("DescriptionLanguage") - .HasColumnType("TEXT"); - - b.Property("FeedbackRequestedAt") - .HasColumnType("TEXT"); - - b.Property("FollowUpAt") - .HasColumnType("TEXT"); - - b.Property("IsDeleted") - .HasColumnType("INTEGER"); - - b.Property("JobTitle") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("JobUrl") - .HasColumnType("TEXT"); - - b.Property("Location") - .HasColumnType("TEXT"); - - b.Property("NextAction") - .HasColumnType("TEXT"); - - b.Property("Notes") - .HasColumnType("TEXT"); - - b.Property("ResponseDate") - .HasColumnType("TEXT"); - - b.Property("ResponseReceived") - .HasColumnType("INTEGER"); - - b.Property("Salary") - .HasColumnType("TEXT"); - - b.Property("ShortSummary") - .HasColumnType("TEXT"); - - b.Property("Status") - .IsRequired() - .ValueGeneratedOnAdd() - .HasColumnType("TEXT") - .HasDefaultValue("Applied"); - - b.Property("Tags") - .HasColumnType("TEXT"); - - b.Property("TranslatedDescription") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("CompanyId"); - - b.ToTable("JobApplications"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("JobApplicationId") - .HasColumnType("INTEGER"); - - b.Property("Type") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("OldValue") - .HasColumnType("TEXT"); - - b.Property("NewValue") - .HasColumnType("TEXT"); - - b.Property("Note") - .HasColumnType("TEXT"); - - b.Property("At") - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("JobApplicationId"); - - b.ToTable("JobEvents"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => - { - b.Property("Id") - .HasColumnType("INTEGER"); - - b.Property("AppliedFollowUpDays") - .HasColumnType("INTEGER"); - - b.Property("AppliedGhostDays") - .HasColumnType("INTEGER"); - - b.Property("OfferFollowUpDays") - .HasColumnType("INTEGER"); - - b.Property("OfferGhostDays") - .HasColumnType("INTEGER"); - - b.Property("FeedbackFollowUpDays") - .HasColumnType("INTEGER"); - - b.Property("FeedbackGhostDays") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.ToTable("RuleSettings"); - - b.HasData(new - { - Id = 1, - AppliedFollowUpDays = 14, - AppliedGhostDays = 30, - OfferFollowUpDays = 7, - OfferGhostDays = 14, - FeedbackFollowUpDays = 7, - FeedbackGhostDays = 14 - }); - }); - - modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => - { - b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") - .WithMany("Messages") - .HasForeignKey("JobApplicationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("JobApplication"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => - { - b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") - .WithMany("Attachments") - .HasForeignKey("JobApplicationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("JobApplication"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => - { - b.HasOne("JobTrackerApi.Models.Company", "Company") - .WithMany("Jobs") - .HasForeignKey("CompanyId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("Company"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => - { - b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") - .WithMany("Events") - .HasForeignKey("JobApplicationId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.Navigation("JobApplication"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.Company", b => - { - b.Navigation("Jobs"); - }); - - modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => - { - b.Navigation("Attachments"); - - b.Navigation("Events"); - - b.Navigation("Messages"); - }); + modelBuilder.HasAnnotation("ProductVersion", "9.0.2"); #pragma warning restore 612, 618 } } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index a5487c9..60a0fb8 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -347,6 +347,7 @@ CREATE TABLE IF NOT EXISTS `AspNetUsers` ( `LastName` longtext NULL, `DisplayName` longtext NULL, `ProfileCvText` longtext NULL, + `AvatarImageDataUrl` longtext NULL, `GoogleSubject` longtext NULL, `GoogleEmail` longtext NULL, `GoogleLinkedAt` datetime(6) NULL, @@ -498,6 +499,8 @@ CREATE TABLE IF NOT EXISTS "AspNetUsers" ( "FirstName" TEXT NULL, "LastName" TEXT NULL, "DisplayName" TEXT NULL, + "ProfileCvText" TEXT NULL, + "AvatarImageDataUrl" TEXT NULL, "GoogleSubject" TEXT NULL, "GoogleEmail" TEXT NULL, "GoogleLinkedAt" TEXT NULL @@ -570,6 +573,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); @@ -680,6 +684,61 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( using var conn = new MySqlConnection(cs); conn.Open(); EnsureIdentityTablesMySql(conn); + + static bool MySqlColumnExists(MySqlConnection c, string table, string column) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;"; + + cmd.Parameters.AddWithValue("@schema", c.Database); + cmd.Parameters.AddWithValue("@table", table); + cmd.Parameters.AddWithValue("@column", column); + + return cmd.ExecuteScalar() is not null; + } + + static bool MySqlIndexExists(MySqlConnection c, string table, string indexName) + { + using var cmd = c.CreateCommand(); + cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;"; + + cmd.Parameters.AddWithValue("@schema", c.Database); + cmd.Parameters.AddWithValue("@table", table); + cmd.Parameters.AddWithValue("@index", indexName); + + return cmd.ExecuteScalar() is not null; + } + + static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl) + { + using var existsCmd = c.CreateCommand(); + existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;"; + existsCmd.Parameters.AddWithValue("@schema", c.Database); + existsCmd.Parameters.AddWithValue("@table", table); + if (existsCmd.ExecuteScalar() is null) return; + + if (MySqlColumnExists(c, table, column)) return; + using var ddlCmd = c.CreateCommand(); + ddlCmd.CommandText = ddl; + ddlCmd.ExecuteNonQuery(); + } + + EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); + EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); + + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } } } diff --git a/JobTrackerApi/appsettings.Development.json b/JobTrackerApi/appsettings.Development.json index 692b564..46c7cdf 100644 --- a/JobTrackerApi/appsettings.Development.json +++ b/JobTrackerApi/appsettings.Development.json @@ -1,44 +1,44 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Cors": { - "Origins": [ - "http://localhost:3000", - "https://jobs.cesnimda.uk" - ] - }, - "Exports": { - "DailyEnabled": true, - "DailyFolder": "exports", - "DailyHourLocal": 2 - }, - "Auth": { - "Require": true, - "AllowRegistration": false, - "JwtKey": "Y00VuqZehhsMiNa8elch7q7FOlPm5ncugKJtMOpFn3P2xNtrZVfvGxVP2bKbnzL6rI08/H6vZGNBYh1dHh71/g==", - "JwtIssuer": "JobTrackerApi", - "JwtAudience": "job-tracker-ui", - "JwtExpiresMinutes": 720, - "AdminEmail": "dj@cesnimda.co.uk", - "AdminPassword": "Leethacks12", - "GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com" - }, - "App": { - "PublicBaseUrl": "https://jobs.cesnimda.uk" - }, - "Email": { - "Enabled": false, - "SmtpHost": "smtp.gmail.com", - "SmtpPort": 587, - "SmtpUser": "CHANGE_ME_GMAIL_ADDRESS", - "SmtpPassword": "CHANGE_ME_GOOGLE_APP_PASSWORD", - "From": "CHANGE_ME_GMAIL_ADDRESS", - "FromName": "Job Tracker", - "SmtpEnableSsl": true, - "SmtpTimeoutMs": 15000 - } +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Cors": { + "Origins": [ + "http://localhost:3000", + "https://jobs.cesnimda.uk" + ] + }, + "Exports": { + "DailyEnabled": true, + "DailyFolder": "exports", + "DailyHourLocal": 2 + }, + "Auth": { + "Require": true, + "AllowRegistration": false, + "JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET", + "JwtIssuer": "JobTrackerApi", + "JwtAudience": "job-tracker-ui", + "JwtExpiresMinutes": 720, + "AdminEmail": "admin@example.com", + "AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD", + "GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID" + }, + "App": { + "PublicBaseUrl": "https://jobs.cesnimda.uk" + }, + "Email": { + "Enabled": false, + "SmtpHost": "smtp.gmail.com", + "SmtpPort": 587, + "SmtpUser": "CHANGE_ME_GMAIL_ADDRESS", + "SmtpPassword": "CHANGE_ME_GOOGLE_APP_PASSWORD", + "From": "CHANGE_ME_GMAIL_ADDRESS", + "FromName": "Jobbjakt", + "SmtpEnableSsl": true, + "SmtpTimeoutMs": 15000 + } } diff --git a/Models/ApplicationUser.cs b/Models/ApplicationUser.cs index b2e00d8..5d5fe77 100644 --- a/Models/ApplicationUser.cs +++ b/Models/ApplicationUser.cs @@ -8,6 +8,7 @@ public sealed class ApplicationUser : IdentityUser public string? LastName { get; set; } public string? DisplayName { get; set; } public string? ProfileCvText { get; set; } + public string? AvatarImageDataUrl { get; set; } public string? GoogleSubject { get; set; } public string? GoogleEmail { get; set; } public DateTimeOffset? GoogleLinkedAt { get; set; } diff --git a/deploy/README.md b/deploy/README.md index c5397c7..2f2c271 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -35,7 +35,15 @@ to: This keeps secrets outside the uploaded repo checkout so they are not wiped by CI deploys. -### Example production `.env` +### Frontend API base URL +The production frontend already proxies `/api` to the backend container via Nginx. + +Recommended default: +- leave `REACT_APP_API_BASE_URL` unset/empty in production + +Only set `REACT_APP_API_BASE_URL` if the UI must call a different external API origin on purpose. + +## Example production `.env` ```env DATABASE_PROVIDER=mariadb JOBTRACKER_CONNECTION_STRING=server=mariadb;port=3306;database=jobtracker;user=jobtracker;password=REPLACE_ME diff --git a/job-tracker-ui/public/index.html b/job-tracker-ui/public/index.html index beca66d..85eb36b 100644 --- a/job-tracker-ui/public/index.html +++ b/job-tracker-ui/public/index.html @@ -8,44 +8,14 @@ - - + + - - - JobTrack + Jobbjakt
- - - -un build` or `yarn build`. - --> diff --git a/job-tracker-ui/public/logo192.png b/job-tracker-ui/public/logo192.png index 68e2760..4a5ea97 100644 Binary files a/job-tracker-ui/public/logo192.png and b/job-tracker-ui/public/logo192.png differ diff --git a/job-tracker-ui/public/logo512.png b/job-tracker-ui/public/logo512.png index 221d744..77b44b3 100644 Binary files a/job-tracker-ui/public/logo512.png and b/job-tracker-ui/public/logo512.png differ diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 0de8812..16748f7 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Box, Button, CssBaseline, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; +import { Box, Button, CssBaseline, Typography } from "@mui/material"; import useMediaQuery from "@mui/material/useMediaQuery"; import { CssVarsProvider } from "@mui/material/styles"; @@ -38,27 +38,39 @@ import AdminAuditPage from "./pages/AdminAuditPage"; import AdminUsersPage from "./pages/AdminUsersPage"; import AdminSystemPage from "./pages/AdminSystemPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; +import NotFoundPage from "./pages/NotFoundPage"; +import RouteErrorPage from "./pages/RouteErrorPage"; import { api } from "./api"; import { clearAuthToken, getAuthToken } from "./auth"; import AppShell, { NavItem } from "./layout/AppShell"; import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs"; type AuthConfig = { requireAuth: boolean }; -type MeResponse = { provider?: "local" | "google" | "external"; id?: string; email?: string; userName?: string; roles?: string[] }; +type MeResponse = { + provider?: "local" | "google" | "external"; + id?: string; + email?: string; + userName?: string; + firstName?: string; + lastName?: string; + displayName?: string; + avatarImageDataUrl?: string; + roles?: string[]; +}; function breadcrumbsFor(path: string, t: (k: any) => string): string[] { - if (path.startsWith("/dashboard")) return ["Home", "Analytics", "Overview"]; - if (path.startsWith("/jobs")) return ["Home", t("jobApplications")]; - if (path.startsWith("/reminders")) return ["Home", t("reminders")]; - if (path.startsWith("/kanban")) return ["Home", t("kanbanBoard")]; - if (path.startsWith("/companies")) return ["Home", t("companies")]; - if (path.startsWith("/trash")) return ["Home", t("trash")]; - if (path.startsWith("/settings")) return ["Home", t("settings")]; - if (path.startsWith("/profile")) return ["Home", "Account", "Profile"]; - if (path.startsWith("/admin/audit")) return ["Home", "Admin", "Audit"]; - if (path.startsWith("/admin/users")) return ["Home", "Admin", "Users"]; - if (path.startsWith("/admin/system")) return ["Home", "Admin", "System"]; - return ["Home"]; + if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")]; + if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")]; + if (path.startsWith("/reminders")) return [t("home"), t("reminders")]; + if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")]; + if (path.startsWith("/companies")) return [t("home"), t("companies")]; + if (path.startsWith("/trash")) return [t("home"), t("trash")]; + if (path.startsWith("/settings")) return [t("home"), t("settings")]; + if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")]; + if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")]; + if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")]; + if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")]; + return [t("home")]; } function titleFor(path: string, t: (k: any) => string): string { @@ -69,17 +81,17 @@ function titleFor(path: string, t: (k: any) => string): string { if (path.startsWith("/companies")) return t("companies"); if (path.startsWith("/trash")) return t("trash"); if (path.startsWith("/settings")) return t("settings"); - if (path.startsWith("/profile")) return "Profile"; - if (path.startsWith("/admin/audit")) return "Audit log"; - if (path.startsWith("/admin/users")) return "Users"; - if (path.startsWith("/admin/system")) return "System status"; + if (path.startsWith("/profile")) return t("profile"); + if (path.startsWith("/admin/audit")) return t("auditLog"); + if (path.startsWith("/admin/users")) return t("users"); + if (path.startsWith("/admin/system")) return t("systemStatus"); return t("appTitle"); } function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange, accentColor, onAccentColorChange, onResetAccentColor }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; accentColor: string; onAccentColorChange: (v: string) => void; onResetAccentColor: () => void; }) { const location = useLocation(); const navigate = useNavigate(); - const { language, setLanguage, t } = useI18n(); + const { t } = useI18n(); const [addOpen, setAddOpen] = useState(false); const [quickOpen, setQuickOpen] = useState(false); @@ -126,31 +138,28 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const breadcrumbs = breadcrumbsFor(path, t); const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); }; const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); }; + const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); const nav: NavItem[] = [ - { to: "/dashboard", label: t("dashboard"), icon: , section: "Manage" }, - { to: "/jobs", label: t("jobApplications"), icon: , section: "Manage" }, - { to: "/reminders", label: t("reminders"), icon: , badgeCount: notifCount, section: "Manage" }, - { to: "/kanban", label: t("kanbanBoard"), icon: , section: "Manage" }, - { to: "/companies", label: t("companies"), icon: , section: "Manage" }, - { to: "/trash", label: t("trash"), icon: , section: "Manage" }, + { to: "/dashboard", label: t("dashboard"), icon: , section: t("manage") }, + { to: "/jobs", label: t("jobApplications"), icon: , section: t("manage") }, + { to: "/reminders", label: t("reminders"), icon: , badgeCount: notifCount, section: t("manage") }, + { to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") }, + { to: "/companies", label: t("companies"), icon: , section: t("manage") }, + { to: "/trash", label: t("trash"), icon: , section: t("manage") }, ]; const navBottom: NavItem[] = [ - { to: "/admin/audit", label: "Audit log", icon: , hidden: !isAdmin, section: "Admin" }, - { to: "/admin/users", label: "Users", icon: , hidden: !isAdmin, section: "Admin" }, - { to: "/admin/system", label: "System", icon: , hidden: !isAdmin, section: "Admin" }, - { to: "/profile", label: "Profile", icon: , section: "Account" }, - { to: "/settings", label: t("settings"), icon: , section: "Account" }, + { to: "/admin/audit", label: t("auditLog"), icon: , hidden: !isAdmin, section: t("admin") }, + { to: "/admin/users", label: t("users"), icon: , hidden: !isAdmin, section: t("admin") }, + { to: "/admin/system", label: t("system"), icon: , hidden: !isAdmin, section: t("admin") }, + { to: "/profile", label: t("profile"), icon: , section: t("account") }, + { to: "/settings", label: t("settings"), icon: , section: t("account") }, ]; const rightActions = ( - - v && setLanguage(v)}> - EN - NO - + {isJobs ? : null} ); @@ -166,7 +175,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo drawerOpen={mobileDrawerOpen} onToggleDrawer={setMobileDrawerOpen} onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }} - user={{ email: me?.email, userName: me?.userName, roleLabel: isAdmin ? "Super Admin" : "User" }} + user={{ email: me?.email, userName: me?.userName, displayName: me?.displayName || fullName || undefined, avatarImageDataUrl: me?.avatarImageDataUrl, roleLabel: isAdmin ? t("superAdmin") : t("user") }} notificationsCount={notifCount} onOpenNotifications={() => navigate("/reminders")} onOpenSettings={() => navigate("/settings")} @@ -187,7 +196,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> } /> - } /> + } /> @@ -231,9 +240,9 @@ export default function App() { }); const router = useMemo(() => createBrowserRouter([ - { path: "/login", element: }, - { path: "/reset-password", element: }, - { path: "/*", element: }, + { path: "/login", element: , errorElement: }, + { path: "/reset-password", element: , errorElement: }, + { path: "/*", element: , errorElement: }, ], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]); return ( diff --git a/job-tracker-ui/src/api.ts b/job-tracker-ui/src/api.ts index 0d8d6bd..235929e 100644 --- a/job-tracker-ui/src/api.ts +++ b/job-tracker-ui/src/api.ts @@ -2,6 +2,27 @@ import axios from "axios"; import { getAuthToken } from "./auth"; import { clearAuthToken } from "./auth"; +export function getApiErrorMessage(error: any, fallback = "Request failed.") { + const data = error?.response?.data; + if (typeof data === "string" && data.trim()) return data.trim(); + if (typeof data?.message === "string" && data.message.trim()) return data.message.trim(); + if (Array.isArray(data?.errors)) { + const first = data.errors.find((value: unknown) => typeof value === "string" && value.trim()); + if (first) return first; + } + if (data?.errors && typeof data.errors === "object") { + for (const value of Object.values(data.errors)) { + if (Array.isArray(value)) { + const first = value.find((item: unknown) => typeof item === "string" && item.trim()); + if (first) return first; + } + if (typeof value === "string" && value.trim()) return value.trim(); + } + } + if (typeof error?.message === "string" && error.message.trim()) return error.message.trim(); + return fallback; +} + const envBaseUrl = process.env.REACT_APP_API_BASE_URL; const defaultBaseUrl = window.location.hostname === "localhost" diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 22ffb2e..909d2e1 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -5,12 +5,13 @@ import { Autocomplete, Box, Button, - Checkbox, + Chip, Dialog, + DialogActions, DialogContent, DialogTitle, Divider, - FormControlLabel, + IconButton, List, ListItem, ListItemText, @@ -19,7 +20,10 @@ import { Typography, } from "@mui/material"; -import { api } from "../api"; +import CloseIcon from "@mui/icons-material/Close"; +import UploadFileOutlinedIcon from "@mui/icons-material/UploadFileOutlined"; + +import { api, getApiErrorMessage } from "../api"; import { Company, JobImportResult } from "../types"; import { invalidateCompaniesCache, useCompanies } from "../hooks/useCompanies"; import { useToast } from "../toast"; @@ -47,19 +51,43 @@ type DuplicateCheckResult = { matches: DuplicateCandidate[]; }; +type CreatedJobResponse = { + id?: number; +}; + +type AttachmentBucketKey = "resume" | "coverLetter" | "portfolio" | "other"; +type AttachmentBuckets = Record; + const STATUS_OPTIONS = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; +const ACCEPTED_DOCUMENT_TYPES = ".pdf,.doc,.docx,.txt,.md,image/*,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown"; function getTodayIso() { return new Date().toISOString().slice(0, 10); } +function emptyAttachmentBuckets(): AttachmentBuckets { + return { + resume: [], + coverLetter: [], + portfolio: [], + other: [], + }; +} + +function normalizeLanguage(value?: string | null) { + const raw = (value || "").trim().toLowerCase(); + if (!raw) return ""; + if (["en", "eng", "english"].includes(raw)) return "en"; + if (["no", "nb", "nn", "norwegian", "norwegian bokmål", "bokmal", "bokmål"].includes(raw)) return "no"; + return raw; +} + export default function AddJobModal({ open, onClose, onCreated }: Props) { const { toast } = useToast(); - const { t } = useI18n(); + const { t, language } = useI18n(); const [saving, setSaving] = useState(false); const [importing, setImporting] = useState(false); - const [saveAndAddAnother, setSaveAndAddAnother] = useState(false); const [duplicateCheck, setDuplicateCheck] = useState(null); const { companies: cachedCompanies } = useCompanies(); @@ -75,8 +103,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [status, setStatus] = useState<(typeof STATUS_OPTIONS)[number]>("Applied"); const [location, setLocation] = useState(""); const [salary, setSalary] = useState(""); - const [nextAction, setNextAction] = useState(""); - const [followUpAt, setFollowUpAt] = useState(""); const [jobUrl, setJobUrl] = useState(""); const [deadline, setDeadline] = useState(""); @@ -85,14 +111,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const [descriptionLanguage, setDescriptionLanguage] = useState(""); const [tags, setTags] = useState([]); - const [notes, setNotes] = useState(""); - const [coverLetter, setCoverLetter] = useState(""); - - const [hasResume, setHasResume] = useState(false); - const [hasCoverLetter, setHasCoverLetter] = useState(false); - const [hasPortfolio, setHasPortfolio] = useState(false); - const [hasOtherAttachment, setHasOtherAttachment] = useState(false); + const [attachments, setAttachments] = useState(() => emptyAttachmentBuckets()); useEffect(() => { setCompanies(cachedCompanies); @@ -108,8 +128,6 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setStatus("Applied"); setLocation(""); setSalary(""); - setNextAction(""); - setFollowUpAt(""); setJobUrl(""); setDeadline(""); setDescription(""); @@ -117,11 +135,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setDescriptionLanguage(""); setTags([]); setNotes(""); - setCoverLetter(""); - setHasResume(false); - setHasCoverLetter(false); - setHasPortfolio(false); - setHasOtherAttachment(false); + setAttachments(emptyAttachmentBuckets()); setDuplicateCheck(null); }; @@ -133,6 +147,10 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const selectedCompanyId = company?.id ?? matchingCompany?.id ?? 0; const showNewCompanyFields = !company && !!normalizedCompanyName && !matchingCompany; + const preferredLanguage = normalizeLanguage(language); + const sourceLanguage = normalizeLanguage(descriptionLanguage); + const shouldShowTranslatedDescription = Boolean(sourceLanguage && preferredLanguage && sourceLanguage !== preferredLanguage); + const attachmentCount = Object.values(attachments).reduce((sum, files) => sum + files.length, 0); useEffect(() => { if (!open) return; @@ -180,8 +198,8 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setNewCompanyLocation(""); setNewCompanySource(""); return res.data; - } catch { - toast("Failed to create company.", "error"); + } catch (error: any) { + toast(getApiErrorMessage(error, t("addJobModalFailedCreateCompany")), "error"); return null; } }; @@ -189,7 +207,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const importFromUrl = async () => { if (importing) return; if (!jobUrl.trim()) { - toast("Paste a job URL first.", "warning"); + toast(t("addJobModalPasteUrlFirst"), "warning"); return; } @@ -197,7 +215,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { try { const res = await api.post("/jobimport/preview", { url: jobUrl.trim() }); const r = res.data; - if (!r?.success) throw new Error(r?.error || "Import failed"); + if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed")); if (r.title) setJobTitle(r.title); if (r.location) setLocation(r.location); @@ -217,15 +235,28 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { setTags(r.tags || []); setDeadline(r.deadline ? r.deadline.slice(0, 10) : ""); - toast("Imported.", "success"); + toast(t("addJobModalImported"), "success"); } catch (e: any) { - toast(e?.message || "Import failed.", "error"); + toast(e?.message || t("addJobModalImportFailed"), "error"); } finally { setImporting(false); } }; - const createJob = async () => { + const uploadAttachments = async (jobId: number) => { + const files = Object.values(attachments).flat(); + if (!files.length) return; + + const data = new FormData(); + files.forEach((file) => data.append("files", file)); + data.append("jobId", String(jobId)); + + await api.post("/attachments", data, { + headers: { "Content-Type": "multipart/form-data" }, + }); + }; + + const createJob = async (addAnother = false) => { if (saving) return; setSaving(true); @@ -235,41 +266,53 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { selectedCompany = await createCompany(); } if (!selectedCompany) { - toast("Select or create a company.", "warning"); + toast(t("addJobModalSelectCompany"), "warning"); return; } - await api.post("/jobapplications", { + const response = await api.post("/jobapplications", { jobTitle, companyId: selectedCompany.id, status, location, salary, - nextAction, - followUpAt: followUpAt || null, + nextAction: null, + followUpAt: null, jobUrl, description: description || null, - translatedDescription: translatedDescription || null, + translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null, descriptionLanguage: descriptionLanguage || null, tags: tags.length ? JSON.stringify(tags) : null, deadline: deadline || null, notes, - coverLetterText: coverLetter, + coverLetterText: null, dateApplied, - hasResume, - hasCoverLetter, - hasPortfolio, - hasOtherAttachment, + 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) { + try { + await uploadAttachments(response.data.id); + toast(t("addJobModalJobAndFilesAdded"), "success"); + } catch { + toast(t("addJobModalJobCreatedUploadFailed"), "warning"); + } + } else if (attachmentCount > 0) { + toast(t("addJobModalJobCreatedFilesNotAttached"), "warning"); + } else { + toast(t("addJobModalJobAdded"), "success"); + } + onCreated(); - toast("Job added.", "success"); resetForm(); - if (!saveAndAddAnother) { + if (!addAnother) { onClose(); } } catch { - toast("Failed to add job.", "error"); + toast(t("addJobModalFailedAddJob"), "error"); } finally { setSaving(false); } @@ -277,12 +320,64 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { const canSave = normalizedCompanyName.length > 0 && jobTitle.trim().length > 0; + const setFilesForBucket = (bucket: AttachmentBucketKey, files: FileList | null) => { + setAttachments((prev) => ({ + ...prev, + [bucket]: files ? Array.from(files) : [], + })); + }; + + const statusLabel = (value: typeof STATUS_OPTIONS[number]) => { + const map = { + Applied: t("statusApplied"), + Waiting: t("statusWaiting"), + Interview: t("statusInterview"), + Offer: t("statusOffer"), + Rejected: t("statusRejected"), + Ghosted: t("statusGhosted"), + } as const; + return map[value]; + }; + + const filesLabel = (files: File[]) => { + if (files.length === 0) return t("addJobModalNoFilesSelected"); + if (files.length === 1) return files[0].name; + return t("addJobModalFilesSelected", { count: files.length }); + }; + + const uploadField = ( + bucket: AttachmentBucketKey, + label: string, + helperText: string, + ) => ( + + + + {label} + {helperText} + + + + + {filesLabel(attachments[bucket])} + + + ); + return ( - {t("addJob")} - - - Company + + {t("addJob")} + + + + + + + {t("addJobModalCompanySection")} @@ -312,12 +407,12 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { /> {showNewCompanyFields ? ( - - setNewCompanyLocation(e.target.value)} /> - setNewCompanySource(e.target.value)} /> + + setNewCompanyLocation(e.target.value)} /> + setNewCompanySource(e.target.value)} /> @@ -325,7 +420,7 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { {duplicateCheck?.hasDuplicates ? ( - Possible duplicates found + {t("addJobModalPossibleDuplicates")} {duplicateCheck.matches.map((match) => ( @@ -342,68 +437,96 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) { - Job application + {t("addJobModalJobApplicationSection")} - - setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> + + setJobUrl(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> - setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> + setDateApplied(e.target.value)} InputLabelProps={{ shrink: true }} /> - setStatus(e.target.value as any)}> + setStatus(e.target.value as any)}> {STATUS_OPTIONS.map((s) => ( - {s} + {statusLabel(s)} ))} - setJobTitle(e.target.value)} /> + setJobTitle(e.target.value)} /> setLocation(e.target.value)} /> - setSalary(e.target.value)} /> - setNextAction(e.target.value)} /> - - setFollowUpAt(e.target.value)} InputLabelProps={{ shrink: true }} /> - - setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> + setSalary(e.target.value)} /> + setDeadline(e.target.value)} InputLabelProps={{ shrink: true }} /> - setDescription(e.target.value)} helperText={`${description.length} characters`} sx={{ gridColumn: "1 / -1" }} /> - setTranslatedDescription(e.target.value)} helperText={`${translatedDescription.length} characters`} sx={{ gridColumn: "1 / -1" }} /> - setDescriptionLanguage(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> - setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} /> - setCoverLetter(e.target.value)} helperText={`${coverLetter.length} characters`} sx={{ gridColumn: "1 / -1" }} /> + setDescription(e.target.value)} + helperText={`${description.length} characters`} + sx={{ gridColumn: "1 / -1" }} + /> + + {shouldShowTranslatedDescription ? ( + setTranslatedDescription(e.target.value)} + helperText={`${translatedDescription.length} characters`} + sx={{ gridColumn: "1 / -1" }} + /> + ) : null} + + setDescriptionLanguage(e.target.value)} + helperText={shouldShowTranslatedDescription ? t("addJobModalTranslatedShown", { language: preferredLanguage.toUpperCase() }) : t("addJobModalTranslatedHidden")} + sx={{ gridColumn: "1 / -1" }} + /> + + setNotes(e.target.value)} helperText={`${notes.length} characters`} sx={{ gridColumn: "1 / -1" }} /> - Attachments checklist - - setHasResume(e.target.checked)} />} label="Resume" /> - setHasCoverLetter(e.target.checked)} />} label="Cover letter" /> - setHasPortfolio(e.target.checked)} />} label="Portfolio" /> - setHasOtherAttachment(e.target.checked)} />} label="Other" /> + {t("addJobModalDocuments")} + + {uploadField("resume", t("addJobModalResume"), t("addJobModalResumeHelp"))} + {uploadField("coverLetter", t("addJobModalCoverLetter"), t("addJobModalCoverLetterHelp"))} + {uploadField("portfolio", t("addJobModalPortfolio"), t("addJobModalPortfolioHelp"))} + {uploadField("other", t("addJobModalOtherFiles"), t("addJobModalOtherFilesHelp"))} - - - - setSaveAndAddAnother(e.target.checked)} />} label="Save and add another" /> - - - + + + + + + + + + + + ); } diff --git a/job-tracker-ui/src/components/CompaniesTable.tsx b/job-tracker-ui/src/components/CompaniesTable.tsx index 48b2eb1..b602334 100644 --- a/job-tracker-ui/src/components/CompaniesTable.tsx +++ b/job-tracker-ui/src/components/CompaniesTable.tsx @@ -19,13 +19,15 @@ import { IconButton, } from "@mui/material"; -import { api } from "../api"; +import { api, getApiErrorMessage } from "../api"; import { Company } from "../types"; import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; export default function CompaniesTable() { const { toast } = useToast(); + const { t } = useI18n(); const location = useLocation(); const navigate = useNavigate(); const [companies, setCompanies] = useState([]); @@ -40,8 +42,8 @@ export default function CompaniesTable() { const [nextContactAt, setNextContactAt] = useState(""); useEffect(() => { - api.get("/companies").then((r) => setCompanies(r.data)); - }, []); + api.get("/companies").then((r) => setCompanies(r.data)).catch((error) => toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error")); + }, [t, toast]); useEffect(() => { const params = new URLSearchParams(location.search); @@ -83,11 +85,11 @@ export default function CompaniesTable() { }); setCompanies((prev) => prev.map((x) => (x.id === res.data.id ? res.data : x))); - toast("Company updated.", "success"); + toast(t("companiesUpdated"), "success"); setEditOpen(false); setEditing(null); - } catch { - toast("Failed to update company.", "error"); + } catch (error) { + toast(getApiErrorMessage(error, t("companiesUpdateFailed")), "error"); } }; @@ -96,12 +98,12 @@ export default function CompaniesTable() { - Name - Location - Source - Pipeline - Recruiter - Next Contact + {t("companiesName")} + {t("companiesLocation")} + {t("companiesSource")} + {t("companiesPipeline")} + {t("companiesRecruiter")} + {t("companiesNextContact")} @@ -128,7 +130,7 @@ export default function CompaniesTable() { - No companies yet. + {t("companiesEmpty")} @@ -137,57 +139,57 @@ export default function CompaniesTable() {
setEditOpen(false)} fullWidth maxWidth="sm"> - Edit Company + {t("companiesEdit")} setEditing((p) => (p ? { ...p, name: e.target.value } : p))} sx={{ gridColumn: "1 / -1" }} /> setEditing((p) => (p ? { ...p, location: e.target.value } : p))} /> setEditing((p) => (p ? { ...p, source: e.target.value } : p))} /> setPipelineStage(e.target.value)} /> setRecruiterName(e.target.value)} /> setRecruiterEmail(e.target.value)} /> setRecruiterLinkedIn(e.target.value)} sx={{ gridColumn: "1 / -1" }} /> setLastContactedAt(e.target.value)} InputLabelProps={{ shrink: true }} /> setNextContactAt(e.target.value)} @@ -196,9 +198,9 @@ export default function CompaniesTable() { - + diff --git a/job-tracker-ui/src/components/CropImageDialog.tsx b/job-tracker-ui/src/components/CropImageDialog.tsx new file mode 100644 index 0000000..26e05bc --- /dev/null +++ b/job-tracker-ui/src/components/CropImageDialog.tsx @@ -0,0 +1,224 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Slider, + Typography, +} from "@mui/material"; + +import { useI18n } from "../i18n/I18nProvider"; + +const CROPPER_SIZE = 280; +const OUTPUT_SIZE = 512; + +type DragState = { + startX: number; + startY: number; + originX: number; + originY: number; +}; + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export default function CropImageDialog({ + open, + file, + onClose, + onSave, +}: { + open: boolean; + file: File | null; + onClose: () => void; + onSave: (blob: Blob) => Promise | void; +}) { + const { t } = useI18n(); + const [imageUrl, setImageUrl] = useState(null); + const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null); + const [zoom, setZoom] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [saving, setSaving] = useState(false); + const dragRef = useRef(null); + const imgRef = useRef(null); + + useEffect(() => { + if (!file || !open) { + setImageUrl(null); + setImageSize(null); + setZoom(1); + setPosition({ x: 0, y: 0 }); + return; + } + + const url = URL.createObjectURL(file); + setImageUrl(url); + setImageSize(null); + setZoom(1); + setPosition({ x: 0, y: 0 }); + + return () => URL.revokeObjectURL(url); + }, [file, open]); + + const rendered = useMemo(() => { + if (!imageSize) return null; + const scale = Math.max(CROPPER_SIZE / imageSize.width, CROPPER_SIZE / imageSize.height) * zoom; + const width = imageSize.width * scale; + const height = imageSize.height * scale; + return { scale, width, height }; + }, [imageSize, zoom]); + + useEffect(() => { + if (!rendered) return; + const minX = Math.min(0, CROPPER_SIZE - rendered.width); + const minY = Math.min(0, CROPPER_SIZE - rendered.height); + setPosition((prev) => ({ + x: clamp(prev.x, minX, 0), + y: clamp(prev.y, minY, 0), + })); + }, [rendered]); + + const beginDrag = (clientX: number, clientY: number) => { + dragRef.current = { + startX: clientX, + startY: clientY, + originX: position.x, + originY: position.y, + }; + }; + + const moveDrag = useCallback((clientX: number, clientY: number) => { + if (!dragRef.current || !rendered) return; + const minX = Math.min(0, CROPPER_SIZE - rendered.width); + const minY = Math.min(0, CROPPER_SIZE - rendered.height); + const nextX = dragRef.current.originX + (clientX - dragRef.current.startX); + const nextY = dragRef.current.originY + (clientY - dragRef.current.startY); + setPosition({ x: clamp(nextX, minX, 0), y: clamp(nextY, minY, 0) }); + }, [rendered]); + + useEffect(() => { + const onMouseMove = (event: MouseEvent) => moveDrag(event.clientX, event.clientY); + const onTouchMove = (event: TouchEvent) => { + const touch = event.touches[0]; + if (touch) moveDrag(touch.clientX, touch.clientY); + }; + const stop = () => { + dragRef.current = null; + }; + + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", stop); + window.addEventListener("touchmove", onTouchMove, { passive: false }); + window.addEventListener("touchend", stop); + + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", stop); + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", stop); + }; + }, [moveDrag]); + + const exportCrop = async () => { + if (!file || !rendered) return; + const image = imgRef.current; + if (!image) return; + + const canvas = document.createElement("canvas"); + canvas.width = OUTPUT_SIZE; + canvas.height = OUTPUT_SIZE; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const cropX = -position.x / rendered.scale; + const cropY = -position.y / rendered.scale; + const cropWidth = CROPPER_SIZE / rendered.scale; + const cropHeight = CROPPER_SIZE / rendered.scale; + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE); + + setSaving(true); + try { + const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png", 0.95)); + if (!blob) return; + await onSave(blob); + } finally { + setSaving(false); + } + }; + + return ( + + {t("cropDialogTitle")} + + + {t("cropDialogBody")} + + + + beginDrag(e.clientX, e.clientY)} + onTouchStart={(e) => { + const touch = e.touches[0]; + if (touch) beginDrag(touch.clientX, touch.clientY); + }} + > + {imageUrl ? ( + Crop preview { + const target = e.currentTarget; + setImageSize({ width: target.naturalWidth, height: target.naturalHeight }); + }} + draggable={false} + style={{ + position: "absolute", + left: position.x, + top: position.y, + width: rendered?.width ?? "auto", + height: rendered?.height ?? "auto", + maxWidth: "none", + pointerEvents: "none", + }} + /> + ) : null} + + + + + {t("cropDialogZoom")} + setZoom(value as number)} /> + + + + + + + + ); +} diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 2e6ee9e..5e4e27f 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -1,16 +1,12 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Button, - ButtonGroup, Checkbox, Menu, MenuItem, Paper, - Tab, - Tabs, - TextField, Typography, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; @@ -18,6 +14,7 @@ import TuneIcon from "@mui/icons-material/Tune"; import { api } from "../api"; import { getUserKeyFromToken } from "../themePrefs"; +import { useI18n } from "../i18n/I18nProvider"; interface JobStats { total: number; @@ -36,24 +33,6 @@ type ReminderJob = { type AnalyticsPoint = { month: string; applied: number; responses: number }; type TagPoint = { tag: string; count: number }; -type SummarizerMetrics = { - healthy: boolean; - model?: string | null; - healthLatencyMs?: number | null; - probeLatencyMs?: number | null; - lastProbeAt?: string | null; - lastProbeSuccessAt?: string | null; - lastProbeFailureAt?: string | null; - probeFailures: number; - requests: number; - cacheHits: number; - cacheMisses: number; - failures: number; - averageLatencyMs?: number | null; - lastSuccessAt?: string | null; - lastFailureAt?: string | null; - lastError?: string | null; -}; type OverviewAnalytics = { funnel: { label: string; count: number }[]; responseRateBySource: { label: string; total: number; responses: number; rate: number }[]; @@ -106,32 +85,15 @@ function toPath(values: number[], w: number, h: number) { return values.map((v, i) => `${i === 0 ? "M" : "L"} ${Math.round(i * dx)} ${Math.round(norm(v))}`).join(" "); } -function formatRelative(ts?: string | null) { - if (!ts) return "Never"; - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return "Unknown"; - const mins = Math.round((Date.now() - d.getTime()) / 60000); - if (mins < 1) return "Just now"; - if (mins < 60) return `${mins}m ago`; - const hours = Math.round(mins / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.round(hours / 24)}d ago`; -} - export default function DashboardView() { const theme = useTheme(); + const { t } = useI18n(); const [stats, setStats] = useState(null); const [overview, setOverview] = useState(null); const [tagTrends, setTagTrends] = useState(null); - const [tab, setTab] = useState(0); - const [rangeMode, setRangeMode] = useState<"preset" | "custom">("preset"); - const [months, setMonths] = useState<6 | 12 | 24>(12); - const [fromMonth, setFromMonth] = useState(() => new Date(new Date().getFullYear(), new Date().getMonth() - 11, 1).toISOString().slice(0, 7)); - const [toMonth, setToMonth] = useState(() => new Date().toISOString().slice(0, 7)); - const [appliedCustom, setAppliedCustom] = useState<{ from: string; to: string } | null>(null); const [analytics, setAnalytics] = useState([]); const [tags, setTags] = useState([]); - const [summarizerMetrics, setSummarizerMetrics] = useState(null); + const [months, setMonths] = useState<6 | 12 | 24>(12); const [reminderJobs, setReminderJobs] = useState([]); const [prefs, setPrefs] = useState(() => loadPrefs()); const [prefsAnchor, setPrefsAnchor] = useState(null); @@ -143,34 +105,12 @@ export default function DashboardView() { }, []); useEffect(() => { - const params = rangeMode === "custom" && appliedCustom ? { from: `${appliedCustom.from}-01`, to: `${appliedCustom.to}-01` } : { months }; - + const params = { months }; api.get("/jobapplications/analytics", { params }).then((r) => setAnalytics(r.data ?? [])).catch(() => setAnalytics([])); api.get("/jobapplications/tags", { params: { limit: 10, ...params } }).then((r) => setTags(r.data ?? [])).catch(() => setTags([])); - api.get("/jobapplications/tag-trends", { params: { months: rangeMode === "custom" ? 6 : months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null)); - }, [months, rangeMode, appliedCustom]); + api.get("/jobapplications/tag-trends", { params: { months, limit: 5 } }).then((r) => setTagTrends(r.data)).catch(() => setTagTrends(null)); + }, [months]); - useEffect(() => { - if (tab !== 2) return; - let cancelled = false; - const load = async () => { - try { - const res = await api.get("/jobapplications/summarizer-metrics"); - if (!cancelled) setSummarizerMetrics(res.data); - } catch { - if (!cancelled) setSummarizerMetrics(null); - } - }; - void load(); - const id = window.setInterval(() => void load(), 30000); - return () => { - cancelled = true; - window.clearInterval(id); - }; - }, [tab]); - - const statusRows = useMemo(() => Object.entries(stats?.byStatus ?? {}).sort((a, b) => b[1] - a[1]), [stats]); - const maxStatus = statusRows.length ? Math.max(...statusRows.map(([, v]) => v)) : 0; const chartW = 860; const chartH = 260; const appliedSeries = analytics.map((x) => x.applied); @@ -182,11 +122,11 @@ export default function DashboardView() { const funnelMax = overview?.funnel?.length ? Math.max(...overview.funnel.map((x) => x.count)) : 0; const metricCards = [ - { label: "Active applications", value: stats?.active ?? "-", sub: "Currently in progress" }, - { label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" }, - { label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" }, - { label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" }, - { label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" }, + { label: t("dashboardActiveApplications"), value: stats?.active ?? "-", sub: t("dashboardCurrentlyInProgress") }, + { label: t("dashboardApplied30Days"), value: stats?.appliedLast30Days ?? "-", sub: t("dashboardNewApplications") }, + { label: t("dashboardMedianFirstResponse"), value: overview?.medianDaysToFirstResponse ?? "-", sub: t("dashboardDaysUntilFirstReply") }, + { label: t("dashboardResponsesLogged"), value: overview?.totalResponses ?? 0, sub: t("dashboardAcrossActiveJobs") }, + { label: t("dashboardLowReadiness"), value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: t("dashboardMissingTailoredCv") }, ]; const togglePref = (key: keyof Prefs) => { @@ -205,24 +145,29 @@ export default function DashboardView() { return ( - setTab(v)} sx={{ mb: 2 }}> - - - - - - {tab !== 2 ? ( - + + + {t("dashboardOverviewTitle")} + + {t("dashboardOverviewBody")} + + + + {([6, 12, 24] as const).map((m) => ( + + ))} setPrefsAnchor(null)}> {[ - ["cards", "Summary cards"], - ["activity", "Activity chart"], - ["funnel", "Conversion funnel"], - ["companies", "Top companies"], - ["skills", "Skills insights"], + ["cards", t("dashboardSummaryCards")], + ["activity", t("dashboardActivityChart")], + ["funnel", t("dashboardConversionFunnel")], + ["companies", t("dashboardTopCompanies")], + ["skills", t("dashboardSkillsInsights")], ].map(([key, label]) => ( togglePref(key as keyof Prefs)}> @@ -231,208 +176,136 @@ export default function DashboardView() { ))} - ) : null} + - {tab === 0 ? ( - <> - {prefs.cards ? ( - - - {metricCards.map((m, idx) => ( - - - - ))} + {prefs.cards ? ( + + + {metricCards.map((m, idx) => ( + + - - ) : null} - - {prefs.activity ? ( - - - - Application activity - Monthly applications versus responses. - - - - {([6, 12, 24] as const).map((m) => ( - - ))} - - - {rangeMode === "custom" ? ( - <> - setFromMonth(e.target.value)} /> - setToMonth(e.target.value)} /> - - - ) : null} - - - - - - {[0.25, 0.5, 0.75].map((tick) => )} - {responsePath ? : null} - {appliedPath ? : null} - - {analytics.map((p) => {p.month.slice(5)})} - - - - ) : null} - - - {prefs.funnel ? ( - - Conversion funnel - - {(overview?.funnel ?? []).map((item) => ( - - {item.label} - - - - {item.count} - - ))} - - Response sources - - {(overview?.responseRateBySource ?? []).map((item) => ( - - {item.label} - {item.rate}% - - ))} - - - ) : null} - - {prefs.companies ? ( - - Top companies by activity - - {(overview?.topCompanies ?? []).map((item) => ( - - {item.company} - {item.count} jobs - {item.responseRate}% - - ))} - - - ) : null} + ))} - - {prefs.skills ? ( - - - - Top skills - {tags.length === 0 ? No tags yet. : ( - - - - - {(() => { - const r = 52; - const circ = 2 * Math.PI * r; - let offset = 0; - return tags.map((t, i) => { - const len = circ * (tagTotal ? t.count / tagTotal : 0); - const el = ; - offset += len; - return el; - }); - })()} - - {tagTotal} - skill tags - - - - {tags.slice(0, 8).map((t, i) => {t.tag}{t.count})} - - - )} - - - Skill trends - {!tagTrends || tagTrends.series.length === 0 ? No tag trend data yet. : ( - - {tagTrends.series.map((series, idx) => ( - - - {series.tag} - {series.counts.reduce((a, b) => a + b, 0)} total - - - {series.counts.map((count, i) => ( - 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} /> - ))} - - - ))} - - {tagTrends.months.map((month) => {month.slice(5)})} - - - )} - - - - ) : null} - + ) : null} - {tab === 1 ? ( - - - - Status breakdown - {statusRows.length === 0 ? No data yet. : ( - - {statusRows.map(([status, value]) => { - const tone = status === "Rejected" ? theme.palette.error.main : status === "Waiting" || status === "Ghosted" ? theme.palette.warning.main : status === "Offer" ? theme.palette.success.main : status === "Interview" ? theme.palette.info.main : theme.palette.primary.main; - const w = maxStatus ? clamp(Math.round((value / maxStatus) * 100), 0, 100) : 0; - return {status}{value}; - })} - - )} - - - Response rate by source - - {(overview?.responseRateBySource ?? []).map((item) => ( - - {item.label} - {item.responses} responses from {item.total} jobs - {item.rate}% - - ))} - + {prefs.activity ? ( + + {t("dashboardApplicationActivity")} + {t("dashboardMonthlyApplicationsResponses")} + + + + {[0.25, 0.5, 0.75].map((tick) => )} + {responsePath ? : null} + {appliedPath ? : null} + + {analytics.map((p) => {p.month.slice(5)})} ) : null} - {tab === 2 ? ( - - - {[{ label: "Service status", value: summarizerMetrics?.healthy ? "Healthy" : "Offline", sub: summarizerMetrics?.model || "Summarizer health check" }, { label: "Health latency", value: summarizerMetrics?.healthLatencyMs != null ? `${summarizerMetrics.healthLatencyMs} ms` : "-", sub: "Latest /health round-trip" }, { label: "Probe latency", value: summarizerMetrics?.probeLatencyMs != null ? `${summarizerMetrics.probeLatencyMs} ms` : "-", sub: "Periodic small summarize request" }, { label: "Last success", value: formatRelative(summarizerMetrics?.lastProbeSuccessAt || summarizerMetrics?.lastSuccessAt), sub: "Recent successful latency sample" }].map((m) => {m.label}{m.value}{m.sub})} - - - Telemetry - Requests: {summarizerMetrics?.requests ?? 0} - Cache hits: {summarizerMetrics?.cacheHits ?? 0} - Cache misses: {summarizerMetrics?.cacheMisses ?? 0} - Failures: {summarizerMetrics?.failures ?? 0} - Probe failures: {summarizerMetrics?.probeFailures ?? 0} - Last failure: {formatRelative(summarizerMetrics?.lastFailureAt)} - {summarizerMetrics?.lastError || "No recent summarizer errors recorded."} + + {prefs.funnel ? ( + + {t("dashboardConversionFunnelTitle")} + + {(overview?.funnel ?? []).map((item) => ( + + {item.label} + + + + {item.count} + + ))} + + {t("dashboardResponseSources")} + + {(overview?.responseRateBySource ?? []).map((item) => ( + + {item.label} + {item.rate}% + + ))} + + ) : null} + + {prefs.companies ? ( + + {t("dashboardTopCompaniesByActivity")} + + {(overview?.topCompanies ?? []).map((item) => ( + + {item.company} + {item.count} jobs + {item.responseRate}% + + ))} + + + ) : null} + + + {prefs.skills ? ( + + + + {t("dashboardTopSkills")} + {tags.length === 0 ? {t("dashboardNoTagsYet")} : ( + + + + + {(() => { + const r = 52; + const circ = 2 * Math.PI * r; + let offset = 0; + return tags.map((tItem, i) => { + const len = circ * (tagTotal ? tItem.count / tagTotal : 0); + const el = ; + offset += len; + return el; + }); + })()} + + {tagTotal} + {t("dashboardSkillTags")} + + + + {tags.slice(0, 8).map((tItem, i) => {tItem.tag}{tItem.count})} + + + )} + + + {t("dashboardSkillTrends")} + {!tagTrends || tagTrends.series.length === 0 ? {t("dashboardNoTagTrendData")} : ( + + {tagTrends.series.map((series, idx) => ( + + + {series.tag} + {series.counts.reduce((a, b) => a + b, 0)} total + + + {series.counts.map((count, i) => ( + 0 ? alpha(tagColors[idx % tagColors.length], 0.25 + Math.min(0.6, count / 10)) : alpha(theme.palette.text.primary, 0.06) }} title={`${tagTrends.months[i]}: ${count}`} /> + ))} + + + ))} + + {tagTrends.months.map((month) => {month.slice(5)})} + + + )} + + ) : null} diff --git a/job-tracker-ui/src/components/EditJobDialog.tsx b/job-tracker-ui/src/components/EditJobDialog.tsx index c6698ca..7b73eb7 100644 --- a/job-tracker-ui/src/components/EditJobDialog.tsx +++ b/job-tracker-ui/src/components/EditJobDialog.tsx @@ -22,6 +22,7 @@ import { Company, JobApplication } from "../types"; import { useToast } from "../toast"; import { useCompanies } from "../hooks/useCompanies"; import TagsInput from "./TagsInput"; +import { useI18n } from "../i18n/I18nProvider"; interface Props { open: boolean; @@ -53,6 +54,7 @@ function parseTags(raw: any): string[] { export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) { const { toast } = useToast(); + const { t } = useI18n(); const [loading, setLoading] = useState(false); const { companies } = useCompanies(); @@ -144,7 +146,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) dateApplied: dateApplied || null, jobUrl: jobUrl.trim() || null, }); - toast("Saved.", "success"); + toast(t("save"), "success"); onSaved(); onClose(); } catch { @@ -156,7 +158,7 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) return ( - Edit job + {t("editJobTitle")} @@ -221,8 +223,8 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props) - - + + ); diff --git a/job-tracker-ui/src/components/GoogleAuthCard.tsx b/job-tracker-ui/src/components/GoogleAuthCard.tsx index dc1e74d..9e6b0e7 100644 --- a/job-tracker-ui/src/components/GoogleAuthCard.tsx +++ b/job-tracker-ui/src/components/GoogleAuthCard.tsx @@ -5,6 +5,7 @@ import { Box, Button, Chip, Paper, Typography } from "@mui/material"; import { api } from "../api"; import { clearAuthToken, decodeJwtPayload, getAuthToken, setAuthToken } from "../auth"; import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; declare global { interface Window { @@ -46,6 +47,7 @@ function loadGoogleScript(): Promise { export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void }) { const { toast } = useToast(); + const { t } = useI18n(); const [token, setToken] = useState(() => getAuthToken()); const [me, setMe] = useState(null); const [working, setWorking] = useState(false); @@ -81,20 +83,20 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void if (cancelled) return; setAuthToken(res.data.accessToken); setToken(res.data.accessToken); - toast("Signed in with Google.", "success"); + toast(t("googleSignedIn"), "success"); onSignedIn?.(); } catch { if (cancelled) return; clearAuthToken(); setToken(null); - toast("This Google account is not linked yet. Sign in locally first to bind it.", "info"); + toast(t("googleNotLinkedYet"), "info"); } }; void exchange(); return () => { cancelled = true; }; - }, [token, isRawGoogleToken, onSignedIn, toast]); + }, [token, isRawGoogleToken, onSignedIn, toast, t]); useEffect(() => { const host = hostRef.current; @@ -102,9 +104,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void const shouldRenderButton = !token || isRawGoogleToken || (me?.provider === "local" && !me?.googleLink?.linked); host.replaceChildren(); - if (!shouldRenderButton) { - return; - } + if (!shouldRenderButton) return; let active = true; void loadGoogleScript() @@ -120,17 +120,17 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void try { if (me?.provider === "local") { const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/google/link", { token: credential }); - toast(res.data?.email ? `Linked Google account ${res.data.email}.` : "Google account linked.", "success"); + toast(res.data?.email ? t("googleLinkedSuccessWithEmail", { email: res.data.email }) : t("googleLinkedSuccess"), "success"); await refreshMe(); } else { const res = await api.post<{ accessToken: string }>("/auth/google/exchange", { token: credential }); setAuthToken(res.data.accessToken); setToken(res.data.accessToken); - toast("Signed in with Google.", "success"); + toast(t("googleSignedIn"), "success"); onSignedIn?.(); } } catch (e: any) { - const msg = e?.response?.data || e?.message || "Google authentication failed."; + const msg = e?.response?.data || e?.message || t("googleAuthFailed"); toast(String(msg), "error"); } finally { setWorking(false); @@ -145,46 +145,48 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void text: me?.provider === "local" ? "continue_with" : "signin_with", }); }) - .catch(() => toast("Google auth script failed to load.", "error")); + .catch(() => toast(t("googleScriptLoadFailed"), "error")); return () => { active = false; host.replaceChildren(); }; - }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast]); + }, [clientId, me?.provider, me?.googleLink?.linked, onSignedIn, isRawGoogleToken, token, toast, t]); + + const signedInName = me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || ""; return ( - Google account + {t("googleAccountTitle")} {!clientId && ( - Set `REACT_APP_GOOGLE_CLIENT_ID` in your UI environment to enable Google sign-in and account linking. + {t("googleSetupHint")} )} {clientId && ( - - {me?.googleLink?.linkedAt ? : null} + + {me?.googleLink?.linkedAt ? : null} {!token ? ( - Sign in with a Google account that has already been linked to your Job Tracker user. + {t("googleSignInHint")} ) : me?.provider === "local" ? ( {me.googleLink?.linked - ? `Linked to ${me.googleLink.email || "your Google account"}.` - : "Bind a Google account to this user so you can sign in with Google and still keep your normal app roles and data."} + ? t("googleLinkedTo", { email: me.googleLink.email || t("googleLinkedToYourAccount") }) + : t("googleBindHint")} ) : ( - Exchange your Google sign-in for a normal Job Tracker session. + {t("googleExchangeHint")} )} @@ -198,10 +200,10 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void clearAuthToken(); setToken(null); setMe(null); - toast("Signed out.", "info"); + toast(t("signedOut"), "info"); }} > - Sign out + {t("signOut")} ) : null} @@ -213,22 +215,22 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void onClick={async () => { try { await api.delete("/auth/google/link"); - toast("Google account unlinked.", "info"); + toast(t("googleUnlinked"), "info"); await refreshMe(); } catch (e: any) { - const msg = e?.response?.data || e?.message || "Failed to unlink Google account."; + const msg = e?.response?.data || e?.message || t("googleUnlinkFailed"); toast(String(msg), "error"); } }} > - Unlink Google + {t("unlinkGoogle")} ) : null} {token && me?.email ? ( - Signed in as {me.displayName || [me.firstName, me.lastName].filter(Boolean).join(" ") || me.email}. + {t("signedInAs", { name: signedInName })} ) : null} diff --git a/job-tracker-ui/src/components/ImportExportJobs.tsx b/job-tracker-ui/src/components/ImportExportJobs.tsx index fd870f9..bd9e894 100644 --- a/job-tracker-ui/src/components/ImportExportJobs.tsx +++ b/job-tracker-ui/src/components/ImportExportJobs.tsx @@ -5,6 +5,7 @@ import { Box, Button, Paper, Typography } from "@mui/material"; import { api } from "../api"; import { Company, JobApplication } from "../types"; import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; type ImportJob = Omit & { company: Pick; @@ -32,7 +33,6 @@ function parseCsv(text: string): string[][] { cur = ""; }; const pushRow = () => { - // ignore trailing empty rows if (row.length === 1 && row[0] === "" && rows.length > 0) { row = []; return; @@ -91,15 +91,13 @@ function parseCsv(text: string): string[][] { function parseDateDMY(s: string): string | null { const v = (s || "").trim(); if (!v) return null; - // expects dd/MM/yyyy const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(v); if (!m) return null; const dd = Number(m[1]); const mm = Number(m[2]); const yyyy = Number(m[3]); if (!yyyy || mm < 1 || mm > 12 || dd < 1 || dd > 31) return null; - const iso = `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`; - return iso; + return `${String(yyyy).padStart(4, "0")}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`; } function csvToImportJobs(csvText: string): ImportJob[] { @@ -122,7 +120,6 @@ function csvToImportJobs(csvText: string): ImportJob[] { const iInterviewDate = idx("Interview Date"); const out: ImportJob[] = []; - for (let r = 1; r < rows.length; r++) { const row = rows[r]; const get = (i: number) => (i >= 0 ? (row[i] ?? "").trim() : ""); @@ -132,9 +129,7 @@ function csvToImportJobs(csvText: string): ImportJob[] { if (!jobTitle && !companyName) continue; const rawStatus = get(iStatus); - const status = - rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied"; - + const status = rawStatus === "Follow-up Needed" ? "Waiting" : rawStatus || "Applied"; const dateApplied = parseDateDMY(get(iDateApplied)) ?? new Date().toISOString().slice(0, 10); const responseReceived = /^yes$/i.test(get(iResp)); const responseDate = parseDateDMY(get(iRespDate)) ?? undefined; @@ -179,6 +174,7 @@ function csvToImportJobs(csvText: string): ImportJob[] { export default function ImportExportJobs() { const { toast } = useToast(); + const { t } = useI18n(); const [importing, setImporting] = useState(false); const [lastImportCount, setLastImportCount] = useState(null); @@ -188,17 +184,11 @@ export default function ImportExportJobs() { const url = `/export/jobs?format=${format}&includeDeleted=false`; const res = await api.get(url, { responseType: "blob" }); - if (format === "json") { - const text = await res.data.text(); - downloadText(`job-tracker-export-${stamp}.json`, text, "application/json"); - } else { - const text = await res.data.text(); - downloadText(`job-tracker-export-${stamp}.csv`, text, "text/csv"); - } - - toast(`Exported jobs (${format.toUpperCase()}).`, "success"); + const text = await res.data.text(); + downloadText(`job-tracker-export-${stamp}.${format}`, text, format === "json" ? "application/json" : "text/csv"); + toast(t("exportedJobs", { format: format.toUpperCase() }), "success"); } catch { - toast("Export failed.", "error"); + toast(t("exportFailed"), "error"); } }; @@ -215,7 +205,6 @@ export default function ImportExportJobs() { for (const j of parsed) { const companyRes = await api.post("/companies", j.company); const company = companyRes.data; - await api.post("/jobapplications", { jobTitle: j.jobTitle, companyId: company.id, @@ -233,9 +222,9 @@ export default function ImportExportJobs() { } setLastImportCount(created); - toast(`Imported ${created} jobs.`, "success"); + toast(t("importedJobs", { count: created }), "success"); } catch { - toast("Import failed (expecting exported JSON array).", "error"); + toast(t("importFailedJson"), "error"); } finally { setImporting(false); } @@ -247,63 +236,41 @@ export default function ImportExportJobs() { const text = await file.text(); const jobs = csvToImportJobs(text); const stamp = new Date().toISOString().slice(0, 10); - downloadText( - `job-tracker-import-${stamp}.json`, - JSON.stringify(jobs, null, 2), - "application/json", - ); - toast(`Converted ${jobs.length} rows to import JSON.`, "success"); + downloadText(`job-tracker-import-${stamp}.json`, JSON.stringify(jobs, null, 2), "application/json"); + toast(t("convertedRows", { count: jobs.length }), "success"); } catch { - toast("CSV conversion failed.", "error"); + toast(t("csvConversionFailed"), "error"); } }; - const helper = useMemo( - () => - "Import expects the JSON exported by this app (an array of job objects with embedded company).", - [], - ); + const helper = useMemo(() => t("importExportBody"), [t]); return ( - Import / Export + {t("importExportTitle")} {helper} - - + + {lastImportCount !== null && ( - Last import: {lastImportCount} + {t("lastImport", { count: lastImportCount })} )} diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 3b8aa21..e4f6897 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -26,6 +26,7 @@ import { useDialogActions } from "../dialogs"; import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; import JobFlowBar from "./JobFlowBar"; +import { useI18n } from "../i18n/I18nProvider"; type FollowUpDraft = { subject: string; @@ -70,6 +71,7 @@ function copyLines(items: string[]) { export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const { toast } = useToast(); + const { t } = useI18n(); const { confirmAction } = useDialogActions(); const [job, setJob] = useState(null); @@ -152,7 +154,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { } })(); - const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application"; + const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : t("addJobApplication"); const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || ""; const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet."; const translatedDescriptionText = job?.translatedDescription?.trim() || ""; @@ -166,7 +168,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { - Job workspace + {t("jobTableOpen")} {title} {job && } @@ -179,14 +181,14 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { {summaryFirstText} setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile> - + - + - + {isAdmin ? : null} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index b878918..9b6e078 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -46,6 +46,7 @@ import EditJobDialog from "./EditJobDialog"; import { useToast } from "../toast"; import SavedViewsMenu, { SavedViewParams } from "./SavedViewsMenu"; import { useDialogActions } from "../dialogs"; +import { useI18n } from "../i18n/I18nProvider"; interface JobApplication { id: number; @@ -125,6 +126,7 @@ function statusTone(status: string): string { export default function JobTable({ refreshToken, pageSize, onPageSizeChange, columns, onColumnsChange, mode = "jobs" }: Props) { const theme = useTheme(); const { toast } = useToast(); + const { t } = useI18n(); const { confirmAction } = useDialogActions(); const location = useLocation(); const navigate = useNavigate(); @@ -218,39 +220,39 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col if (jobsToDelete.length === 0) return false; if (jobsToDelete.length === 1) { const job = jobsToDelete[0]; - return confirmAction(`Move "${job.jobTitle}" at ${job.company?.name ?? "this company"} to trash?`, { title: "Move job to trash", confirmLabel: "Move", destructive: true }); + return confirmAction(t("jobTableMoveOneConfirm", { title: job.jobTitle, company: job.company?.name ?? t("jobTableCompany") }), { title: t("jobTableMoveToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true }); } - return confirmAction(`Move ${jobsToDelete.length} selected jobs to trash?`, { title: "Move jobs to trash", confirmLabel: "Move", destructive: true }); + return confirmAction(t("jobTableMoveManyConfirm", { count: jobsToDelete.length }), { title: t("jobTableMoveJobsToTrashTitle"), confirmLabel: t("jobTableMove"), destructive: true }); }; const softDelete = async (job: JobApplication) => { if (!(await confirmDelete([job]))) return; try { await api.delete(`/jobapplications/${job.id}`); - toast("Job moved to trash.", "success", { label: "Undo", onClick: () => { void restore(job.id); } }); + toast(t("jobTableMovedToTrash"), "success", { label: "Undo", onClick: () => { void restore(job.id); } }); setReloadToken((t) => t + 1); } catch { - toast("Failed to delete job.", "error"); + toast(t("jobTableDeleteFailed"), "error"); } }; const restore = async (id: number) => { try { await api.post(`/jobapplications/${id}/restore`); - toast("Job restored.", "success"); + toast(t("jobTableRestored"), "success"); setReloadToken((t) => t + 1); } catch { - toast("Failed to restore job.", "error"); + toast(t("jobTableRestoreFailed"), "error"); } }; const setStatusQuick = async (id: number, status: string) => { try { await api.patch(`/jobapplications/${id}/status`, { status }); - toast(`Status set to ${status}.`, "success"); + toast(t("jobTableStatusSet", { status }), "success"); setReloadToken((t) => t + 1); } catch { - toast("Failed to update status.", "error"); + toast(t("jobTableStatusUpdateFailed"), "error"); } }; @@ -264,11 +266,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col if (action === "restore") return api.post(`/jobapplications/${id}/restore`); return api.patch(`/jobapplications/${id}/status`, { status: value }); })); - toast(`Updated ${selectedIds.length} jobs.`, "success"); + toast(t("jobTableUpdatedJobs", { count: selectedIds.length }), "success"); setReloadToken((t) => t + 1); setSelectedIds([]); } catch { - toast("Bulk action failed.", "error"); + toast(t("jobTableBulkActionFailed"), "error"); } }; @@ -282,55 +284,55 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col return ( - { setSearch(e.target.value); setPage(0); }} placeholder="Title, company, notes, messages" size="small" InputProps={{ startAdornment: }} sx={{ minWidth: 320, flex: "1 1 320px" }} /> + { setSearch(e.target.value); setPage(0); }} placeholder={t("jobTableSearchPlaceholder")} size="small" InputProps={{ startAdornment: }} sx={{ minWidth: 320, flex: "1 1 320px" }} /> - Status - { setStatusFilter(e.target.value); setPage(0); }}> + {[t("jobTableAll"), t("statusApplied"), t("statusWaiting"), t("statusInterview"), t("statusOffer"), t("statusRejected"), t("statusGhosted")].map((s) => {s})} - Company - { setCompanyFilterId(e.target.value as any); setPage(0); }}> + {t("jobTableAll")} {companies.map((c) => {c.name})} - { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} /> + { setLocationFilter(e.target.value); setPage(0); }} sx={{ minWidth: 200, flex: "1 1 200px" }} /> - {mode === "jobs" ? { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label="Needs follow-up" /> : null} + {mode === "jobs" ? { setNeedsFollowUpOnly(e.target.checked); setPage(0); }} />} label={t("jobTableNeedsFollowUp")} /> : null} {mode === "jobs" ? ( - Readiness - setReadinessFilter(e.target.value as any)}> + {t("jobTableAllReadiness")} + {t("jobTableNeedsWork")} + {t("jobTableInterviewStage")} ) : null} - {mode === "jobs" ? { setIncludeDeleted(e.target.checked); setPage(0); }} />} label="Show deleted" /> : null} + {mode === "jobs" ? { setIncludeDeleted(e.target.checked); setPage(0); }} />} label={t("jobTableShowDeleted")} /> : null} { setSearch(p.q ?? ""); setStatusFilter(p.status ?? "All"); setCompanyFilterId(p.companyId ?? "All"); setLocationFilter(p.location ?? ""); setNeedsFollowUpOnly(Boolean(p.needsFollowUp)); setPage(0); }} /> - setColumnsAnchor(e.currentTarget)}> + setColumnsAnchor(e.currentTarget)}> {selectedIds.length > 0 ? ( - {selectedIds.length} selected + {t("jobTableSelected", { count: selectedIds.length })} - {mode === "trash" ? : } + {mode === "trash" ? : } {mode === "jobs" ? ["Waiting", "Interview", "Rejected", "Ghosted", "Offer"].map((s) => ) : null} ) : null} setColumnsAnchor(null)}> - {([ ["status", "Status"], ["dateApplied", "Date applied"], ["daysSince", "Days"], ["jobUrl", "Job URL"] ] as const).map(([key, label]) => ( + {([ ["status", t("settingsColumnStatus")], ["dateApplied", t("settingsColumnDateApplied")], ["daysSince", t("settingsColumnDays")], ["jobUrl", t("settingsColumnJobUrl")] ] as const).map(([key, label]) => ( onColumnsChange({ ...columns, [key]: !columns[key] })}> {label} @@ -344,13 +346,13 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col 0 && !selectedAllOnPage} onChange={(e) => toggleSelectAll(e.target.checked)} /> - requestSort("company")}>Company - requestSort("jobTitle")}>Role - {columns.status ? requestSort("status")}>Status : null} - {columns.dateApplied ? requestSort("dateApplied")}>Date Applied : null} - {columns.daysSince ? requestSort("daysSince")}>Days : null} - {columns.jobUrl ? Job URL : null} - Actions + requestSort("company")}>{t("jobTableCompany")} + requestSort("jobTitle")}>{t("jobTableRole")} + {columns.status ? requestSort("status")}>{t("jobTableStatus")} : null} + {columns.dateApplied ? requestSort("dateApplied")}>{t("jobTableDateApplied")} : null} + {columns.daysSince ? requestSort("daysSince")}>{t("jobTableDays")} : null} + {columns.jobUrl ? {t("settingsColumnJobUrl")} : null} + {t("jobTableActions")} @@ -367,21 +369,21 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {job.jobTitle} - {job.needsFollowUp ? : null} - {!job.tailoredCvText && !job.isDeleted ? : null} - {job.tailoredCvText ? : null} + {job.needsFollowUp ? : null} + {!job.tailoredCvText && !job.isDeleted ? : null} + {job.tailoredCvText ? : null} {columns.status ? : null} {columns.dateApplied ? {new Date(job.dateApplied).toLocaleDateString()} : null} {columns.daysSince ? {job.daysSince} : null} - {columns.jobUrl ? {job.jobUrl ? Link : ""} : null} + {columns.jobUrl ? {job.jobUrl ? {t("jobTableLink")} : ""} : null} - setEditJobId(job.id)}> - { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> - setDetailsJobId(job.id)}> - {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} + setEditJobId(job.id)}> + { setStatusJobId(job.id); setStatusAnchor(e.currentTarget); }}> + setDetailsJobId(job.id)}> + {(mode === "trash" || (includeDeleted && job.isDeleted)) ? void restore(job.id)}> : void softDelete(job)}>} @@ -389,11 +391,11 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - Location{job.location ?? "-"} - Salary{job.salary ?? "-"} - Job URL{job.jobUrl ? Open listing : "-"} - Skills{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : No tags} - Overview{generateOverview(job) || "No summary yet."} + {t("jobTableLocation")}{job.location ?? "-"} + {t("addJobModalSalary")}{job.salary ?? "-"} + {t("settingsColumnJobUrl")}{job.jobUrl ? {t("jobTableOpenListing")} : "-"} + {t("jobTableSkills")}{parseTags(job.tags).length ? parseTags(job.tags).slice(0, 8).map((tag) => ) : {t("jobTableNoTags")}} + {t("jobTableOverview")}{generateOverview(job) || t("jobTableNoSummaryYet")} @@ -401,7 +403,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col ); })} - {filteredJobs.length === 0 ? No jobs found. : null} + {filteredJobs.length === 0 ? {t("jobTableNoJobsFound")} : null} setPage(next)} rowsPerPage={pageSize} onRowsPerPageChange={(e) => { onPageSizeChange(Number(e.target.value) as 15 | 20 | 25); setPage(0); }} rowsPerPageOptions={[15, 20, 25]} /> @@ -410,7 +412,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col setDetailsJobId(null)} /> setEditJobId(null)} onSaved={() => setReloadToken((t) => t + 1)} /> { setStatusAnchor(null); setStatusJobId(null); }}> - {["Waiting", "Interview", "Offer", "Rejected", "Ghosted"].map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>Set {s})} + {(["Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const).map((s) => { if (statusJobId) void setStatusQuick(statusJobId, s); setStatusAnchor(null); setStatusJobId(null); }}>{t("jobTableSetStatus", { status: s })})} ); diff --git a/job-tracker-ui/src/components/KanbanBoard.tsx b/job-tracker-ui/src/components/KanbanBoard.tsx index a82e7d4..36aeb9b 100644 --- a/job-tracker-ui/src/components/KanbanBoard.tsx +++ b/job-tracker-ui/src/components/KanbanBoard.tsx @@ -16,6 +16,7 @@ import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import { api } from "../api"; import { JobApplication } from "../types"; +import { useI18n } from "../i18n/I18nProvider"; const STATUSES = ["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const; type Status = (typeof STATUSES)[number]; @@ -36,6 +37,7 @@ function toneColor(theme: any, status: Status | "Other"): string { export default function KanbanBoard() { const theme = useTheme(); + const { t } = useI18n(); const [jobs, setJobs] = useState([]); const [dragJobId, setDragJobId] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null); @@ -64,9 +66,7 @@ export default function KanbanBoard() { if (!dragJobId) return; setDragJobId(null); await api.patch(`/jobapplications/${dragJobId}/status`, { status }); - setJobs((prev) => - prev.map((j) => (j.id === dragJobId ? { ...j, status } : j)), - ); + setJobs((prev) => prev.map((j) => (j.id === dragJobId ? { ...j, status } : j))); }; const setStatus = async (id: number, status: Status) => { @@ -74,9 +74,7 @@ export default function KanbanBoard() { setJobs((prev) => prev.map((j) => (j.id === id ? { ...j, status } : j))); }; - const currentMenuStatus = menuJobId == null - ? null - : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? ""); + const currentMenuStatus = menuJobId == null ? null : normalizeStatus(jobs.find((j) => j.id === menuJobId)?.status ?? ""); return ( @@ -84,14 +82,7 @@ export default function KanbanBoard() { Drag cards between columns to update status. - + {STATUSES.map((status) => { const c = toneColor(theme, status); const list = groups.get(status) ?? []; @@ -178,28 +169,14 @@ export default function KanbanBoard() { })} - { - setMenuAnchor(null); - setMenuJobId(null); - }} - > + { setMenuAnchor(null); setMenuJobId(null); }}> {(["Applied", "Waiting", "Interview", "Offer", "Rejected", "Ghosted"] as const) .filter((s) => s !== currentMenuStatus) .map((s) => ( - { - if (menuJobId) void setStatus(menuJobId, s); - setMenuAnchor(null); - setMenuJobId(null); - }} - > - Set {s} - - ))} + { if (menuJobId) void setStatus(menuJobId, s); setMenuAnchor(null); setMenuJobId(null); }}> + {t("jobTableSetStatus", { status: s })} + + ))} ); diff --git a/job-tracker-ui/src/components/QuickCommandDialog.tsx b/job-tracker-ui/src/components/QuickCommandDialog.tsx index 954dd7c..3d26bc2 100644 --- a/job-tracker-ui/src/components/QuickCommandDialog.tsx +++ b/job-tracker-ui/src/components/QuickCommandDialog.tsx @@ -14,6 +14,7 @@ import { import SearchIcon from "@mui/icons-material/Search"; import { api } from "../api"; +import { useI18n } from "../i18n/I18nProvider"; type CommandItem = { id: string; @@ -41,6 +42,7 @@ interface Props { } export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAddJob }: Props) { + const { t } = useI18n(); const [query, setQuery] = useState(""); const [jobs, setJobs] = useState([]); const [companies, setCompanies] = useState([]); @@ -82,31 +84,31 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd const commands = useMemo(() => { const base: CommandItem[] = [ - { id: "go-dashboard", label: "Go to dashboard", hint: "Analytics overview", action: () => onNavigate("/dashboard") }, - { id: "go-jobs", label: "Go to jobs", hint: "Main applications table", action: () => onNavigate("/jobs") }, - { id: "go-reminders", label: "Go to reminders", hint: "Follow-up queue", action: () => onNavigate("/reminders") }, - { id: "go-companies", label: "Go to companies", hint: "CRM and source tracking", action: () => onNavigate("/companies") }, - { id: "go-settings", label: "Go to settings", hint: "Preferences and admin tools", action: () => onNavigate("/settings") }, - { id: "add-job", label: "Add new job", hint: "Open the add-job modal", action: onOpenAddJob }, + { id: "go-dashboard", label: t("goToDashboard"), hint: t("analyticsOverview"), action: () => onNavigate("/dashboard") }, + { id: "go-jobs", label: t("goToJobs"), hint: t("mainApplicationsTable"), action: () => onNavigate("/jobs") }, + { id: "go-reminders", label: t("goToReminders"), hint: t("followUpQueue"), action: () => onNavigate("/reminders") }, + { id: "go-companies", label: t("goToCompanies"), hint: t("crmAndSourceTracking"), action: () => onNavigate("/companies") }, + { id: "go-settings", label: t("goToSettings"), hint: t("preferencesAndAdminTools"), action: () => onNavigate("/settings") }, + { id: "add-job", label: t("addNewJob"), hint: t("openAddJobModal"), action: onOpenAddJob }, ]; const q = query.trim().toLowerCase(); if (!q) return base; return base.filter((item) => item.label.toLowerCase().includes(q) || item.hint?.toLowerCase().includes(q)); - }, [onNavigate, onOpenAddJob, query]); + }, [onNavigate, onOpenAddJob, query, t]); const allItems: CommandItem[] = [ ...commands, ...jobs.slice(0, 6).map((job) => ({ id: `job-${job.id}`, - label: `${job.company?.name ?? "Company"} - ${job.jobTitle}`, - hint: "Open job list and search result", + label: `${job.company?.name ?? t("company")} - ${job.jobTitle}`, + hint: t("openJobListAndSearchResult"), action: () => onNavigate(`/jobs?open=${job.id}`), })), ...companies.slice(0, 6).map((company) => ({ id: `company-${company.id}`, label: company.name, - hint: "Open companies", + hint: t("openCompanies"), action: () => onNavigate(`/companies?edit=${company.id}`), })), ]; @@ -120,7 +122,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd autoFocus variant="standard" fullWidth - placeholder="Search jobs, companies, or actions" + placeholder={t("searchPlaceholder")} value={query} onChange={(e) => setQuery(e.target.value)} InputProps={{ disableUnderline: true }} @@ -133,7 +135,7 @@ export default function QuickCommandDialog({ open, onClose, onNavigate, onOpenAd {allItems.length === 0 ? ( - No matching commands or records. + {t("noMatchingCommands")} ) : ( allItems.map((item) => ( diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index 4fb76b9..a530245 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -5,6 +5,7 @@ import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material"; import { api } from "../api"; import { JobApplication } from "../types"; import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; import JobDetailsDialog from "./JobDetailsDialog"; @@ -28,6 +29,7 @@ function groupItems(items: JobApplication[]): ReminderGroups { } function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) { + const { t } = useI18n(); if (items.length === 0) return null; return ( @@ -40,17 +42,17 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin {j.company?.name ?? ""} {j.jobTitle} - {j.needsFollowUp ? : null} + {j.needsFollowUp ? : null} {j.followUpReason ? : null} - {j.followUpAt ? : null} + {j.followUpAt ? : null} - + - + ))} @@ -60,6 +62,7 @@ function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: strin export default function RemindersView() { const { toast } = useToast(); + const { t } = useI18n(); const [items, setItems] = useState([]); const [openJobId, setOpenJobId] = useState(null); @@ -78,32 +81,32 @@ export default function RemindersView() { try { const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d }); - toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success"); + toast(daysFromNow === null ? t("remindersFollowUpCleared") : t("remindersFollowUpSet"), "success"); await load(); } catch { - toast("Failed to set follow-up.", "error"); + toast(t("remindersFollowUpFailed"), "error"); } }; return ( - Needs Follow-up + {t("remindersTitle")} - Grouped by the most useful next action so you can fix gaps faster. + {t("remindersSubtitle")} - - - - + + + + - {items.length === 0 ? Nothing to follow up right now. : null} + {items.length === 0 ? {t("remindersNothing")} : null} - Tip: focus on tailored CV and interview prep first for the highest-value roles. + {t("remindersTip")} setOpenJobId(null)} /> diff --git a/job-tracker-ui/src/components/RulesSettingsCard.tsx b/job-tracker-ui/src/components/RulesSettingsCard.tsx index af5f0e3..a58eb40 100644 --- a/job-tracker-ui/src/components/RulesSettingsCard.tsx +++ b/job-tracker-ui/src/components/RulesSettingsCard.tsx @@ -4,6 +4,7 @@ import { Box, Button, Paper, TextField, Typography } from "@mui/material"; import { api } from "../api"; import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; type RuleSettings = { id: number; @@ -17,6 +18,7 @@ type RuleSettings = { export default function RulesSettingsCard() { const { toast } = useToast(); + const { t } = useI18n(); const [s, setS] = useState(null); const [saving, setSaving] = useState(false); @@ -29,7 +31,7 @@ export default function RulesSettingsCard() { setSaving(true); try { await api.put("/rules", s); - toast("Rules saved.", "success"); + toast(t("rulesSave"), "success"); } catch { toast("Failed to save rules.", "error"); } finally { @@ -50,30 +52,29 @@ export default function RulesSettingsCard() { return ( - Follow-up + Ghosting Rules + {t("rulesTitle")} - Jobs get a “Follow up” flag based on these thresholds. Ghosting is automatic. + {t("rulesBody")} - - + + - - + + - - + + ); } - diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index efa45a7..225cebc 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -12,7 +12,6 @@ import { Select, Tab, Tabs, - TextField, Typography, } from "@mui/material"; @@ -22,8 +21,8 @@ import GoogleAuthCard from "./GoogleAuthCard"; import RulesSettingsCard from "./RulesSettingsCard"; import BackupCard from "./BackupCard"; import AuthStatusCard from "./AuthStatusCard"; - -export type ThemeModePref = "system" | "light" | "dark"; +import { ThemeModePref } from "../themePrefs"; +import { useI18n } from "../i18n/I18nProvider"; interface Props { pageSize: 15 | 20 | 25; @@ -42,7 +41,7 @@ function TabPanel({ value, index, children }: { value: number; index: number; ch return {children}; } -const ACCENTS = ["#7c4dff", "#4f8cff", "#16a34a", "#f59e0b", "#e11d48", "#06b6d4"]; // violet, blue, green, amber, rose, cyan +const ACCENTS = ["#15803d", "#16a34a", "#22c55e", "#0f766e", "#0f766e", "#65a30d"]; export default function SettingsView({ pageSize, @@ -56,56 +55,60 @@ export default function SettingsView({ onResetAccentColor, }: Props) { const [tab, setTab] = useState(0); + const { language, setLanguage, t } = useI18n(); const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]); return ( - Settings + {t("settingsTitle")} - Preferences and admin tools. + {t("settingsSubtitle")} setTab(v)} sx={{ mb: 1 }}> - - - - - + + + + + - Appearance + {t("settingsAppearance")} - Theme + {t("settingsTheme")} - onAccentColorChange(e.target.value)} - sx={{ width: 160 }} - InputLabelProps={{ shrink: true }} - /> + + + {t("settingsAccent")} + onAccentColorChange(e.target.value)} + style={{ width: 160, height: 40, border: "none", background: "transparent", padding: 0 }} + /> + @@ -129,24 +132,48 @@ export default function SettingsView({ - Saved per user on this browser. + {t("settingsSavedPerUser")} - Jobs + {t("settingsLanguageTitle")} + + {t("settingsLanguageBody")} + + + + {t("settingsPreferredLanguage")} + + + + + {t("settingsMorePagesSoon")} + + + + + {t("settingsJobs")} - Pagination + {t("settingsPagination")} - Rows per page + {t("settingsRowsPerPage")} { + const file = event.target.files?.[0] ?? null; + event.target.value = ""; + if (!file) return; + setAvatarFile(file); + setCropOpen(true); + }} + /> + + {me?.avatarImageDataUrl ? ( + + ) : null} + + - Profile + {t("profileTitle")} - {me?.displayName || fullName || me?.userName || me?.email || "-"} - {headline || "Add a short headline to personalize your account view."} + {me?.userName || me?.displayName || fullName || me?.email || "-"} + {headline || t("profileHeadlinePlaceholder")} - - - + + + @@ -101,40 +194,40 @@ export default function ProfilePage() { - Account + {t("profileAccountSection")} {!isLocal ? ( - This session is not using a local app token, so profile edits are read-only right now. + {t("profileReadOnlyInfo")} ) : null} - setDisplayName(e.target.value)} disabled={!isLocal} fullWidth /> - setUserName(e.target.value)} disabled={!isLocal} fullWidth /> - setFirstName(e.target.value)} disabled={!isLocal} fullWidth /> - setLastName(e.target.value)} disabled={!isLocal} fullWidth /> - setEmail(e.target.value)} disabled={!isLocal} fullWidth /> + setDisplayName(e.target.value)} disabled={!isLocal} fullWidth /> + setUserName(e.target.value)} disabled={!isLocal} fullWidth /> + setFirstName(e.target.value)} disabled={!isLocal} fullWidth /> + setLastName(e.target.value)} disabled={!isLocal} fullWidth /> + setEmail(e.target.value)} disabled={!isLocal} fullWidth /> setHeadline(e.target.value)} - helperText="Stored only in this browser to personalize your workspace." + helperText={t("profileHeadlineHelp")} fullWidth /> - Master CV + {t("profileMasterCv")} - Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging. + {t("profileMasterCvBody")} { const file = event.target.files?.[0]; @@ -146,28 +239,28 @@ export default function ProfilePage() { try { await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); await loadProfile(); - toast("CV text imported.", "success"); + toast(t("profileCvUploaded"), "success"); } catch (e: any) { - toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error"); + toast(String(e?.response?.data || e?.message || t("profileCvUploadFailed")), "error"); } finally { setUploadingCv(false); } }} /> - {uploadingCv ? : null} setProfileCvText(e.target.value)} - helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next." + helperText={t("profileCvTextHelp")} multiline minRows={12} disabled={!isLocal} @@ -178,15 +271,12 @@ export default function ProfilePage() { {cvWordCount} words - Tip: plain text works best right now. + {t("profileCvPreferredUploads")} - - - Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"} - + - Change password - {!isLocal ? Password changes are only available for local accounts. : null} + {t("profileChangePassword")} + {!isLocal ? {t("profilePasswordLocalOnly")} : null} - setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth /> - setNewPassword(e.target.value)} disabled={!isLocal} fullWidth /> + setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth /> + setNewPassword(e.target.value)} disabled={!isLocal} fullWidth /> diff --git a/job-tracker-ui/src/pages/ResetPasswordPage.tsx b/job-tracker-ui/src/pages/ResetPasswordPage.tsx index 941beb3..418efff 100644 --- a/job-tracker-ui/src/pages/ResetPasswordPage.tsx +++ b/job-tracker-ui/src/pages/ResetPasswordPage.tsx @@ -6,6 +6,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { api } from "../api"; import { useToast } from "../toast"; +import { useI18n } from "../i18n/I18nProvider"; function useQuery() { const { search } = useLocation(); @@ -14,6 +15,7 @@ function useQuery() { export default function ResetPasswordPage() { const { toast } = useToast(); + const { t } = useI18n(); const navigate = useNavigate(); const q = useQuery(); @@ -37,10 +39,10 @@ export default function ResetPasswordPage() { > - Reset password + {t("resetPasswordTitle")} - Set a new password for your account. + {t("resetPasswordBody")} { e.preventDefault(); if (!email || !token) { - toast("Missing email/token in link.", "error"); + toast(t("missingResetLinkInfo"), "error"); return; } setLoading(true); api .post("/auth/reset-password", { email, token, newPassword }) .then(() => { - toast("Password reset. Please sign in.", "success"); + toast(t("passwordResetSuccess"), "success"); navigate("/login", { replace: true }); }) .catch((e2: any) => { - const msg = e2?.response?.data || e2?.message || "Reset failed."; + const msg = e2?.response?.data || e2?.message || t("resetFailed"); toast(String(msg), "error"); }) .finally(() => setLoading(false)); }} sx={{ display: "flex", flexDirection: "column", gap: 1.5 }} > - - setNewPassword(e.target.value)} fullWidth /> + + setNewPassword(e.target.value)} fullWidth /> @@ -82,4 +84,3 @@ export default function ResetPasswordPage() { ); } - diff --git a/job-tracker-ui/src/pages/RouteErrorPage.tsx b/job-tracker-ui/src/pages/RouteErrorPage.tsx new file mode 100644 index 0000000..270f87b --- /dev/null +++ b/job-tracker-ui/src/pages/RouteErrorPage.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Box, Button, Paper, Typography } from "@mui/material"; +import { useNavigate, useRouteError } from "react-router-dom"; +import { useI18n } from "../i18n/I18nProvider"; + +export default function RouteErrorPage() { + const navigate = useNavigate(); + const error = useRouteError() as any; + const { t } = useI18n(); + + const details = typeof error?.statusText === "string" && error.statusText.trim() + ? error.statusText + : typeof error?.message === "string" && error.message.trim() + ? error.message + : null; + + return ( + + + + + {error?.status || 500} + + + {t("appErrorTitle")} + + + {t("appErrorBody")} + + {details ? {details} : null} + + + + + + + + ); +} diff --git a/job-tracker-ui/src/theme.ts b/job-tracker-ui/src/theme.ts index f065902..dffc9ec 100644 --- a/job-tracker-ui/src/theme.ts +++ b/job-tracker-ui/src/theme.ts @@ -24,7 +24,7 @@ function buildLightPalette(accentColor: string): PaletteLike { const disabledBackground = "#E4E1E6"; return { - primary: buildPrimary(accentColor || "#606BDF"), + primary: buildPrimary(accentColor || "#15803D"), secondary: { lighter: "#E0E0FF", light: "#C3C4E4", @@ -47,11 +47,11 @@ function buildLightPalette(accentColor: string): PaletteLike { darker: "#4A2800", }, success: { - lighter: "#C8FFC0", - light: "#B6F2AF", - main: "#22892F", - dark: "#006E1C", - darker: "#00390A", + lighter: "#DCFCE7", + light: "#BBF7D0", + main: "#16A34A", + dark: "#15803D", + darker: "#14532D", }, info: { lighter: "#D4F7FF", @@ -80,7 +80,7 @@ function buildLightPalette(accentColor: string): PaletteLike { divider, background: { default: background, paper: background }, action: { - hover: alpha(secondaryMain, 0.05), + hover: alpha(accentColor || "#15803D", 0.05), disabled: alpha(disabled, 0.6), disabledBackground: alpha(disabledBackground, 0.9), }, @@ -99,7 +99,7 @@ function buildDarkPalette(accentColor: string): PaletteLike { const disabledBackground = alpha("#FFFFFF", 0.08); return { - primary: buildPrimary(accentColor || "#606BDF"), + primary: buildPrimary(accentColor || "#15803D"), secondary: { lighter: alpha(secondaryMain, 0.22), light: alpha(secondaryMain, 0.14), @@ -122,8 +122,8 @@ function buildDarkPalette(accentColor: string): PaletteLike { darker: "#FFE1B8", }, success: { - lighter: alpha("#22892F", 0.18), - light: alpha("#22892F", 0.12), + lighter: alpha("#16A34A", 0.18), + light: alpha("#16A34A", 0.12), main: "#4ADE80", dark: "#22C55E", darker: "#BBF7D0", @@ -155,7 +155,7 @@ function buildDarkPalette(accentColor: string): PaletteLike { divider, background: { default: bg, paper }, action: { - hover: alpha("#FFFFFF", 0.06), + hover: alpha(accentColor || "#15803D", 0.16), disabled: alpha("#FFFFFF", 0.5), disabledBackground, }, @@ -189,7 +189,6 @@ function buildTypography() { body2: { fontWeight: 400, fontSize: 13, lineHeight: "17px" }, caption: { fontWeight: 400, fontSize: 12, lineHeight: "16px", letterSpacing: 0 }, overline: { fontWeight: 600, fontSize: 11, lineHeight: "14px", letterSpacing: "0.08em", textTransform: "uppercase" as const }, - // Saasable uses caption1; keep as a custom variant for internal usage if needed. caption1: { fontWeight: 500, fontSize: 12, lineHeight: "16px", letterSpacing: 0 }, button: { textTransform: "capitalize" as const }, }; @@ -277,8 +276,30 @@ export const getTheme = (_mode: "light" | "dark", accentColor: string) => { }, }), notchedOutline: ({ theme }: any) => ({ borderColor: theme.vars.palette.divider }), - multiline: { padding: 10 }, - input: { paddingLeft: 0, paddingRight: 0 }, + multiline: { + padding: 10, + alignItems: "flex-start", + }, + input: { + paddingLeft: 0, + paddingRight: 0, + paddingTop: 10, + paddingBottom: 10, + }, + inputMultiline: { + paddingTop: 0, + paddingBottom: 0, + lineHeight: 1.5, + }, + }, + }, + MuiInputBase: { + styleOverrides: { + inputMultiline: { + "&::placeholder": { + opacity: 0.72, + }, + }, }, }, MuiListItemButton: { diff --git a/job-tracker-ui/src/themePrefs.ts b/job-tracker-ui/src/themePrefs.ts index b3752c5..46bb7bc 100644 --- a/job-tracker-ui/src/themePrefs.ts +++ b/job-tracker-ui/src/themePrefs.ts @@ -40,7 +40,7 @@ export function setThemeModePref(v: ThemeModePref) { export function getAccentColor(): string { const raw = window.localStorage.getItem(k("accentColor")); if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw; - return "#7c4dff"; + return "#15803d"; } export function setAccentColor(v: string) {