Преглед изворни кода

Add My Profile page and wire it into the dashboard

Introduce MyProfilePageView with a scrollable card layout for profile
fields and photo upload. Embed it from DashboardView when the Profile
sidebar item is selected, matching other non-home sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 пре 3 недеља
родитељ
комит
c1a81de065
2 измењених фајлова са 536 додато и 3 уклоњено
  1. 37 3
      App for Indeed/Views/DashboardView.swift
  2. 499 0
      App for Indeed/Views/MyProfilePageView.swift

+ 37 - 3
App for Indeed/Views/DashboardView.swift

@@ -125,6 +125,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
125 125
     private lazy var cvMakerPageView: CVMakerPageView = {
126 126
         CVMakerPageView()
127 127
     }()
128
+    private let profilePageContainer = NSView()
129
+    private lazy var myProfilePageView: MyProfilePageView = {
130
+        MyProfilePageView()
131
+    }()
128 132
 
129 133
     private var currentSidebarItems: [SidebarItem] = []
130 134
     private var selectedSidebarIndex: Int = 0
@@ -1186,10 +1190,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1186 1190
         savedJobsPageContainer.translatesAutoresizingMaskIntoConstraints = false
1187 1191
         settingsPageContainer.translatesAutoresizingMaskIntoConstraints = false
1188 1192
         cvMakerPageContainer.translatesAutoresizingMaskIntoConstraints = false
1193
+        profilePageContainer.translatesAutoresizingMaskIntoConstraints = false
1189 1194
         nonHomeHost.addSubview(nonHomeGenericContainer)
1190 1195
         nonHomeHost.addSubview(savedJobsPageContainer)
1191 1196
         nonHomeHost.addSubview(settingsPageContainer)
1192 1197
         nonHomeHost.addSubview(cvMakerPageContainer)
1198
+        nonHomeHost.addSubview(profilePageContainer)
1193 1199
 
1194 1200
         NSLayoutConstraint.activate([
1195 1201
             nonHomeGenericContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
@@ -1210,7 +1216,12 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1210 1216
             cvMakerPageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
1211 1217
             cvMakerPageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
1212 1218
             cvMakerPageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
1213
-            cvMakerPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
1219
+            cvMakerPageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor),
1220
+
1221
+            profilePageContainer.leadingAnchor.constraint(equalTo: nonHomeHost.leadingAnchor),
1222
+            profilePageContainer.trailingAnchor.constraint(equalTo: nonHomeHost.trailingAnchor),
1223
+            profilePageContainer.topAnchor.constraint(equalTo: nonHomeHost.topAnchor),
1224
+            profilePageContainer.bottomAnchor.constraint(equalTo: nonHomeHost.bottomAnchor)
1214 1225
         ])
1215 1226
 
1216 1227
         nonHomeTitleLabel.font = .systemFont(ofSize: 22, weight: .bold)
@@ -1305,6 +1316,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1305 1316
 
1306 1317
         configureSettingsPage()
1307 1318
         configureCVMakerPage()
1319
+        configureProfilePage()
1308 1320
     }
1309 1321
 
1310 1322
     private func configureCVMakerPage() {
@@ -1322,6 +1334,21 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1322 1334
         ])
1323 1335
     }
1324 1336
 
1337
+    private func configureProfilePage() {
1338
+        profilePageContainer.wantsLayer = true
1339
+        profilePageContainer.layer?.backgroundColor = Theme.mainHostBackground.cgColor
1340
+        profilePageContainer.isHidden = true
1341
+
1342
+        myProfilePageView.translatesAutoresizingMaskIntoConstraints = false
1343
+        profilePageContainer.addSubview(myProfilePageView)
1344
+        NSLayoutConstraint.activate([
1345
+            myProfilePageView.leadingAnchor.constraint(equalTo: profilePageContainer.leadingAnchor),
1346
+            myProfilePageView.trailingAnchor.constraint(equalTo: profilePageContainer.trailingAnchor),
1347
+            myProfilePageView.topAnchor.constraint(equalTo: profilePageContainer.topAnchor),
1348
+            myProfilePageView.bottomAnchor.constraint(equalTo: profilePageContainer.bottomAnchor)
1349
+        ])
1350
+    }
1351
+
1325 1352
     private func configureSettingsPage() {
1326 1353
         settingsPageContainer.wantsLayer = true
1327 1354
         settingsPageContainer.layer?.backgroundColor = Theme.settingsPageBackground.cgColor
@@ -1555,6 +1582,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1555 1582
         return currentSidebarItems[index].title == "CV Maker"
1556 1583
     }
1557 1584
 
1585
+    private func isProfileSidebarIndex(_ index: Int) -> Bool {
1586
+        guard index >= 0, index < currentSidebarItems.count else { return false }
1587
+        return currentSidebarItems[index].title == "Profile"
1588
+    }
1589
+
1558 1590
     private func updateMainContentVisibility() {
1559 1591
         if isIndeedJobBrowserPresented {
1560 1592
             mainOverlay.isHidden = true
@@ -1567,16 +1599,18 @@ final class DashboardView: NSView, NSTextFieldDelegate {
1567 1599
         let savedJobs = isSavedJobsSidebarIndex(selectedSidebarIndex)
1568 1600
         let settings = isSettingsSidebarIndex(selectedSidebarIndex)
1569 1601
         let cvMaker = isCVMakerSidebarIndex(selectedSidebarIndex)
1602
+        let profile = isProfileSidebarIndex(selectedSidebarIndex)
1570 1603
         mainOverlay.isHidden = !home
1571 1604
         nonHomeHost.isHidden = home
1572
-        nonHomeGenericContainer.isHidden = savedJobs || settings || cvMaker
1605
+        nonHomeGenericContainer.isHidden = savedJobs || settings || cvMaker || profile
1573 1606
         savedJobsPageContainer.isHidden = !savedJobs
1574 1607
         settingsPageContainer.isHidden = !settings
1575 1608
         cvMakerPageContainer.isHidden = !cvMaker
1609
+        profilePageContainer.isHidden = !profile
1576 1610
         if !home, selectedSidebarIndex < currentSidebarItems.count {
1577 1611
             if savedJobs {
1578 1612
                 reloadSavedJobsListings()
1579
-            } else if settings || cvMaker {
1613
+            } else if settings || cvMaker || profile {
1580 1614
                 window?.makeFirstResponder(nil)
1581 1615
             } else {
1582 1616
                 nonHomeTitleLabel.stringValue = currentSidebarItems[selectedSidebarIndex].title

+ 499 - 0
App for Indeed/Views/MyProfilePageView.swift

@@ -0,0 +1,499 @@
1
+//
2
+//  MyProfilePageView.swift
3
+//  App for Indeed
4
+//
5
+//  Light-theme profile editor: card layout, adaptive two-column rows, and
6
+//  vertical scrolling when the window is short.
7
+//
8
+
9
+import Cocoa
10
+import UniformTypeIdentifiers
11
+
12
+private enum ProfilePagePalette {
13
+    static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
14
+    static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
15
+    static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
16
+    static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
17
+    static let fieldFill = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
18
+    static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
19
+    static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
20
+    static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
21
+    static let avatarWell = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
22
+}
23
+
24
+final class MyProfilePageView: NSView {
25
+    private static let compactFormWidth: CGFloat = 520
26
+
27
+    private let scrollView = NSScrollView()
28
+    private let documentView = NSView()
29
+    private let cardView = NSView()
30
+    private let formStack = NSStackView()
31
+
32
+    private let profileNameField = NSTextField()
33
+    private let fullNameField = NSTextField()
34
+    private let emailField = NSTextField()
35
+    private let phoneField = NSTextField()
36
+    private let jobTitleField = NSTextField()
37
+    private let addressField = NSTextField()
38
+    private let careerField = NSTextField()
39
+    private let avatarImageView = NSImageView()
40
+    private let uploadPhotoButton = NSButton(title: "Upload Photo", target: nil, action: nil)
41
+    private let saveButton = ProfilePrimaryButton(title: "Save Profile  →", target: nil, action: nil)
42
+
43
+    private let nameEmailRow = NSStackView()
44
+    private let phoneJobRow = NSStackView()
45
+
46
+    private var lastCompactLayout: Bool?
47
+
48
+    override init(frame frameRect: NSRect) {
49
+        super.init(frame: frameRect)
50
+        setup()
51
+    }
52
+
53
+    required init?(coder: NSCoder) {
54
+        super.init(coder: coder)
55
+        setup()
56
+    }
57
+
58
+    override func layout() {
59
+        super.layout()
60
+        applyResponsiveRowsIfNeeded()
61
+    }
62
+
63
+    private func setup() {
64
+        wantsLayer = true
65
+        layer?.backgroundColor = ProfilePagePalette.pageBackground.cgColor
66
+
67
+        scrollView.translatesAutoresizingMaskIntoConstraints = false
68
+        scrollView.hasVerticalScroller = true
69
+        scrollView.hasHorizontalScroller = false
70
+        scrollView.autohidesScrollers = true
71
+        scrollView.drawsBackground = false
72
+        scrollView.borderType = .noBorder
73
+        scrollView.scrollerStyle = .overlay
74
+        scrollView.automaticallyAdjustsContentInsets = false
75
+
76
+        documentView.translatesAutoresizingMaskIntoConstraints = false
77
+        documentView.userInterfaceLayoutDirection = .leftToRight
78
+
79
+        cardView.translatesAutoresizingMaskIntoConstraints = false
80
+        cardView.wantsLayer = true
81
+        cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
82
+        cardView.layer?.cornerRadius = 16
83
+        cardView.layer?.borderWidth = 1
84
+        cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
85
+        cardView.userInterfaceLayoutDirection = .leftToRight
86
+        if #available(macOS 11.0, *) {
87
+            cardView.layer?.cornerCurve = .continuous
88
+        }
89
+
90
+        formStack.translatesAutoresizingMaskIntoConstraints = false
91
+        formStack.orientation = .vertical
92
+        formStack.alignment = .width
93
+        formStack.spacing = 20
94
+        formStack.edgeInsets = NSEdgeInsets(top: 28, left: 28, bottom: 28, right: 28)
95
+        formStack.userInterfaceLayoutDirection = .leftToRight
96
+
97
+        addSubview(scrollView)
98
+        scrollView.documentView = documentView
99
+        documentView.addSubview(cardView)
100
+        cardView.addSubview(formStack)
101
+
102
+        NSLayoutConstraint.activate([
103
+            scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
104
+            scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
105
+            scrollView.topAnchor.constraint(equalTo: topAnchor),
106
+            scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
107
+
108
+            documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
109
+            documentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
110
+            documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
111
+            documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
112
+
113
+            cardView.leadingAnchor.constraint(equalTo: documentView.leadingAnchor, constant: 24),
114
+            cardView.trailingAnchor.constraint(equalTo: documentView.trailingAnchor, constant: -24),
115
+            cardView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: 16),
116
+            cardView.bottomAnchor.constraint(equalTo: documentView.bottomAnchor, constant: -24),
117
+
118
+            formStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor),
119
+            formStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor),
120
+            formStack.topAnchor.constraint(equalTo: cardView.topAnchor),
121
+            formStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor)
122
+        ])
123
+
124
+        formStack.addArrangedSubview(labeledGroup(title: "Profile Name *", field: profileNameField, placeholder: "Marketing Director Profile"))
125
+        formStack.addArrangedSubview(sectionHeading("Personal Information"))
126
+
127
+        let nameGroup = labeledGroup(title: "Full Name *", field: fullNameField, placeholder: "John Doe")
128
+        let emailGroup = labeledGroup(title: "Email *", field: emailField, placeholder: "john@example.com")
129
+        configureTwoColumnRow(nameEmailRow, left: nameGroup, right: emailGroup)
130
+        pinEqualColumnWidths(in: nameEmailRow)
131
+        formStack.addArrangedSubview(nameEmailRow)
132
+
133
+        let phoneGroup = labeledGroup(title: "Phone", field: phoneField, placeholder: "+1 (555) 123-4567")
134
+        let jobGroup = labeledGroup(title: "Job Title *", field: jobTitleField, placeholder: "Software Engineer")
135
+        configureTwoColumnRow(phoneJobRow, left: phoneGroup, right: jobGroup)
136
+        pinEqualColumnWidths(in: phoneJobRow)
137
+        formStack.addArrangedSubview(phoneJobRow)
138
+
139
+        formStack.addArrangedSubview(labeledGroup(title: "Address", field: addressField, placeholder: "123 Main St, City, State, ZIP"))
140
+        formStack.addArrangedSubview(careerSummaryBlock())
141
+        formStack.addArrangedSubview(profileImageBlock())
142
+        formStack.addArrangedSubview(saveButtonHost())
143
+
144
+        uploadPhotoButton.target = self
145
+        uploadPhotoButton.action = #selector(didTapUploadPhoto)
146
+        saveButton.target = self
147
+        saveButton.action = #selector(didTapSave)
148
+    }
149
+
150
+    private func applyResponsiveRowsIfNeeded() {
151
+        let w = cardView.bounds.width
152
+        guard w > 1 else { return }
153
+        let formWidth = max(0, w - formStack.edgeInsets.left - formStack.edgeInsets.right)
154
+        let compact = formWidth < Self.compactFormWidth
155
+        guard compact != lastCompactLayout else { return }
156
+        lastCompactLayout = compact
157
+        let orientation: NSUserInterfaceLayoutOrientation = compact ? .vertical : .horizontal
158
+        let rowSpacing: CGFloat = compact ? 16 : 12
159
+        nameEmailRow.orientation = orientation
160
+        nameEmailRow.spacing = rowSpacing
161
+        phoneJobRow.orientation = orientation
162
+        phoneJobRow.spacing = rowSpacing
163
+        nameEmailRow.distribution = compact ? .fill : .fillEqually
164
+        phoneJobRow.distribution = compact ? .fill : .fillEqually
165
+    }
166
+
167
+    /// Keeps two columns the same width when the row is horizontal; avoids NSTextField intrinsic width fighting the stack.
168
+    private func pinEqualColumnWidths(in row: NSStackView) {
169
+        guard row.arrangedSubviews.count == 2 else { return }
170
+        let left = row.arrangedSubviews[0]
171
+        let right = row.arrangedSubviews[1]
172
+        left.widthAnchor.constraint(equalTo: right.widthAnchor).isActive = true
173
+    }
174
+
175
+    private func configureTwoColumnRow(_ row: NSStackView, left: NSView, right: NSView) {
176
+        row.translatesAutoresizingMaskIntoConstraints = false
177
+        row.orientation = .horizontal
178
+        row.spacing = 12
179
+        row.distribution = .fillEqually
180
+        row.alignment = .top
181
+        row.userInterfaceLayoutDirection = .leftToRight
182
+        row.setContentHuggingPriority(.defaultLow, for: .horizontal)
183
+        row.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
184
+        row.addArrangedSubview(left)
185
+        row.addArrangedSubview(right)
186
+    }
187
+
188
+    private func sectionHeading(_ text: String) -> NSTextField {
189
+        let label = NSTextField(labelWithString: text)
190
+        label.font = .systemFont(ofSize: 15, weight: .semibold)
191
+        label.textColor = ProfilePagePalette.primaryText
192
+        label.alignment = .left
193
+        label.translatesAutoresizingMaskIntoConstraints = false
194
+        label.setContentHuggingPriority(.defaultLow, for: .horizontal)
195
+        return label
196
+    }
197
+
198
+    private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
199
+        let label = NSTextField(labelWithString: title)
200
+        label.font = .systemFont(ofSize: 12, weight: .medium)
201
+        label.textColor = ProfilePagePalette.secondaryText
202
+        label.alignment = .left
203
+        label.translatesAutoresizingMaskIntoConstraints = false
204
+
205
+        styleSingleLineField(field, placeholder: placeholder)
206
+        let wrap = roundedFieldChrome(containing: field, minHeight: 40)
207
+
208
+        let stack = NSStackView(views: [label, wrap])
209
+        stack.orientation = .vertical
210
+        stack.spacing = 8
211
+        stack.alignment = .width
212
+        stack.translatesAutoresizingMaskIntoConstraints = false
213
+        stack.userInterfaceLayoutDirection = .leftToRight
214
+        stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
215
+        wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
216
+        wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
217
+        return stack
218
+    }
219
+
220
+    private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
221
+        field.translatesAutoresizingMaskIntoConstraints = false
222
+        field.isBordered = false
223
+        field.drawsBackground = false
224
+        field.focusRingType = .none
225
+        field.font = .systemFont(ofSize: 14, weight: .regular)
226
+        field.textColor = ProfilePagePalette.primaryText
227
+        field.setContentHuggingPriority(.defaultLow, for: .horizontal)
228
+        field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
229
+        field.placeholderAttributedString = NSAttributedString(
230
+            string: placeholder,
231
+            attributes: [
232
+                .foregroundColor: ProfilePagePalette.secondaryText,
233
+                .font: NSFont.systemFont(ofSize: 14, weight: .regular)
234
+            ]
235
+        )
236
+        field.cell?.usesSingleLineMode = true
237
+        field.cell?.wraps = false
238
+        field.cell?.isScrollable = true
239
+    }
240
+
241
+    private func roundedFieldChrome(containing field: NSTextField, minHeight: CGFloat) -> NSView {
242
+        let wrap = NSView()
243
+        wrap.translatesAutoresizingMaskIntoConstraints = false
244
+        wrap.wantsLayer = true
245
+        wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
246
+        wrap.layer?.cornerRadius = 10
247
+        wrap.layer?.borderWidth = 1
248
+        wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
249
+        if #available(macOS 11.0, *) {
250
+            wrap.layer?.cornerCurve = .continuous
251
+        }
252
+        wrap.addSubview(field)
253
+        NSLayoutConstraint.activate([
254
+            field.leadingAnchor.constraint(equalTo: wrap.leadingAnchor, constant: 12),
255
+            field.trailingAnchor.constraint(equalTo: wrap.trailingAnchor, constant: -12),
256
+            field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
257
+            wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
258
+        ])
259
+        return wrap
260
+    }
261
+
262
+    private func careerSummaryBlock() -> NSView {
263
+        let label = NSTextField(labelWithString: "Career Summary")
264
+        label.font = .systemFont(ofSize: 12, weight: .medium)
265
+        label.textColor = ProfilePagePalette.secondaryText
266
+        label.alignment = .left
267
+        label.translatesAutoresizingMaskIntoConstraints = false
268
+
269
+        careerField.translatesAutoresizingMaskIntoConstraints = false
270
+        careerField.isEditable = true
271
+        careerField.isSelectable = true
272
+        careerField.isBordered = false
273
+        careerField.drawsBackground = false
274
+        careerField.focusRingType = .none
275
+        careerField.font = .systemFont(ofSize: 14, weight: .regular)
276
+        careerField.textColor = ProfilePagePalette.primaryText
277
+        careerField.maximumNumberOfLines = 0
278
+        careerField.cell?.wraps = true
279
+        careerField.cell?.isScrollable = false
280
+        careerField.cell?.usesSingleLineMode = false
281
+        careerField.stringValue = ""
282
+        careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
283
+        careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
284
+        careerField.placeholderAttributedString = NSAttributedString(
285
+            string: "Brief overview of your professional background and key achievements...",
286
+            attributes: [
287
+                .foregroundColor: ProfilePagePalette.secondaryText,
288
+                .font: NSFont.systemFont(ofSize: 14, weight: .regular)
289
+            ]
290
+        )
291
+
292
+        let wrap = NSView()
293
+        wrap.translatesAutoresizingMaskIntoConstraints = false
294
+        wrap.wantsLayer = true
295
+        wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
296
+        wrap.layer?.cornerRadius = 10
297
+        wrap.layer?.borderWidth = 1
298
+        wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
299
+        if #available(macOS 11.0, *) {
300
+            wrap.layer?.cornerCurve = .continuous
301
+        }
302
+        wrap.addSubview(careerField)
303
+        NSLayoutConstraint.activate([
304
+            careerField.leadingAnchor.constraint(equalTo: wrap.leadingAnchor, constant: 12),
305
+            careerField.trailingAnchor.constraint(equalTo: wrap.trailingAnchor, constant: -12),
306
+            careerField.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
307
+            careerField.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
308
+            wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
309
+        ])
310
+
311
+        let stack = NSStackView(views: [label, wrap])
312
+        stack.orientation = .vertical
313
+        stack.spacing = 8
314
+        stack.alignment = .width
315
+        stack.translatesAutoresizingMaskIntoConstraints = false
316
+        stack.userInterfaceLayoutDirection = .leftToRight
317
+        stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
318
+        wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
319
+        return stack
320
+    }
321
+
322
+    private func profileImageBlock() -> NSView {
323
+        let title = NSTextField(labelWithString: "Profile Image (Optional)")
324
+        title.font = .systemFont(ofSize: 12, weight: .medium)
325
+        title.textColor = ProfilePagePalette.secondaryText
326
+        title.translatesAutoresizingMaskIntoConstraints = false
327
+
328
+        let avatarHost = NSView()
329
+        avatarHost.translatesAutoresizingMaskIntoConstraints = false
330
+        avatarHost.wantsLayer = true
331
+        avatarHost.layer?.backgroundColor = ProfilePagePalette.avatarWell.cgColor
332
+        avatarHost.layer?.cornerRadius = 36
333
+        avatarHost.layer?.masksToBounds = true
334
+
335
+        avatarImageView.translatesAutoresizingMaskIntoConstraints = false
336
+        avatarImageView.imageScaling = .scaleProportionallyUpOrDown
337
+        avatarImageView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 36, weight: .light)
338
+        avatarImageView.image = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: nil)
339
+        avatarImageView.contentTintColor = ProfilePagePalette.secondaryText
340
+        avatarHost.addSubview(avatarImageView)
341
+        NSLayoutConstraint.activate([
342
+            avatarHost.widthAnchor.constraint(equalToConstant: 72),
343
+            avatarHost.heightAnchor.constraint(equalToConstant: 72),
344
+            avatarImageView.centerXAnchor.constraint(equalTo: avatarHost.centerXAnchor),
345
+            avatarImageView.centerYAnchor.constraint(equalTo: avatarHost.centerYAnchor),
346
+            avatarImageView.widthAnchor.constraint(equalToConstant: 44),
347
+            avatarImageView.heightAnchor.constraint(equalToConstant: 44)
348
+        ])
349
+
350
+        uploadPhotoButton.translatesAutoresizingMaskIntoConstraints = false
351
+        uploadPhotoButton.bezelStyle = .rounded
352
+        uploadPhotoButton.controlSize = .large
353
+        uploadPhotoButton.font = .systemFont(ofSize: 13, weight: .medium)
354
+        if let image = NSImage(systemSymbolName: "arrow.up.circle", accessibilityDescription: nil) {
355
+            uploadPhotoButton.image = image
356
+            uploadPhotoButton.imagePosition = .imageLeading
357
+            uploadPhotoButton.contentTintColor = ProfilePagePalette.brandBlue
358
+        }
359
+
360
+        let hint = NSTextField(wrappingLabelWithString: "Recommended: Square image, max 2MB")
361
+        hint.font = .systemFont(ofSize: 11, weight: .regular)
362
+        hint.textColor = ProfilePagePalette.secondaryText
363
+        hint.maximumNumberOfLines = 0
364
+
365
+        let rightColumn = NSStackView(views: [uploadPhotoButton, hint])
366
+        rightColumn.orientation = .vertical
367
+        rightColumn.alignment = .leading
368
+        rightColumn.spacing = 6
369
+        rightColumn.translatesAutoresizingMaskIntoConstraints = false
370
+
371
+        let row = NSStackView(views: [avatarHost, rightColumn])
372
+        row.orientation = .horizontal
373
+        row.alignment = .centerY
374
+        row.spacing = 16
375
+        row.translatesAutoresizingMaskIntoConstraints = false
376
+
377
+        let stack = NSStackView(views: [title, row])
378
+        stack.orientation = .vertical
379
+        stack.spacing = 12
380
+        stack.alignment = .width
381
+        stack.translatesAutoresizingMaskIntoConstraints = false
382
+        stack.userInterfaceLayoutDirection = .leftToRight
383
+        row.setContentHuggingPriority(.defaultLow, for: .horizontal)
384
+        row.alignment = .leading
385
+        return stack
386
+    }
387
+
388
+    private func saveButtonHost() -> NSView {
389
+        saveButton.translatesAutoresizingMaskIntoConstraints = false
390
+        let host = NSView()
391
+        host.translatesAutoresizingMaskIntoConstraints = false
392
+        host.userInterfaceLayoutDirection = .leftToRight
393
+        host.addSubview(saveButton)
394
+        NSLayoutConstraint.activate([
395
+            saveButton.leadingAnchor.constraint(equalTo: host.leadingAnchor),
396
+            saveButton.trailingAnchor.constraint(equalTo: host.trailingAnchor),
397
+            saveButton.topAnchor.constraint(equalTo: host.topAnchor),
398
+            saveButton.bottomAnchor.constraint(equalTo: host.bottomAnchor),
399
+            saveButton.heightAnchor.constraint(equalToConstant: 48)
400
+        ])
401
+        return host
402
+    }
403
+
404
+    @objc private func didTapUploadPhoto() {
405
+        let panel = NSOpenPanel()
406
+        panel.allowedContentTypes = [UTType.image]
407
+        panel.allowsMultipleSelection = false
408
+        panel.canChooseDirectories = false
409
+        guard let window else { return }
410
+        panel.beginSheetModal(for: window) { [weak self] response in
411
+            guard response == .OK, let url = panel.url else { return }
412
+            if let image = NSImage(contentsOf: url) {
413
+                self?.avatarImageView.image = image
414
+                self?.avatarImageView.contentTintColor = nil
415
+                self?.avatarImageView.imageScaling = .scaleAxesIndependently
416
+            }
417
+        }
418
+    }
419
+
420
+    @objc private func didTapSave() {
421
+        // UI shell only; wire persistence when profiles are stored.
422
+    }
423
+}
424
+
425
+// MARK: - Primary CTA
426
+
427
+private final class ProfilePrimaryButton: NSButton {
428
+    private var trackingArea: NSTrackingArea?
429
+    private var didPushCursor = false
430
+
431
+    override init(frame frameRect: NSRect) {
432
+        super.init(frame: frameRect)
433
+        commonInit()
434
+    }
435
+
436
+    required init?(coder: NSCoder) {
437
+        super.init(coder: coder)
438
+        commonInit()
439
+    }
440
+
441
+    convenience init(title: String, target: AnyObject?, action: Selector?) {
442
+        self.init(frame: .zero)
443
+        self.title = title
444
+        self.target = target
445
+        self.action = action
446
+    }
447
+
448
+    private func commonInit() {
449
+        bezelStyle = .rounded
450
+        isBordered = false
451
+        font = .systemFont(ofSize: 15, weight: .semibold)
452
+        contentTintColor = .white
453
+        wantsLayer = true
454
+        layer?.cornerRadius = 12
455
+        if #available(macOS 11.0, *) {
456
+            layer?.cornerCurve = .continuous
457
+        }
458
+        layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
459
+    }
460
+
461
+    override func updateTrackingAreas() {
462
+        super.updateTrackingAreas()
463
+        if let trackingArea { removeTrackingArea(trackingArea) }
464
+        let area = NSTrackingArea(
465
+            rect: bounds,
466
+            options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
467
+            owner: self,
468
+            userInfo: nil
469
+        )
470
+        addTrackingArea(area)
471
+        trackingArea = area
472
+    }
473
+
474
+    override func mouseEntered(with event: NSEvent) {
475
+        super.mouseEntered(with: event)
476
+        layer?.backgroundColor = ProfilePagePalette.brandBlueHover.cgColor
477
+        if !didPushCursor {
478
+            NSCursor.pointingHand.push()
479
+            didPushCursor = true
480
+        }
481
+    }
482
+
483
+    override func mouseExited(with event: NSEvent) {
484
+        super.mouseExited(with: event)
485
+        layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
486
+        if didPushCursor {
487
+            NSCursor.pop()
488
+            didPushCursor = false
489
+        }
490
+    }
491
+
492
+    override func viewWillMove(toWindow newWindow: NSWindow?) {
493
+        super.viewWillMove(toWindow: newWindow)
494
+        if newWindow == nil, didPushCursor {
495
+            NSCursor.pop()
496
+            didPushCursor = false
497
+        }
498
+    }
499
+}