Просмотр исходного кода

Add multi-profile storage and profiles list UI

Persist full saved profiles (personal info, career, work, education, extras)
in UserDefaults via SavedProfilesStore, with migration from the legacy
single personal-information key.

Introduce ProfilesListPageView as the Profile sidebar hub: list profiles,
add new, edit, and delete with confirmation. Wire DashboardView to swap
between the list and MyProfilePageView; editor includes back navigation
and saves complete profiles on Save.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 недель назад: 3
Родитель
Сommit
39bb2ad0c0

+ 114 - 0
App for Indeed/Models/SavedProfilesStore.swift

@@ -0,0 +1,114 @@
1
+//
2
+//  SavedProfilesStore.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Foundation
7
+
8
+struct PersonalInformation: Codable, Equatable {
9
+    var fullName: String
10
+    var email: String
11
+    var phone: String
12
+    var jobTitle: String
13
+    var address: String
14
+
15
+    static let empty = PersonalInformation(fullName: "", email: "", phone: "", jobTitle: "", address: "")
16
+}
17
+
18
+struct WorkExperiencePayload: Codable, Equatable {
19
+    var jobTitle: String
20
+    var company: String
21
+    var duration: String
22
+    var description: String
23
+
24
+    static let empty = WorkExperiencePayload(jobTitle: "", company: "", duration: "", description: "")
25
+}
26
+
27
+struct EducationPayload: Codable, Equatable {
28
+    var degree: String
29
+    var institution: String
30
+    var year: String
31
+
32
+    static let empty = EducationPayload(degree: "", institution: "", year: "")
33
+}
34
+
35
+struct SavedProfile: Codable, Equatable, Identifiable {
36
+    var id: UUID
37
+    var profileDisplayName: String
38
+    var personal: PersonalInformation
39
+    var careerSummary: String
40
+    var workExperiences: [WorkExperiencePayload]
41
+    var educations: [EducationPayload]
42
+    var certificates: String
43
+    var interests: String
44
+    var languages: String
45
+    var referral: String
46
+}
47
+
48
+enum SavedProfilesStore {
49
+    private static let profilesKey = "com.appforindeed.savedProfiles.v1"
50
+    private static let legacyPersonalKey = "com.appforindeed.personalInformation.v1"
51
+
52
+    static func loadAll() -> [SavedProfile] {
53
+        migrateLegacyPersonalInformationIfNeeded()
54
+        guard let data = UserDefaults.standard.data(forKey: profilesKey) else { return [] }
55
+        do {
56
+            return try JSONDecoder().decode([SavedProfile].self, from: data)
57
+        } catch {
58
+            return []
59
+        }
60
+    }
61
+
62
+    static func saveAll(_ profiles: [SavedProfile]) {
63
+        do {
64
+            let data = try JSONEncoder().encode(profiles)
65
+            UserDefaults.standard.set(data, forKey: profilesKey)
66
+        } catch {
67
+            // Best-effort persistence.
68
+        }
69
+    }
70
+
71
+    static func upsert(_ profile: SavedProfile) {
72
+        var all = loadAll()
73
+        if let i = all.firstIndex(where: { $0.id == profile.id }) {
74
+            all[i] = profile
75
+        } else {
76
+            all.append(profile)
77
+        }
78
+        saveAll(all)
79
+    }
80
+
81
+    static func delete(id: UUID) {
82
+        var all = loadAll()
83
+        all.removeAll { $0.id == id }
84
+        saveAll(all)
85
+    }
86
+
87
+    static func profile(id: UUID) -> SavedProfile? {
88
+        loadAll().first { $0.id == id }
89
+    }
90
+
91
+    /// Imports the older single `PersonalInformation` blob into the first saved profile when the new store has never been written.
92
+    private static func migrateLegacyPersonalInformationIfNeeded() {
93
+        guard UserDefaults.standard.object(forKey: profilesKey) == nil else { return }
94
+        guard let data = UserDefaults.standard.data(forKey: legacyPersonalKey) else { return }
95
+        guard let personal = try? JSONDecoder().decode(PersonalInformation.self, from: data) else {
96
+            UserDefaults.standard.removeObject(forKey: legacyPersonalKey)
97
+            return
98
+        }
99
+        let imported = SavedProfile(
100
+            id: UUID(),
101
+            profileDisplayName: "My profile",
102
+            personal: personal,
103
+            careerSummary: "",
104
+            workExperiences: [.empty],
105
+            educations: [.empty],
106
+            certificates: "",
107
+            interests: "",
108
+            languages: "",
109
+            referral: ""
110
+        )
111
+        saveAll([imported])
112
+        UserDefaults.standard.removeObject(forKey: legacyPersonalKey)
113
+    }
114
+}

+ 90 - 0
App for Indeed/Views/DashboardView.swift

@@ -126,9 +126,14 @@ final class DashboardView: NSView, NSTextFieldDelegate {
126 126
         CVMakerPageView()
127 127
     }()
128 128
     private let profilePageContainer = NSView()
129
+    private lazy var profilesListPageView: ProfilesListPageView = {
130
+        ProfilesListPageView()
131
+    }()
129 132
     private lazy var myProfilePageView: MyProfilePageView = {
130 133
         MyProfilePageView()
131 134
     }()
135
+    /// When true, `myProfilePageView` is visible instead of the profiles list.
136
+    private var isProfileEditorPresented = false
132 137
 
133 138
     private var currentSidebarItems: [SidebarItem] = []
134 139
     private var selectedSidebarIndex: Int = 0
@@ -1341,14 +1346,89 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1341 1346
         profilePageContainer.isHidden = true
1342 1347
         profilePageContainer.userInterfaceLayoutDirection = .leftToRight
1343 1348
 
1349
+        profilesListPageView.translatesAutoresizingMaskIntoConstraints = false
1344 1350
         myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
1351
+        profilePageContainer.addSubview(profilesListPageView)
1345 1352
         profilePageContainer.addSubview(myProfilePageView)
1353
+
1346 1354
         NSLayoutConstraint.activate([
1355
+            profilesListPageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
1356
+            profilesListPageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
1357
+            profilesListPageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
1358
+            profilesListPageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor),
1359
+
1347 1360
             myProfilePageView.leftAnchor.constraint(equalTo: profilePageContainer.leftAnchor),
1348 1361
             myProfilePageView.rightAnchor.constraint(equalTo: profilePageContainer.rightAnchor),
1349 1362
             myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
1350 1363
             myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
1351 1364
         ])
1365
+
1366
+        profilesListPageView.onAddProfile = { [weak self] in
1367
+            self?.presentProfileEditor(existingID: nil)
1368
+        }
1369
+        profilesListPageView.onEditProfile = { [weak self] id in
1370
+            self?.presentProfileEditor(existingID: id)
1371
+        }
1372
+        profilesListPageView.onDeleteProfile = { [weak self] id in
1373
+            self?.confirmDeleteProfile(id: id)
1374
+        }
1375
+        myProfilePageView.onDismiss = { [weak self] in
1376
+            self?.dismissProfileEditor()
1377
+        }
1378
+
1379
+        isProfileEditorPresented = false
1380
+        profilesListPageView.isHidden = false
1381
+        myProfilePageView.isHidden = true
1382
+        profilesListPageView.reloadFromStore()
1383
+    }
1384
+
1385
+    private func presentProfileEditor(existingID: UUID?) {
1386
+        isProfileEditorPresented = true
1387
+        if let id = existingID, let profile = SavedProfilesStore.profile(id: id) {
1388
+            myProfilePageView.loadSavedProfile(profile)
1389
+        } else {
1390
+            myProfilePageView.prepareNewProfile()
1391
+        }
1392
+        profilesListPageView.isHidden = true
1393
+        myProfilePageView.isHidden = false
1394
+    }
1395
+
1396
+    private func dismissProfileEditor() {
1397
+        isProfileEditorPresented = false
1398
+        profilesListPageView.reloadFromStore()
1399
+        profilesListPageView.isHidden = false
1400
+        myProfilePageView.isHidden = true
1401
+    }
1402
+
1403
+    private func confirmDeleteProfile(id: UUID) {
1404
+        let displayName = SavedProfilesStore.profile(id: id)?.profileDisplayName ?? ""
1405
+        let alert = NSAlert()
1406
+        alert.messageText = "Delete this profile?"
1407
+        alert.informativeText = displayName.isEmpty
1408
+            ? "This profile will be removed from this Mac."
1409
+            : "“\(displayName)” will be removed from this Mac."
1410
+        alert.alertStyle = .warning
1411
+        alert.addButton(withTitle: "Cancel")
1412
+        alert.addButton(withTitle: "Delete")
1413
+        guard let window = window else {
1414
+            let response = alert.runModal()
1415
+            if response == .alertSecondButtonReturn {
1416
+                SavedProfilesStore.delete(id: id)
1417
+                profilesListPageView.reloadFromStore()
1418
+            }
1419
+            return
1420
+        }
1421
+        alert.beginSheetModal(for: window) { [weak self] response in
1422
+            guard let self else { return }
1423
+            if response == .alertSecondButtonReturn {
1424
+                SavedProfilesStore.delete(id: id)
1425
+                if self.isProfileEditorPresented {
1426
+                    self.dismissProfileEditor()
1427
+                } else {
1428
+                    self.profilesListPageView.reloadFromStore()
1429
+                }
1430
+            }
1431
+        }
1352 1432
     }
1353 1433
 
1354 1434
     private func configureSettingsPage() {
@@ -1609,6 +1689,16 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1609 1689
         settingsPageContainer.isHidden = !settings
1610 1690
         cvMakerPageContainer.isHidden = !cvMaker
1611 1691
         profilePageContainer.isHidden = !profile
1692
+        if !profile {
1693
+            isProfileEditorPresented = false
1694
+            profilesListPageView.isHidden = false
1695
+            myProfilePageView.isHidden = true
1696
+        }
1697
+        if profile, !isProfileEditorPresented {
1698
+            profilesListPageView.reloadFromStore()
1699
+            profilesListPageView.isHidden = false
1700
+            myProfilePageView.isHidden = true
1701
+        }
1612 1702
         if !home, selectedSidebarIndex < currentSidebarItems.count {
1613 1703
             if savedJobs {
1614 1704
                 reloadSavedJobsListings()

+ 217 - 2
App for Indeed/Views/MyProfilePageView.swift

@@ -152,6 +152,14 @@ final class MyProfilePageView: NSView {
152 152
     private var nameEmailRow: ProfileDualFieldRow!
153 153
     private var phoneJobRow: ProfileDualFieldRow!
154 154
 
155
+    private let topChrome = NSView()
156
+    private let backButton = NSButton(title: "← All profiles", target: nil, action: nil)
157
+    private let contextLabel = NSTextField(labelWithString: "")
158
+    private var editingProfileID: UUID?
159
+
160
+    /// Called from the back control and after a successful save (returns to the profiles list).
161
+    var onDismiss: (() -> Void)?
162
+
155 163
     private let workExperienceRowsStack = NSStackView()
156 164
     private var workExperienceEntries: [WorkExperienceEntryView] = []
157 165
     private let educationRowsStack = NSStackView()
@@ -278,15 +286,52 @@ final class MyProfilePageView: NSView {
278 286
         formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
279 287
         formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
280 288
 
289
+        topChrome.translatesAutoresizingMaskIntoConstraints = false
290
+        topChrome.userInterfaceLayoutDirection = .leftToRight
291
+
292
+        backButton.translatesAutoresizingMaskIntoConstraints = false
293
+        backButton.bezelStyle = .rounded
294
+        backButton.isBordered = false
295
+        backButton.font = .systemFont(ofSize: 13, weight: .medium)
296
+        backButton.contentTintColor = ProfilePagePalette.brandBlue
297
+        backButton.target = self
298
+        backButton.action = #selector(didTapBack)
299
+
300
+        contextLabel.translatesAutoresizingMaskIntoConstraints = false
301
+        contextLabel.font = .systemFont(ofSize: 15, weight: .semibold)
302
+        contextLabel.textColor = ProfilePagePalette.primaryText
303
+        contextLabel.stringValue = "New profile"
304
+        contextLabel.backgroundColor = .clear
305
+        contextLabel.isBordered = false
306
+        contextLabel.isEditable = false
307
+        contextLabel.isSelectable = false
308
+        ProfileLayoutEnforcement.applyLeftAlignedTextField(contextLabel)
309
+
310
+        topChrome.addSubview(backButton)
311
+        topChrome.addSubview(contextLabel)
312
+        NSLayoutConstraint.activate([
313
+            backButton.leadingAnchor.constraint(equalTo: topChrome.leadingAnchor, constant: 4),
314
+            backButton.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
315
+            contextLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 10),
316
+            contextLabel.centerYAnchor.constraint(equalTo: topChrome.centerYAnchor),
317
+            contextLabel.trailingAnchor.constraint(lessThanOrEqualTo: topChrome.trailingAnchor, constant: -8)
318
+        ])
319
+
320
+        addSubview(topChrome)
281 321
         addSubview(scrollView)
282 322
         scrollView.documentView = documentView
283 323
         documentView.addSubview(cardView)
284 324
         cardView.addSubview(formStack)
285 325
 
286 326
         NSLayoutConstraint.activate([
327
+            topChrome.leftAnchor.constraint(equalTo: leftAnchor),
328
+            topChrome.rightAnchor.constraint(equalTo: rightAnchor),
329
+            topChrome.topAnchor.constraint(equalTo: topAnchor, constant: 4),
330
+            topChrome.heightAnchor.constraint(equalToConstant: 40),
331
+
287 332
             scrollView.leftAnchor.constraint(equalTo: leftAnchor),
288 333
             scrollView.rightAnchor.constraint(equalTo: rightAnchor),
289
-            scrollView.topAnchor.constraint(equalTo: topAnchor),
334
+            scrollView.topAnchor.constraint(equalTo: topChrome.bottomAnchor, constant: 2),
290 335
             scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
291 336
 
292 337
             // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
@@ -369,6 +414,124 @@ final class MyProfilePageView: NSView {
369 414
         ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
370 415
     }
371 416
 
417
+    func prepareNewProfile() {
418
+        editingProfileID = nil
419
+        contextLabel.stringValue = "New profile"
420
+        applyForm(
421
+            from: SavedProfile(
422
+                id: UUID(),
423
+                profileDisplayName: "",
424
+                personal: .empty,
425
+                careerSummary: "",
426
+                workExperiences: [.empty],
427
+                educations: [.empty],
428
+                certificates: "",
429
+                interests: "",
430
+                languages: "",
431
+                referral: ""
432
+            )
433
+        )
434
+    }
435
+
436
+    func loadSavedProfile(_ profile: SavedProfile) {
437
+        editingProfileID = profile.id
438
+        contextLabel.stringValue = "Edit profile"
439
+        applyForm(from: profile)
440
+    }
441
+
442
+    private func applyForm(from profile: SavedProfile) {
443
+        profileNameField.stringValue = profile.profileDisplayName
444
+        applyPersonalInformation(profile.personal)
445
+        careerField.stringValue = profile.careerSummary
446
+        certificatesField.stringValue = profile.certificates
447
+        interestsField.stringValue = profile.interests
448
+        languagesField.stringValue = profile.languages
449
+        referralField.stringValue = profile.referral
450
+
451
+        let workCount = max(1, profile.workExperiences.count)
452
+        syncWorkExperienceRowCount(to: workCount)
453
+        if profile.workExperiences.isEmpty {
454
+            workExperienceEntries[0].applyPayload(.empty)
455
+        } else {
456
+            for (i, payload) in profile.workExperiences.enumerated() where i < workExperienceEntries.count {
457
+                workExperienceEntries[i].applyPayload(payload)
458
+            }
459
+        }
460
+
461
+        let eduCount = max(1, profile.educations.count)
462
+        syncEducationRowCount(to: eduCount)
463
+        if profile.educations.isEmpty {
464
+            educationEntries[0].applyPayload(.empty)
465
+        } else {
466
+            for (i, payload) in profile.educations.enumerated() where i < educationEntries.count {
467
+                educationEntries[i].applyPayload(payload)
468
+            }
469
+        }
470
+
471
+        lastCompactLayout = nil
472
+        needsLayout = true
473
+    }
474
+
475
+    private func syncWorkExperienceRowCount(to target: Int) {
476
+        let n = max(1, target)
477
+        while workExperienceEntries.count < n {
478
+            appendWorkExperienceEntry()
479
+        }
480
+        while workExperienceEntries.count > n {
481
+            guard let last = workExperienceEntries.last, workExperienceEntries.count > 1 else { break }
482
+            removeWorkExperienceEntry(last)
483
+        }
484
+    }
485
+
486
+    private func syncEducationRowCount(to target: Int) {
487
+        let n = max(1, target)
488
+        while educationEntries.count < n {
489
+            appendEducationEntry()
490
+        }
491
+        while educationEntries.count > n {
492
+            guard let last = educationEntries.last, educationEntries.count > 1 else { break }
493
+            removeEducationEntry(last)
494
+        }
495
+    }
496
+
497
+    private func captureSavedProfileForSave() -> SavedProfile {
498
+        let id = editingProfileID ?? UUID()
499
+        return SavedProfile(
500
+            id: id,
501
+            profileDisplayName: profileNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
502
+            personal: collectPersonalInformationFromFields(),
503
+            careerSummary: careerField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
504
+            workExperiences: workExperienceEntries.map { $0.capturePayload() },
505
+            educations: educationEntries.map { $0.capturePayload() },
506
+            certificates: certificatesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
507
+            interests: interestsField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
508
+            languages: languagesField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
509
+            referral: referralField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
510
+        )
511
+    }
512
+
513
+    @objc private func didTapBack() {
514
+        onDismiss?()
515
+    }
516
+
517
+    private func applyPersonalInformation(_ info: PersonalInformation) {
518
+        fullNameField.stringValue = info.fullName
519
+        emailField.stringValue = info.email
520
+        phoneField.stringValue = info.phone
521
+        jobTitleField.stringValue = info.jobTitle
522
+        addressField.stringValue = info.address
523
+    }
524
+
525
+    private func collectPersonalInformationFromFields() -> PersonalInformation {
526
+        PersonalInformation(
527
+            fullName: fullNameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
528
+            email: emailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
529
+            phone: phoneField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
530
+            jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
531
+            address: addressField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
532
+        )
533
+    }
534
+
372 535
     private func applyResponsiveRowsIfNeeded() {
373 536
         let w = cardView.bounds.width
374 537
         guard w > 1 else { return }
@@ -881,7 +1044,29 @@ final class MyProfilePageView: NSView {
881 1044
     }
882 1045
 
883 1046
     @objc private func didTapSave() {
884
-        // UI shell only; wire persistence when profiles are stored.
1047
+        window?.makeFirstResponder(nil)
1048
+        let profile = captureSavedProfileForSave()
1049
+        var missing: [String] = []
1050
+        if profile.profileDisplayName.isEmpty { missing.append("Profile name") }
1051
+        if profile.personal.fullName.isEmpty { missing.append("Full Name") }
1052
+        if profile.personal.email.isEmpty { missing.append("Email") }
1053
+        if profile.personal.jobTitle.isEmpty { missing.append("Job Title") }
1054
+        guard missing.isEmpty else {
1055
+            let alert = NSAlert()
1056
+            alert.messageText = "Complete required fields"
1057
+            alert.informativeText = "Please fill in: " + missing.joined(separator: ", ") + "."
1058
+            alert.alertStyle = .informational
1059
+            alert.addButton(withTitle: "OK")
1060
+            if let window = window {
1061
+                alert.beginSheetModal(for: window) { _ in }
1062
+            } else {
1063
+                alert.runModal()
1064
+            }
1065
+            return
1066
+        }
1067
+        SavedProfilesStore.upsert(profile)
1068
+        editingProfileID = profile.id
1069
+        onDismiss?()
885 1070
     }
886 1071
 }
887 1072
 
@@ -924,6 +1109,22 @@ private final class WorkExperienceEntryView: NSView {
924 1109
         jobCompanyRow.setCompact(compact)
925 1110
     }
926 1111
 
1112
+    func capturePayload() -> WorkExperiencePayload {
1113
+        WorkExperiencePayload(
1114
+            jobTitle: jobTitleField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
1115
+            company: companyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
1116
+            duration: durationField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
1117
+            description: descriptionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1118
+        )
1119
+    }
1120
+
1121
+    func applyPayload(_ payload: WorkExperiencePayload) {
1122
+        jobTitleField.stringValue = payload.jobTitle
1123
+        companyField.stringValue = payload.company
1124
+        durationField.stringValue = payload.duration
1125
+        descriptionField.stringValue = payload.description
1126
+    }
1127
+
927 1128
     private func configure() {
928 1129
         userInterfaceLayoutDirection = .leftToRight
929 1130
         ProfileLayoutEnforcement.applyForcedLTR(to: self)
@@ -1192,6 +1393,20 @@ private final class EducationEntryView: NSView {
1192 1393
         degreeInstitutionRow.setCompact(compact)
1193 1394
     }
1194 1395
 
1396
+    func capturePayload() -> EducationPayload {
1397
+        EducationPayload(
1398
+            degree: degreeField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
1399
+            institution: institutionField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
1400
+            year: yearField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
1401
+        )
1402
+    }
1403
+
1404
+    func applyPayload(_ payload: EducationPayload) {
1405
+        degreeField.stringValue = payload.degree
1406
+        institutionField.stringValue = payload.institution
1407
+        yearField.stringValue = payload.year
1408
+    }
1409
+
1195 1410
     private func configure() {
1196 1411
         userInterfaceLayoutDirection = .leftToRight
1197 1412
         ProfileLayoutEnforcement.applyForcedLTR(to: self)

+ 310 - 0
App for Indeed/Views/ProfilesListPageView.swift

@@ -0,0 +1,310 @@
1
+//
2
+//  ProfilesListPageView.swift
3
+//  App for Indeed
4
+//
5
+
6
+import Cocoa
7
+
8
+private enum ProfilesListPalette {
9
+    static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
10
+    static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
11
+    static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
12
+    static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
13
+    static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
14
+    static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
15
+    static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
16
+    static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
17
+}
18
+
19
+/// Hub for saved job profiles: list, add, edit (opens editor elsewhere), delete.
20
+final class ProfilesListPageView: NSView {
21
+    var onAddProfile: (() -> Void)?
22
+    var onEditProfile: ((UUID) -> Void)?
23
+    var onDeleteProfile: ((UUID) -> Void)?
24
+
25
+    private let scrollView = NSScrollView()
26
+    private let documentView = NSView()
27
+    private let contentStack = NSStackView()
28
+    private let emptyStateLabel = NSTextField(wrappingLabelWithString: "")
29
+    private let addButton = ProfilesPrimaryButton(title: "Add new profile  →", target: nil, action: nil)
30
+
31
+    override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
32
+        get { .leftToRight }
33
+        set { super.userInterfaceLayoutDirection = .leftToRight }
34
+    }
35
+
36
+    override init(frame frameRect: NSRect) {
37
+        super.init(frame: frameRect)
38
+        setup()
39
+    }
40
+
41
+    required init?(coder: NSCoder) {
42
+        super.init(coder: coder)
43
+        setup()
44
+    }
45
+
46
+    func reloadFromStore() {
47
+        for row in contentStack.arrangedSubviews {
48
+            contentStack.removeArrangedSubview(row)
49
+            row.removeFromSuperview()
50
+        }
51
+
52
+        let profiles = SavedProfilesStore.loadAll()
53
+        emptyStateLabel.isHidden = !profiles.isEmpty
54
+
55
+        for profile in profiles {
56
+            let row = ProfileListRowView(profile: profile)
57
+            row.translatesAutoresizingMaskIntoConstraints = false
58
+            row.onEdit = { [weak self] id in self?.onEditProfile?(id) }
59
+            row.onDelete = { [weak self] id in self?.onDeleteProfile?(id) }
60
+            contentStack.addArrangedSubview(row)
61
+            row.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
62
+        }
63
+    }
64
+
65
+    private func setup() {
66
+        wantsLayer = true
67
+        layer?.backgroundColor = ProfilesListPalette.pageBackground.cgColor
68
+        userInterfaceLayoutDirection = .leftToRight
69
+
70
+        let title = NSTextField(labelWithString: "Profiles")
71
+        title.font = .systemFont(ofSize: 22, weight: .semibold)
72
+        title.textColor = ProfilesListPalette.primaryText
73
+
74
+        let subtitle = NSTextField(wrappingLabelWithString: "Create and manage CV profiles. Each profile stores your details on this Mac.")
75
+        subtitle.font = .systemFont(ofSize: 13, weight: .regular)
76
+        subtitle.textColor = ProfilesListPalette.secondaryText
77
+        subtitle.maximumNumberOfLines = 0
78
+
79
+        emptyStateLabel.stringValue = "No profiles yet. Tap “Add new profile” to create your first one."
80
+        emptyStateLabel.font = .systemFont(ofSize: 13, weight: .regular)
81
+        emptyStateLabel.textColor = ProfilesListPalette.secondaryText
82
+        emptyStateLabel.isHidden = true
83
+
84
+        addButton.target = self
85
+        addButton.action = #selector(didTapAdd)
86
+        addButton.translatesAutoresizingMaskIntoConstraints = false
87
+
88
+        let headerStack = NSStackView(views: [title, subtitle, emptyStateLabel, addButton])
89
+        headerStack.orientation = .vertical
90
+        headerStack.alignment = .leading
91
+        headerStack.spacing = 10
92
+        headerStack.translatesAutoresizingMaskIntoConstraints = false
93
+        headerStack.setCustomSpacing(16, after: subtitle)
94
+
95
+        contentStack.orientation = .vertical
96
+        contentStack.alignment = .leading
97
+        contentStack.spacing = 12
98
+        contentStack.translatesAutoresizingMaskIntoConstraints = false
99
+
100
+        documentView.translatesAutoresizingMaskIntoConstraints = false
101
+        documentView.addSubview(headerStack)
102
+        documentView.addSubview(contentStack)
103
+
104
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
105
+        scrollView.drawsBackground = false
106
+        scrollView.hasVerticalScroller = true
107
+        scrollView.hasHorizontalScroller = false
108
+        scrollView.autohidesScrollers = true
109
+        scrollView.borderType = .noBorder
110
+        scrollView.documentView = documentView
111
+
112
+        addSubview(scrollView)
113
+
114
+        NSLayoutConstraint.activate([
115
+            scrollView.leftAnchor.constraint(equalTo: leftAnchor),
116
+            scrollView.rightAnchor.constraint(equalTo: rightAnchor),
117
+            scrollView.topAnchor.constraint(equalTo: topAnchor),
118
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
119
+
120
+            documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
121
+            documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
122
+            documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
123
+            documentView.bottomAnchor.constraint(equalTo: contentStack.bottomAnchor, constant: 32),
124
+
125
+            headerStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
126
+            headerStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
127
+            headerStack.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 24),
128
+
129
+            contentStack.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 32),
130
+            contentStack.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -32),
131
+            contentStack.topAnchor.constraint(equalTo: headerStack.bottomAnchor, constant: 24),
132
+
133
+            headerStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64),
134
+            contentStack.widthAnchor.constraint(equalTo: documentView.widthAnchor, constant: -64)
135
+        ])
136
+
137
+        reloadFromStore()
138
+    }
139
+
140
+    @objc private func didTapAdd() {
141
+        onAddProfile?()
142
+    }
143
+}
144
+
145
+// MARK: - Row
146
+
147
+private final class ProfileListRowView: NSView {
148
+    var onEdit: ((UUID) -> Void)?
149
+    var onDelete: ((UUID) -> Void)?
150
+
151
+    private let profileID: UUID
152
+    private let editButton = NSButton(title: "Edit", target: nil, action: nil)
153
+    private let deleteButton = NSButton(title: "Delete", target: nil, action: nil)
154
+
155
+    init(profile: SavedProfile) {
156
+        self.profileID = profile.id
157
+        super.init(frame: .zero)
158
+        translatesAutoresizingMaskIntoConstraints = false
159
+        wantsLayer = true
160
+        layer?.cornerRadius = 14
161
+        layer?.borderWidth = 1
162
+        layer?.borderColor = ProfilesListPalette.border.cgColor
163
+        layer?.backgroundColor = ProfilesListPalette.cardBackground.cgColor
164
+        if #available(macOS 11.0, *) {
165
+            layer?.cornerCurve = .continuous
166
+        }
167
+
168
+        let name = NSTextField(labelWithString: profile.profileDisplayName.isEmpty ? "Untitled profile" : profile.profileDisplayName)
169
+        name.font = .systemFont(ofSize: 15, weight: .semibold)
170
+        name.textColor = ProfilesListPalette.primaryText
171
+
172
+        let detailParts = [profile.personal.fullName, profile.personal.email].filter { !$0.isEmpty }
173
+        let detail = NSTextField(wrappingLabelWithString: detailParts.isEmpty ? "No contact details yet" : detailParts.joined(separator: " · "))
174
+        detail.font = .systemFont(ofSize: 12, weight: .regular)
175
+        detail.textColor = ProfilesListPalette.secondaryText
176
+        detail.maximumNumberOfLines = 2
177
+
178
+        let textStack = NSStackView(views: [name, detail])
179
+        textStack.orientation = .vertical
180
+        textStack.alignment = .leading
181
+        textStack.spacing = 4
182
+        textStack.translatesAutoresizingMaskIntoConstraints = false
183
+
184
+        editButton.translatesAutoresizingMaskIntoConstraints = false
185
+        editButton.bezelStyle = .rounded
186
+        editButton.isBordered = true
187
+        editButton.font = .systemFont(ofSize: 12, weight: .medium)
188
+        editButton.target = self
189
+        editButton.action = #selector(didTapEdit)
190
+
191
+        deleteButton.translatesAutoresizingMaskIntoConstraints = false
192
+        deleteButton.bezelStyle = .rounded
193
+        deleteButton.isBordered = false
194
+        deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
195
+        deleteButton.contentTintColor = ProfilesListPalette.destructive
196
+        deleteButton.target = self
197
+        deleteButton.action = #selector(didTapDelete)
198
+
199
+        let spacer = NSView()
200
+        spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
201
+
202
+        let actions = NSStackView(views: [editButton, deleteButton])
203
+        actions.orientation = .horizontal
204
+        actions.spacing = 8
205
+        actions.alignment = .centerY
206
+        actions.translatesAutoresizingMaskIntoConstraints = false
207
+
208
+        let row = NSStackView(views: [textStack, spacer, actions])
209
+        row.orientation = .horizontal
210
+        row.alignment = .top
211
+        row.spacing = 16
212
+        row.translatesAutoresizingMaskIntoConstraints = false
213
+
214
+        addSubview(row)
215
+        NSLayoutConstraint.activate([
216
+            row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
217
+            row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
218
+            row.topAnchor.constraint(equalTo: topAnchor, constant: 16),
219
+            row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
220
+        ])
221
+    }
222
+
223
+    required init?(coder: NSCoder) {
224
+        fatalError("init(coder:) has not been implemented")
225
+    }
226
+
227
+    @objc private func didTapEdit() {
228
+        onEdit?(profileID)
229
+    }
230
+
231
+    @objc private func didTapDelete() {
232
+        onDelete?(profileID)
233
+    }
234
+}
235
+
236
+// MARK: - Primary CTA (matches profile page button)
237
+
238
+private final class ProfilesPrimaryButton: NSButton {
239
+    private var trackingArea: NSTrackingArea?
240
+    private var didPushCursor = false
241
+
242
+    override init(frame frameRect: NSRect) {
243
+        super.init(frame: frameRect)
244
+        commonInit()
245
+    }
246
+
247
+    required init?(coder: NSCoder) {
248
+        super.init(coder: coder)
249
+        commonInit()
250
+    }
251
+
252
+    convenience init(title: String, target: AnyObject?, action: Selector?) {
253
+        self.init(frame: .zero)
254
+        self.title = title
255
+        self.target = target
256
+        self.action = action
257
+    }
258
+
259
+    private func commonInit() {
260
+        bezelStyle = .rounded
261
+        isBordered = false
262
+        font = .systemFont(ofSize: 16, weight: .semibold)
263
+        contentTintColor = .white
264
+        wantsLayer = true
265
+        layer?.cornerRadius = 14
266
+        if #available(macOS 11.0, *) {
267
+            layer?.cornerCurve = .continuous
268
+        }
269
+        layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
270
+    }
271
+
272
+    override func updateTrackingAreas() {
273
+        super.updateTrackingAreas()
274
+        if let trackingArea { removeTrackingArea(trackingArea) }
275
+        let area = NSTrackingArea(
276
+            rect: bounds,
277
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
278
+            owner: self,
279
+            userInfo: nil
280
+        )
281
+        addTrackingArea(area)
282
+        trackingArea = area
283
+    }
284
+
285
+    override func mouseEntered(with event: NSEvent) {
286
+        super.mouseEntered(with: event)
287
+        layer?.backgroundColor = ProfilesListPalette.brandBlueHover.cgColor
288
+        if !didPushCursor {
289
+            NSCursor.pointingHand.push()
290
+            didPushCursor = true
291
+        }
292
+    }
293
+
294
+    override func mouseExited(with event: NSEvent) {
295
+        super.mouseExited(with: event)
296
+        layer?.backgroundColor = ProfilesListPalette.brandBlue.cgColor
297
+        if didPushCursor {
298
+            NSCursor.pop()
299
+            didPushCursor = false
300
+        }
301
+    }
302
+
303
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
304
+        super.viewWillMove(toWindow: newWindow)
305
+        if newWindow == nil, didPushCursor {
306
+            NSCursor.pop()
307
+            didPushCursor = false
308
+        }
309
+    }
310
+}