Няма описание

MyProfilePageView.swift 60KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372
  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. import Cocoa
  9. private enum ProfilePagePalette {
  10. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  11. static let brandBlueHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
  12. static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  13. static let cardBackground = NSColor(srgbRed: 252 / 255, green: 252 / 255, blue: 252 / 255, alpha: 1)
  14. static let fieldFill = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
  15. static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
  16. static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  17. static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
  18. static let destructive = NSColor(srgbRed: 220 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1)
  19. }
  20. /// Keeps profile text left-aligned and LTR so fields do not collapse to a narrow trailing strip under RTL / natural alignment.
  21. private enum ProfileLayoutEnforcement {
  22. static func applyForcedLTRSubtree(from root: NSView) {
  23. var stack: [NSView] = [root]
  24. while let view = stack.popLast() {
  25. applyForcedLTR(to: view)
  26. stack.append(contentsOf: view.subviews)
  27. }
  28. }
  29. static func applyForcedLTR(to view: NSView) {
  30. view.userInterfaceLayoutDirection = .leftToRight
  31. }
  32. static func applyLeftAlignedTextField(_ field: NSTextField) {
  33. applyForcedLTR(to: field)
  34. field.baseWritingDirection = .leftToRight
  35. field.alignment = .left
  36. if let cell = field.cell as? NSTextFieldCell {
  37. cell.alignment = .left
  38. cell.baseWritingDirection = .leftToRight
  39. }
  40. }
  41. static func leftAlignedParagraphStyle() -> NSParagraphStyle {
  42. let p = NSMutableParagraphStyle()
  43. p.alignment = .left
  44. p.baseWritingDirection = .leftToRight
  45. return p
  46. }
  47. }
  48. private extension NSStackView {
  49. /// For vertical stacks using `.leading` alignment (geometric left under mixed RTL), pin each arranged subview’s width to the stack so labels/fields stay full-width.
  50. func pinAllArrangedSubviewWidthsEqualToStackWidth() {
  51. for subview in arrangedSubviews {
  52. subview.widthAnchor.constraint(equalTo: widthAnchor).isActive = true
  53. }
  54. }
  55. }
  56. /// Two fields side‑by‑side with a true 50/50 split, or stacked full‑width when compact. Avoids `NSStackView` collapsing paired columns to a narrow strip on the trailing edge.
  57. private final class ProfileDualFieldRow: NSView {
  58. private let leftView: NSView
  59. private let rightView: NSView
  60. private let spacing: CGFloat
  61. private var horizontalConstraints: [NSLayoutConstraint] = []
  62. private var verticalConstraints: [NSLayoutConstraint] = []
  63. private var isCompact = false
  64. init(left: NSView, right: NSView, spacing: CGFloat = 12) {
  65. self.leftView = left
  66. self.rightView = right
  67. self.spacing = spacing
  68. super.init(frame: .zero)
  69. translatesAutoresizingMaskIntoConstraints = false
  70. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  71. addSubview(leftView)
  72. addSubview(rightView)
  73. leftView.translatesAutoresizingMaskIntoConstraints = false
  74. rightView.translatesAutoresizingMaskIntoConstraints = false
  75. leftView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  76. rightView.setContentHuggingPriority(.defaultLow, for: .horizontal)
  77. leftView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  78. rightView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  79. horizontalConstraints = [
  80. leftView.leftAnchor.constraint(equalTo: leftAnchor),
  81. leftView.topAnchor.constraint(equalTo: topAnchor),
  82. leftView.bottomAnchor.constraint(equalTo: bottomAnchor),
  83. rightView.rightAnchor.constraint(equalTo: rightAnchor),
  84. rightView.topAnchor.constraint(equalTo: topAnchor),
  85. rightView.bottomAnchor.constraint(equalTo: bottomAnchor),
  86. leftView.rightAnchor.constraint(equalTo: rightView.leftAnchor, constant: -spacing),
  87. leftView.widthAnchor.constraint(equalTo: rightView.widthAnchor)
  88. ]
  89. verticalConstraints = [
  90. leftView.leftAnchor.constraint(equalTo: leftAnchor),
  91. leftView.rightAnchor.constraint(equalTo: rightAnchor),
  92. leftView.topAnchor.constraint(equalTo: topAnchor),
  93. rightView.leftAnchor.constraint(equalTo: leftAnchor),
  94. rightView.rightAnchor.constraint(equalTo: rightAnchor),
  95. rightView.topAnchor.constraint(equalTo: leftView.bottomAnchor, constant: spacing),
  96. rightView.bottomAnchor.constraint(equalTo: bottomAnchor)
  97. ]
  98. NSLayoutConstraint.activate(horizontalConstraints)
  99. }
  100. required init?(coder: NSCoder) {
  101. fatalError("init(coder:) has not been implemented")
  102. }
  103. func setCompact(_ compact: Bool) {
  104. guard compact != isCompact else { return }
  105. isCompact = compact
  106. if compact {
  107. NSLayoutConstraint.deactivate(horizontalConstraints)
  108. NSLayoutConstraint.activate(verticalConstraints)
  109. } else {
  110. NSLayoutConstraint.deactivate(verticalConstraints)
  111. NSLayoutConstraint.activate(horizontalConstraints)
  112. }
  113. }
  114. }
  115. final class MyProfilePageView: NSView {
  116. /// Below this form content width, two-column rows stack vertically.
  117. private static let compactFormWidth: CGFloat = 640
  118. private static let horizontalPageInset: CGFloat = 24
  119. /// Inset of form content from the card border (left/right); explicit constraints so fields stay inside the card chrome.
  120. private static let cardContentHorizontalInset: CGFloat = 28
  121. private let scrollView = NSScrollView()
  122. private let documentView = NSView()
  123. private let cardView = NSView()
  124. private let formStack = NSStackView()
  125. private let profileNameField = NSTextField()
  126. private let fullNameField = NSTextField()
  127. private let emailField = NSTextField()
  128. private let phoneField = NSTextField()
  129. private let jobTitleField = NSTextField()
  130. private let addressField = NSTextField()
  131. private let careerField = NSTextField()
  132. private let certificatesField = NSTextField()
  133. private let interestsField = NSTextField()
  134. private let languagesField = NSTextField()
  135. private let referralField = NSTextField()
  136. private let saveButton = ProfilePrimaryButton(title: "Save Profile →", target: nil, action: nil)
  137. private var nameEmailRow: ProfileDualFieldRow!
  138. private var phoneJobRow: ProfileDualFieldRow!
  139. private let workExperienceRowsStack = NSStackView()
  140. private var workExperienceEntries: [WorkExperienceEntryView] = []
  141. private let educationRowsStack = NSStackView()
  142. private var educationEntries: [EducationEntryView] = []
  143. private var lastCompactLayout: Bool?
  144. private var referralHelperLabel: NSTextField?
  145. /// Force left-to-right geometry so profile fields span the full width even when the window uses RTL layout.
  146. override var userInterfaceLayoutDirection: NSUserInterfaceLayoutDirection {
  147. get { .leftToRight }
  148. set { super.userInterfaceLayoutDirection = .leftToRight }
  149. }
  150. override init(frame frameRect: NSRect) {
  151. super.init(frame: frameRect)
  152. setup()
  153. }
  154. required init?(coder: NSCoder) {
  155. super.init(coder: coder)
  156. setup()
  157. }
  158. override func viewDidMoveToWindow() {
  159. super.viewDidMoveToWindow()
  160. guard window != nil else { return }
  161. ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
  162. needsLayout = true
  163. }
  164. override func layout() {
  165. super.layout()
  166. if let layer = cardView.layer, layer.shadowOpacity > 0 {
  167. let r = layer.cornerRadius
  168. layer.shadowPath = CGPath(roundedRect: cardView.bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  169. }
  170. updateMultilinePreferredLayoutWidths()
  171. applyResponsiveRowsIfNeeded()
  172. }
  173. /// Wrapping `NSTextField`s report a tiny intrinsic width until `preferredMaxLayoutWidth` tracks the chrome width, which otherwise collapses the stack to a narrow trailing column.
  174. private func updateMultilinePreferredLayoutWidths() {
  175. let horizontalInset: CGFloat = 24
  176. applyPreferredWrapWidth(to: profileNameField, horizontalInset: horizontalInset)
  177. applyPreferredWrapWidth(to: fullNameField, horizontalInset: horizontalInset)
  178. applyPreferredWrapWidth(to: emailField, horizontalInset: horizontalInset)
  179. applyPreferredWrapWidth(to: phoneField, horizontalInset: horizontalInset)
  180. applyPreferredWrapWidth(to: jobTitleField, horizontalInset: horizontalInset)
  181. applyPreferredWrapWidth(to: addressField, horizontalInset: horizontalInset)
  182. applyPreferredWrapWidth(to: referralField, horizontalInset: horizontalInset)
  183. applyPreferredWrapWidth(to: careerField, horizontalInset: horizontalInset)
  184. applyPreferredWrapWidth(to: certificatesField, horizontalInset: horizontalInset)
  185. applyPreferredWrapWidth(to: interestsField, horizontalInset: horizontalInset)
  186. applyPreferredWrapWidth(to: languagesField, horizontalInset: horizontalInset)
  187. if let helper = referralHelperLabel, let stack = helper.superview, stack.bounds.width > 2 {
  188. let w = max(1, stack.bounds.width - 8)
  189. if abs(helper.preferredMaxLayoutWidth - w) > 0.5 {
  190. helper.preferredMaxLayoutWidth = w
  191. }
  192. }
  193. }
  194. private func applyPreferredWrapWidth(to field: NSTextField, horizontalInset: CGFloat) {
  195. guard let wrap = field.superview, wrap.bounds.width > 2 else { return }
  196. let w = max(1, wrap.bounds.width - horizontalInset)
  197. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  198. field.preferredMaxLayoutWidth = w
  199. }
  200. }
  201. private func setup() {
  202. wantsLayer = true
  203. layer?.backgroundColor = ProfilePagePalette.pageBackground.cgColor
  204. userInterfaceLayoutDirection = .leftToRight
  205. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  206. scrollView.translatesAutoresizingMaskIntoConstraints = false
  207. scrollView.userInterfaceLayoutDirection = .leftToRight
  208. ProfileLayoutEnforcement.applyForcedLTR(to: scrollView)
  209. scrollView.hasVerticalScroller = true
  210. scrollView.hasHorizontalScroller = false
  211. scrollView.autohidesScrollers = true
  212. scrollView.drawsBackground = false
  213. scrollView.borderType = .noBorder
  214. scrollView.scrollerStyle = .overlay
  215. scrollView.automaticallyAdjustsContentInsets = false
  216. scrollView.contentView.userInterfaceLayoutDirection = .leftToRight
  217. ProfileLayoutEnforcement.applyForcedLTR(to: scrollView.contentView)
  218. if #available(macOS 10.11, *) {
  219. scrollView.horizontalScrollElasticity = .none
  220. }
  221. documentView.translatesAutoresizingMaskIntoConstraints = false
  222. documentView.userInterfaceLayoutDirection = .leftToRight
  223. ProfileLayoutEnforcement.applyForcedLTR(to: documentView)
  224. cardView.translatesAutoresizingMaskIntoConstraints = false
  225. cardView.wantsLayer = true
  226. cardView.layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  227. cardView.layer?.cornerRadius = 18
  228. cardView.layer?.borderWidth = 1
  229. cardView.layer?.borderColor = ProfilePagePalette.border.cgColor
  230. cardView.layer?.masksToBounds = false
  231. cardView.layer?.shadowColor = NSColor.black.cgColor
  232. cardView.layer?.shadowOpacity = 0.06
  233. cardView.layer?.shadowRadius = 20
  234. cardView.layer?.shadowOffset = CGSize(width: 0, height: 10)
  235. cardView.userInterfaceLayoutDirection = .leftToRight
  236. ProfileLayoutEnforcement.applyForcedLTR(to: cardView)
  237. if #available(macOS 11.0, *) {
  238. cardView.layer?.cornerCurve = .continuous
  239. }
  240. formStack.translatesAutoresizingMaskIntoConstraints = false
  241. formStack.orientation = .vertical
  242. formStack.alignment = .leading
  243. formStack.distribution = .fill
  244. formStack.spacing = 24
  245. formStack.edgeInsets = NSEdgeInsets(top: 32, left: 0, bottom: 32, right: 0)
  246. formStack.userInterfaceLayoutDirection = .leftToRight
  247. ProfileLayoutEnforcement.applyForcedLTR(to: formStack)
  248. formStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  249. formStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  250. addSubview(scrollView)
  251. scrollView.documentView = documentView
  252. documentView.addSubview(cardView)
  253. cardView.addSubview(formStack)
  254. NSLayoutConstraint.activate([
  255. scrollView.leftAnchor.constraint(equalTo: leftAnchor),
  256. scrollView.rightAnchor.constraint(equalTo: rightAnchor),
  257. scrollView.topAnchor.constraint(equalTo: topAnchor),
  258. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  259. // Pin the document to the clip view’s geometric width so LTR/RTL semantics cannot slide the form.
  260. documentView.leftAnchor.constraint(equalTo: scrollView.contentView.leftAnchor),
  261. documentView.rightAnchor.constraint(equalTo: scrollView.contentView.rightAnchor),
  262. documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
  263. documentView.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: Self.horizontalPageInset),
  264. // Pin both edges so the card always spans the clip width minus insets; a separate width equal to a large constant can conflict with the clip and slide the card to the trailing edge.
  265. cardView.leftAnchor.constraint(equalTo: documentView.leftAnchor, constant: Self.horizontalPageInset),
  266. cardView.rightAnchor.constraint(equalTo: documentView.rightAnchor, constant: -Self.horizontalPageInset),
  267. cardView.topAnchor.constraint(equalTo: documentView.topAnchor, constant: Self.horizontalPageInset),
  268. formStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: Self.cardContentHorizontalInset),
  269. formStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -Self.cardContentHorizontalInset),
  270. formStack.topAnchor.constraint(equalTo: cardView.topAnchor),
  271. formStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor)
  272. ])
  273. addFullWidthArrangedSubview(
  274. labeledGroup(title: "Profile Name *", field: profileNameField, placeholder: "Marketing Director Profile")
  275. )
  276. addFullWidthArrangedSubview(sectionHeading("Personal Information"))
  277. let nameGroup = labeledGroup(title: "Full Name *", field: fullNameField, placeholder: "John Doe")
  278. let emailGroup = labeledGroup(title: "Email *", field: emailField, placeholder: "john@example.com")
  279. nameEmailRow = ProfileDualFieldRow(left: nameGroup, right: emailGroup, spacing: 12)
  280. addFullWidthArrangedSubview(nameEmailRow)
  281. let phoneGroup = labeledGroup(title: "Phone", field: phoneField, placeholder: "+1 (555) 123-4567")
  282. let jobGroup = labeledGroup(title: "Job Title *", field: jobTitleField, placeholder: "Software Engineer")
  283. phoneJobRow = ProfileDualFieldRow(left: phoneGroup, right: jobGroup, spacing: 12)
  284. addFullWidthArrangedSubview(phoneJobRow)
  285. addFullWidthArrangedSubview(
  286. labeledGroup(title: "Address", field: addressField, placeholder: "123 Main St, City, State, ZIP")
  287. )
  288. addFullWidthArrangedSubview(careerSummaryBlock())
  289. addFullWidthArrangedSubview(horizontalSeparator())
  290. addFullWidthArrangedSubview(workExperienceSection())
  291. addFullWidthArrangedSubview(horizontalSeparator())
  292. addFullWidthArrangedSubview(educationSection())
  293. addFullWidthArrangedSubview(horizontalSeparator())
  294. addFullWidthArrangedSubview(
  295. multilineProfileBlock(
  296. title: "Certificates / Rewards",
  297. placeholder: "List your certificates and awards...",
  298. field: certificatesField,
  299. minHeight: 100
  300. )
  301. )
  302. addFullWidthArrangedSubview(horizontalSeparator())
  303. addFullWidthArrangedSubview(
  304. multilineProfileBlock(
  305. title: "Interests",
  306. placeholder: "List your interests and hobbies...",
  307. field: interestsField,
  308. minHeight: 100
  309. )
  310. )
  311. addFullWidthArrangedSubview(horizontalSeparator())
  312. addFullWidthArrangedSubview(
  313. multilineProfileBlock(
  314. title: "Languages",
  315. placeholder: "List languages you speak (e.g., English - Native, Spanish - Fluent)...",
  316. field: languagesField,
  317. minHeight: 100
  318. )
  319. )
  320. addFullWidthArrangedSubview(horizontalSeparator())
  321. addFullWidthArrangedSubview(referralBlock())
  322. addFullWidthArrangedSubview(saveButtonHost())
  323. saveButton.target = self
  324. saveButton.action = #selector(didTapSave)
  325. appendWorkExperienceEntry()
  326. appendEducationEntry()
  327. ProfileLayoutEnforcement.applyForcedLTRSubtree(from: self)
  328. }
  329. private func applyResponsiveRowsIfNeeded() {
  330. let w = cardView.bounds.width
  331. guard w > 1 else { return }
  332. let formWidth = max(0, w - 2 * Self.cardContentHorizontalInset - formStack.edgeInsets.left - formStack.edgeInsets.right)
  333. let compact = formWidth < Self.compactFormWidth
  334. guard compact != lastCompactLayout else { return }
  335. lastCompactLayout = compact
  336. nameEmailRow.setCompact(compact)
  337. phoneJobRow.setCompact(compact)
  338. for entry in workExperienceEntries {
  339. entry.applyCompactLayout(compact)
  340. }
  341. for entry in educationEntries {
  342. entry.applyCompactLayout(compact)
  343. }
  344. }
  345. private func addFullWidthArrangedSubview(_ view: NSView) {
  346. formStack.addArrangedSubview(view)
  347. view.setContentHuggingPriority(.defaultLow, for: .horizontal)
  348. view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  349. view.widthAnchor.constraint(equalTo: formStack.widthAnchor).isActive = true
  350. }
  351. private func sectionHeading(_ text: String) -> NSView {
  352. let label = NSTextField(labelWithString: text)
  353. label.font = .systemFont(ofSize: 15, weight: .semibold)
  354. label.textColor = ProfilePagePalette.primaryText
  355. label.baseWritingDirection = .leftToRight
  356. label.translatesAutoresizingMaskIntoConstraints = false
  357. label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  358. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  359. let spacer = NSView()
  360. spacer.translatesAutoresizingMaskIntoConstraints = false
  361. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  362. let row = NSStackView(views: [label, spacer])
  363. row.orientation = .horizontal
  364. row.alignment = .centerY
  365. row.distribution = .fill
  366. row.spacing = 0
  367. row.userInterfaceLayoutDirection = .leftToRight
  368. ProfileLayoutEnforcement.applyForcedLTR(to: row)
  369. row.translatesAutoresizingMaskIntoConstraints = false
  370. NSLayoutConstraint.activate([
  371. label.leftAnchor.constraint(equalTo: row.leftAnchor)
  372. ])
  373. return row
  374. }
  375. private func labeledGroup(title: String, field: NSTextField, placeholder: String) -> NSView {
  376. let label = NSTextField(labelWithString: title)
  377. label.font = .systemFont(ofSize: 12, weight: .medium)
  378. label.textColor = ProfilePagePalette.secondaryText
  379. label.translatesAutoresizingMaskIntoConstraints = false
  380. label.setContentHuggingPriority(.defaultLow, for: .horizontal)
  381. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  382. styleSingleLineField(field, placeholder: placeholder)
  383. let wrap = roundedFieldChrome(containing: field, minHeight: 40)
  384. let stack = NSStackView(views: [label, wrap])
  385. stack.orientation = .vertical
  386. stack.spacing = 8
  387. // `.leading` keeps rows on the geometric left; explicit widths keep labels/fields full-width (`.width` alone can still hug the trailing edge under RTL-style layout).
  388. stack.alignment = .leading
  389. stack.translatesAutoresizingMaskIntoConstraints = false
  390. stack.userInterfaceLayoutDirection = .leftToRight
  391. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  392. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  393. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  394. wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  395. NSLayoutConstraint.activate([
  396. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  397. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  398. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  399. ])
  400. return stack
  401. }
  402. private func styleSingleLineField(_ field: NSTextField, placeholder: String) {
  403. field.translatesAutoresizingMaskIntoConstraints = false
  404. field.isBordered = false
  405. field.drawsBackground = false
  406. field.focusRingType = .none
  407. field.font = .systemFont(ofSize: 14, weight: .regular)
  408. field.textColor = ProfilePagePalette.primaryText
  409. field.setContentHuggingPriority(.defaultLow, for: .horizontal)
  410. field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  411. let paragraph = ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  412. field.placeholderAttributedString = NSAttributedString(
  413. string: placeholder,
  414. attributes: [
  415. .foregroundColor: ProfilePagePalette.secondaryText,
  416. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  417. .paragraphStyle: paragraph
  418. ]
  419. )
  420. field.cell?.usesSingleLineMode = true
  421. field.cell?.wraps = false
  422. field.cell?.isScrollable = true
  423. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  424. }
  425. private func roundedFieldChrome(containing field: NSTextField, minHeight: CGFloat) -> NSView {
  426. let wrap = NSView()
  427. wrap.translatesAutoresizingMaskIntoConstraints = false
  428. wrap.wantsLayer = true
  429. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  430. wrap.layer?.cornerRadius = 10
  431. wrap.layer?.borderWidth = 1
  432. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  433. if #available(macOS 11.0, *) {
  434. wrap.layer?.cornerCurve = .continuous
  435. }
  436. wrap.addSubview(field)
  437. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  438. NSLayoutConstraint.activate([
  439. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  440. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  441. field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
  442. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  443. ])
  444. return wrap
  445. }
  446. private func careerSummaryBlock() -> NSView {
  447. let label = NSTextField(labelWithString: "Career Summary")
  448. label.font = .systemFont(ofSize: 12, weight: .medium)
  449. label.textColor = ProfilePagePalette.secondaryText
  450. label.translatesAutoresizingMaskIntoConstraints = false
  451. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  452. careerField.translatesAutoresizingMaskIntoConstraints = false
  453. careerField.isEditable = true
  454. careerField.isSelectable = true
  455. careerField.isBordered = false
  456. careerField.drawsBackground = false
  457. careerField.focusRingType = .none
  458. careerField.font = .systemFont(ofSize: 14, weight: .regular)
  459. careerField.textColor = ProfilePagePalette.primaryText
  460. careerField.maximumNumberOfLines = 0
  461. careerField.cell?.wraps = true
  462. careerField.cell?.isScrollable = false
  463. careerField.cell?.usesSingleLineMode = false
  464. careerField.stringValue = ""
  465. careerField.setContentHuggingPriority(.defaultLow, for: .horizontal)
  466. careerField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  467. careerField.placeholderAttributedString = NSAttributedString(
  468. string: "Brief overview of your professional background and key achievements...",
  469. attributes: [
  470. .foregroundColor: ProfilePagePalette.secondaryText,
  471. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  472. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  473. ]
  474. )
  475. ProfileLayoutEnforcement.applyLeftAlignedTextField(careerField)
  476. let wrap = NSView()
  477. wrap.translatesAutoresizingMaskIntoConstraints = false
  478. wrap.wantsLayer = true
  479. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  480. wrap.layer?.cornerRadius = 10
  481. wrap.layer?.borderWidth = 1
  482. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  483. if #available(macOS 11.0, *) {
  484. wrap.layer?.cornerCurve = .continuous
  485. }
  486. wrap.addSubview(careerField)
  487. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  488. NSLayoutConstraint.activate([
  489. careerField.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  490. careerField.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  491. careerField.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  492. careerField.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  493. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: 168)
  494. ])
  495. let stack = NSStackView(views: [label, wrap])
  496. stack.orientation = .vertical
  497. stack.spacing = 8
  498. stack.alignment = .leading
  499. stack.translatesAutoresizingMaskIntoConstraints = false
  500. stack.userInterfaceLayoutDirection = .leftToRight
  501. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  502. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  503. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  504. NSLayoutConstraint.activate([
  505. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  506. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  507. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  508. ])
  509. return stack
  510. }
  511. private func multilineProfileBlock(title: String, placeholder: String, field: NSTextField, minHeight: CGFloat) -> NSView {
  512. let label = NSTextField(labelWithString: title)
  513. label.font = .systemFont(ofSize: 12, weight: .medium)
  514. label.textColor = ProfilePagePalette.secondaryText
  515. label.translatesAutoresizingMaskIntoConstraints = false
  516. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  517. field.translatesAutoresizingMaskIntoConstraints = false
  518. field.isEditable = true
  519. field.isSelectable = true
  520. field.isBordered = false
  521. field.drawsBackground = false
  522. field.focusRingType = .none
  523. field.font = .systemFont(ofSize: 14, weight: .regular)
  524. field.textColor = ProfilePagePalette.primaryText
  525. field.maximumNumberOfLines = 0
  526. field.cell?.wraps = true
  527. field.cell?.isScrollable = false
  528. field.cell?.usesSingleLineMode = false
  529. field.stringValue = ""
  530. field.setContentHuggingPriority(.defaultLow, for: .horizontal)
  531. field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  532. field.placeholderAttributedString = NSAttributedString(
  533. string: placeholder,
  534. attributes: [
  535. .foregroundColor: ProfilePagePalette.secondaryText,
  536. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  537. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  538. ]
  539. )
  540. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  541. let wrap = NSView()
  542. wrap.translatesAutoresizingMaskIntoConstraints = false
  543. wrap.wantsLayer = true
  544. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  545. wrap.layer?.cornerRadius = 10
  546. wrap.layer?.borderWidth = 1
  547. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  548. if #available(macOS 11.0, *) {
  549. wrap.layer?.cornerCurve = .continuous
  550. }
  551. wrap.addSubview(field)
  552. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  553. NSLayoutConstraint.activate([
  554. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  555. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  556. field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  557. field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  558. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  559. ])
  560. let stack = NSStackView(views: [label, wrap])
  561. stack.orientation = .vertical
  562. stack.spacing = 8
  563. stack.alignment = .leading
  564. stack.translatesAutoresizingMaskIntoConstraints = false
  565. stack.userInterfaceLayoutDirection = .leftToRight
  566. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  567. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  568. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  569. NSLayoutConstraint.activate([
  570. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  571. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  572. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  573. ])
  574. return stack
  575. }
  576. private func referralBlock() -> NSView {
  577. let label = NSTextField(labelWithString: "Referral (Optional)")
  578. label.font = .systemFont(ofSize: 12, weight: .medium)
  579. label.textColor = ProfilePagePalette.secondaryText
  580. label.translatesAutoresizingMaskIntoConstraints = false
  581. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  582. styleSingleLineField(referralField, placeholder: "Referred by (Company/Person Name)")
  583. let wrap = roundedFieldChrome(containing: referralField, minHeight: 40)
  584. let helper = NSTextField(wrappingLabelWithString: "If someone referred you for this job, enter their name or company here")
  585. helper.font = .systemFont(ofSize: 11, weight: .regular)
  586. helper.textColor = ProfilePagePalette.secondaryText
  587. helper.translatesAutoresizingMaskIntoConstraints = false
  588. ProfileLayoutEnforcement.applyLeftAlignedTextField(helper)
  589. referralHelperLabel = helper
  590. let stack = NSStackView(views: [label, wrap, helper])
  591. stack.orientation = .vertical
  592. stack.spacing = 8
  593. stack.alignment = .leading
  594. stack.translatesAutoresizingMaskIntoConstraints = false
  595. stack.userInterfaceLayoutDirection = .leftToRight
  596. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  597. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  598. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  599. if #available(macOS 10.11, *) {
  600. stack.setCustomSpacing(6, after: wrap)
  601. }
  602. NSLayoutConstraint.activate([
  603. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  604. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  605. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor),
  606. helper.leftAnchor.constraint(equalTo: stack.leftAnchor),
  607. helper.widthAnchor.constraint(equalTo: stack.widthAnchor)
  608. ])
  609. return stack
  610. }
  611. private func horizontalSeparator() -> NSView {
  612. let box = NSBox()
  613. box.boxType = .separator
  614. box.translatesAutoresizingMaskIntoConstraints = false
  615. return box
  616. }
  617. private func workExperienceSection() -> NSView {
  618. let title = NSTextField(labelWithString: "Work Experience")
  619. title.font = .systemFont(ofSize: 15, weight: .semibold)
  620. title.textColor = ProfilePagePalette.primaryText
  621. title.translatesAutoresizingMaskIntoConstraints = false
  622. title.setContentHuggingPriority(.defaultLow, for: .horizontal)
  623. ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
  624. let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddWorkExperience))
  625. addButton.translatesAutoresizingMaskIntoConstraints = false
  626. addButton.bezelStyle = .rounded
  627. addButton.isBordered = true
  628. addButton.font = .systemFont(ofSize: 12, weight: .medium)
  629. addButton.controlSize = .regular
  630. addButton.setContentHuggingPriority(.required, for: .horizontal)
  631. let spacer = NSView()
  632. spacer.translatesAutoresizingMaskIntoConstraints = false
  633. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  634. let headerRow = NSStackView(views: [title, spacer, addButton])
  635. headerRow.orientation = .horizontal
  636. headerRow.alignment = .centerY
  637. headerRow.distribution = .fill
  638. headerRow.spacing = 12
  639. headerRow.userInterfaceLayoutDirection = .leftToRight
  640. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  641. headerRow.translatesAutoresizingMaskIntoConstraints = false
  642. workExperienceRowsStack.translatesAutoresizingMaskIntoConstraints = false
  643. workExperienceRowsStack.orientation = .vertical
  644. workExperienceRowsStack.spacing = 20
  645. workExperienceRowsStack.alignment = .leading
  646. workExperienceRowsStack.userInterfaceLayoutDirection = .leftToRight
  647. ProfileLayoutEnforcement.applyForcedLTR(to: workExperienceRowsStack)
  648. let outer = NSStackView(views: [headerRow, workExperienceRowsStack])
  649. outer.orientation = .vertical
  650. outer.spacing = 16
  651. outer.alignment = .leading
  652. outer.translatesAutoresizingMaskIntoConstraints = false
  653. outer.userInterfaceLayoutDirection = .leftToRight
  654. ProfileLayoutEnforcement.applyForcedLTR(to: outer)
  655. outer.pinAllArrangedSubviewWidthsEqualToStackWidth()
  656. return outer
  657. }
  658. private func educationSection() -> NSView {
  659. let title = NSTextField(labelWithString: "Education")
  660. title.font = .systemFont(ofSize: 15, weight: .semibold)
  661. title.textColor = ProfilePagePalette.primaryText
  662. title.translatesAutoresizingMaskIntoConstraints = false
  663. title.setContentHuggingPriority(.defaultLow, for: .horizontal)
  664. ProfileLayoutEnforcement.applyLeftAlignedTextField(title)
  665. let addButton = NSButton(title: "+ Add Another", target: self, action: #selector(didTapAddEducation))
  666. addButton.translatesAutoresizingMaskIntoConstraints = false
  667. addButton.bezelStyle = .rounded
  668. addButton.isBordered = true
  669. addButton.font = .systemFont(ofSize: 12, weight: .medium)
  670. addButton.setContentHuggingPriority(.required, for: .horizontal)
  671. let spacer = NSView()
  672. spacer.translatesAutoresizingMaskIntoConstraints = false
  673. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  674. let headerRow = NSStackView(views: [title, spacer, addButton])
  675. headerRow.orientation = .horizontal
  676. headerRow.alignment = .centerY
  677. headerRow.distribution = .fill
  678. headerRow.spacing = 12
  679. headerRow.userInterfaceLayoutDirection = .leftToRight
  680. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  681. headerRow.translatesAutoresizingMaskIntoConstraints = false
  682. educationRowsStack.translatesAutoresizingMaskIntoConstraints = false
  683. educationRowsStack.orientation = .vertical
  684. educationRowsStack.spacing = 16
  685. educationRowsStack.alignment = .leading
  686. educationRowsStack.userInterfaceLayoutDirection = .leftToRight
  687. ProfileLayoutEnforcement.applyForcedLTR(to: educationRowsStack)
  688. let outer = NSStackView(views: [headerRow, educationRowsStack])
  689. outer.orientation = .vertical
  690. outer.spacing = 16
  691. outer.alignment = .leading
  692. outer.translatesAutoresizingMaskIntoConstraints = false
  693. outer.userInterfaceLayoutDirection = .leftToRight
  694. ProfileLayoutEnforcement.applyForcedLTR(to: outer)
  695. outer.pinAllArrangedSubviewWidthsEqualToStackWidth()
  696. return outer
  697. }
  698. private func appendWorkExperienceEntry() {
  699. let entry = WorkExperienceEntryView()
  700. entry.translatesAutoresizingMaskIntoConstraints = false
  701. if let compact = lastCompactLayout {
  702. entry.applyCompactLayout(compact)
  703. }
  704. entry.onDelete = { [weak self, weak entry] in
  705. guard let self, let entry else { return }
  706. self.removeWorkExperienceEntry(entry)
  707. }
  708. workExperienceEntries.append(entry)
  709. workExperienceRowsStack.addArrangedSubview(entry)
  710. entry.widthAnchor.constraint(equalTo: workExperienceRowsStack.widthAnchor).isActive = true
  711. renumberWorkExperienceEntries()
  712. refreshWorkExperienceDeleteButtons()
  713. }
  714. private func removeWorkExperienceEntry(_ entry: WorkExperienceEntryView) {
  715. guard workExperienceEntries.count > 1 else { return }
  716. workExperienceEntries.removeAll { $0 === entry }
  717. workExperienceRowsStack.removeArrangedSubview(entry)
  718. entry.removeFromSuperview()
  719. renumberWorkExperienceEntries()
  720. refreshWorkExperienceDeleteButtons()
  721. }
  722. private func renumberWorkExperienceEntries() {
  723. for (i, entry) in workExperienceEntries.enumerated() {
  724. entry.setExperienceIndex(i + 1)
  725. }
  726. }
  727. private func refreshWorkExperienceDeleteButtons() {
  728. let hide = workExperienceEntries.count <= 1
  729. for entry in workExperienceEntries {
  730. entry.setDeleteHidden(hide)
  731. }
  732. }
  733. private func appendEducationEntry() {
  734. let entry = EducationEntryView()
  735. entry.translatesAutoresizingMaskIntoConstraints = false
  736. if let compact = lastCompactLayout {
  737. entry.applyCompactLayout(compact)
  738. }
  739. entry.onDelete = { [weak self, weak entry] in
  740. guard let self, let entry else { return }
  741. self.removeEducationEntry(entry)
  742. }
  743. educationEntries.append(entry)
  744. educationRowsStack.addArrangedSubview(entry)
  745. entry.widthAnchor.constraint(equalTo: educationRowsStack.widthAnchor).isActive = true
  746. renumberEducationEntries()
  747. refreshEducationDeleteButtons()
  748. }
  749. private func removeEducationEntry(_ entry: EducationEntryView) {
  750. guard educationEntries.count > 1 else { return }
  751. educationEntries.removeAll { $0 === entry }
  752. educationRowsStack.removeArrangedSubview(entry)
  753. entry.removeFromSuperview()
  754. renumberEducationEntries()
  755. refreshEducationDeleteButtons()
  756. }
  757. private func renumberEducationEntries() {
  758. for (i, entry) in educationEntries.enumerated() {
  759. entry.setEducationIndex(i + 1)
  760. }
  761. }
  762. private func refreshEducationDeleteButtons() {
  763. let hide = educationEntries.count <= 1
  764. for entry in educationEntries {
  765. entry.setDeleteHidden(hide)
  766. }
  767. }
  768. @objc private func didTapAddWorkExperience() {
  769. appendWorkExperienceEntry()
  770. }
  771. @objc private func didTapAddEducation() {
  772. appendEducationEntry()
  773. }
  774. private func saveButtonHost() -> NSView {
  775. saveButton.translatesAutoresizingMaskIntoConstraints = false
  776. let host = NSView()
  777. host.translatesAutoresizingMaskIntoConstraints = false
  778. host.userInterfaceLayoutDirection = .leftToRight
  779. ProfileLayoutEnforcement.applyForcedLTR(to: host)
  780. host.addSubview(saveButton)
  781. NSLayoutConstraint.activate([
  782. saveButton.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  783. saveButton.topAnchor.constraint(equalTo: host.topAnchor),
  784. saveButton.bottomAnchor.constraint(equalTo: host.bottomAnchor),
  785. saveButton.heightAnchor.constraint(equalToConstant: 48),
  786. saveButton.trailingAnchor.constraint(lessThanOrEqualTo: host.trailingAnchor)
  787. ])
  788. return host
  789. }
  790. @objc private func didTapSave() {
  791. // UI shell only; wire persistence when profiles are stored.
  792. }
  793. }
  794. // MARK: - Work experience & education rows
  795. private enum ProfileEntryCardLayout {
  796. /// Horizontal inset of fields inside each work/education entry card (matches main profile form).
  797. static let horizontalInset: CGFloat = 28
  798. }
  799. private final class WorkExperienceEntryView: NSView {
  800. var onDelete: (() -> Void)?
  801. private let subtitleLabel = NSTextField(labelWithString: "Experience 1")
  802. private let deleteButton = NSButton()
  803. private let jobTitleField = NSTextField()
  804. private let companyField = NSTextField()
  805. private let durationField = NSTextField()
  806. private let descriptionField = NSTextField()
  807. private var jobCompanyRow: ProfileDualFieldRow!
  808. override init(frame frameRect: NSRect) {
  809. super.init(frame: frameRect)
  810. configure()
  811. }
  812. required init?(coder: NSCoder) {
  813. fatalError("init(coder:) has not been implemented")
  814. }
  815. func setExperienceIndex(_ index: Int) {
  816. subtitleLabel.stringValue = "Experience \(index)"
  817. }
  818. func setDeleteHidden(_ hidden: Bool) {
  819. deleteButton.isHidden = hidden
  820. }
  821. func applyCompactLayout(_ compact: Bool) {
  822. jobCompanyRow.setCompact(compact)
  823. }
  824. private func configure() {
  825. userInterfaceLayoutDirection = .leftToRight
  826. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  827. wantsLayer = true
  828. layer?.cornerRadius = 14
  829. layer?.borderWidth = 1
  830. layer?.borderColor = ProfilePagePalette.border.cgColor
  831. layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  832. layer?.masksToBounds = false
  833. layer?.shadowColor = NSColor.black.cgColor
  834. layer?.shadowOpacity = 0.05
  835. layer?.shadowRadius = 12
  836. layer?.shadowOffset = CGSize(width: 0, height: 6)
  837. if #available(macOS 11.0, *) {
  838. layer?.cornerCurve = .continuous
  839. }
  840. subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
  841. subtitleLabel.textColor = ProfilePagePalette.secondaryText
  842. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  843. ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
  844. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  845. deleteButton.isBordered = false
  846. deleteButton.bezelStyle = .regularSquare
  847. deleteButton.focusRingType = .none
  848. deleteButton.contentTintColor = ProfilePagePalette.destructive
  849. deleteButton.target = self
  850. deleteButton.action = #selector(didTapDelete)
  851. if #available(macOS 11.0, *) {
  852. deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove experience")
  853. deleteButton.imagePosition = .imageOnly
  854. } else {
  855. deleteButton.title = "Remove"
  856. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  857. }
  858. let headerSpacer = NSView()
  859. headerSpacer.translatesAutoresizingMaskIntoConstraints = false
  860. headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  861. let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
  862. headerRow.orientation = .horizontal
  863. headerRow.alignment = .centerY
  864. headerRow.distribution = .fill
  865. headerRow.spacing = 8
  866. headerRow.userInterfaceLayoutDirection = .leftToRight
  867. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  868. headerRow.translatesAutoresizingMaskIntoConstraints = false
  869. let jobGroup = Self.labeledFieldStack(title: "Job Title *", field: jobTitleField, placeholder: "e.g., Software Engineer")
  870. let companyGroup = Self.labeledFieldStack(title: "Company Name *", field: companyField, placeholder: "e.g., Google")
  871. jobCompanyRow = ProfileDualFieldRow(left: jobGroup, right: companyGroup, spacing: 12)
  872. let durationGroup = Self.labeledFieldStack(title: "Duration *", field: durationField, placeholder: "e.g., Jan 2020 - Present")
  873. let descriptionGroup = Self.multilineLabeledStack(
  874. title: "Description",
  875. field: descriptionField,
  876. placeholder: "Describe your responsibilities and achievements...",
  877. minHeight: 120
  878. )
  879. let inner = NSStackView(views: [headerRow, jobCompanyRow, durationGroup, descriptionGroup])
  880. inner.orientation = .vertical
  881. inner.spacing = 16
  882. inner.alignment = .leading
  883. inner.translatesAutoresizingMaskIntoConstraints = false
  884. inner.edgeInsets = NSEdgeInsets()
  885. inner.userInterfaceLayoutDirection = .leftToRight
  886. ProfileLayoutEnforcement.applyForcedLTR(to: inner)
  887. inner.pinAllArrangedSubviewWidthsEqualToStackWidth()
  888. addSubview(inner)
  889. NSLayoutConstraint.activate([
  890. inner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ProfileEntryCardLayout.horizontalInset),
  891. inner.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ProfileEntryCardLayout.horizontalInset),
  892. inner.topAnchor.constraint(equalTo: topAnchor, constant: 16),
  893. inner.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16)
  894. ])
  895. }
  896. override func layout() {
  897. super.layout()
  898. for field in [jobTitleField, companyField, durationField, descriptionField] {
  899. if let wrap = field.superview, wrap.bounds.width > 2 {
  900. let w = max(1, wrap.bounds.width - 24)
  901. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  902. field.preferredMaxLayoutWidth = w
  903. }
  904. }
  905. }
  906. guard let layer = layer, layer.shadowOpacity > 0 else { return }
  907. let r = layer.cornerRadius
  908. layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  909. }
  910. @objc private func didTapDelete() {
  911. onDelete?()
  912. }
  913. fileprivate static func labeledFieldStack(title: String, field: NSTextField, placeholder: String) -> NSView {
  914. let label = NSTextField(labelWithString: title)
  915. label.font = .systemFont(ofSize: 12, weight: .medium)
  916. label.textColor = ProfilePagePalette.secondaryText
  917. label.translatesAutoresizingMaskIntoConstraints = false
  918. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  919. styleSingleLineField(field, placeholder: placeholder)
  920. let wrap = roundedChrome(around: field, minHeight: 40)
  921. let stack = NSStackView(views: [label, wrap])
  922. stack.orientation = .vertical
  923. stack.spacing = 8
  924. stack.alignment = .leading
  925. stack.translatesAutoresizingMaskIntoConstraints = false
  926. stack.userInterfaceLayoutDirection = .leftToRight
  927. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  928. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  929. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  930. wrap.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  931. NSLayoutConstraint.activate([
  932. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  933. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  934. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  935. ])
  936. return stack
  937. }
  938. private static func multilineLabeledStack(title: String, field: NSTextField, placeholder: String, minHeight: CGFloat) -> NSView {
  939. let label = NSTextField(labelWithString: title)
  940. label.font = .systemFont(ofSize: 12, weight: .medium)
  941. label.textColor = ProfilePagePalette.secondaryText
  942. label.translatesAutoresizingMaskIntoConstraints = false
  943. ProfileLayoutEnforcement.applyLeftAlignedTextField(label)
  944. field.translatesAutoresizingMaskIntoConstraints = false
  945. field.isEditable = true
  946. field.isSelectable = true
  947. field.isBordered = false
  948. field.drawsBackground = false
  949. field.focusRingType = .none
  950. field.font = .systemFont(ofSize: 14, weight: .regular)
  951. field.textColor = ProfilePagePalette.primaryText
  952. field.maximumNumberOfLines = 0
  953. field.cell?.wraps = true
  954. field.cell?.isScrollable = false
  955. field.cell?.usesSingleLineMode = false
  956. field.placeholderAttributedString = NSAttributedString(
  957. string: placeholder,
  958. attributes: [
  959. .foregroundColor: ProfilePagePalette.secondaryText,
  960. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  961. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  962. ]
  963. )
  964. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  965. let wrap = NSView()
  966. wrap.translatesAutoresizingMaskIntoConstraints = false
  967. wrap.wantsLayer = true
  968. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  969. wrap.layer?.cornerRadius = 10
  970. wrap.layer?.borderWidth = 1
  971. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  972. if #available(macOS 11.0, *) {
  973. wrap.layer?.cornerCurve = .continuous
  974. }
  975. wrap.addSubview(field)
  976. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  977. NSLayoutConstraint.activate([
  978. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  979. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  980. field.topAnchor.constraint(equalTo: wrap.topAnchor, constant: 10),
  981. field.bottomAnchor.constraint(equalTo: wrap.bottomAnchor, constant: -10),
  982. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  983. ])
  984. let stack = NSStackView(views: [label, wrap])
  985. stack.orientation = .vertical
  986. stack.spacing = 8
  987. stack.alignment = .leading
  988. stack.translatesAutoresizingMaskIntoConstraints = false
  989. stack.userInterfaceLayoutDirection = .leftToRight
  990. ProfileLayoutEnforcement.applyForcedLTR(to: stack)
  991. stack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  992. wrap.setContentHuggingPriority(.defaultLow, for: .horizontal)
  993. NSLayoutConstraint.activate([
  994. label.leftAnchor.constraint(equalTo: stack.leftAnchor),
  995. label.widthAnchor.constraint(equalTo: stack.widthAnchor),
  996. wrap.widthAnchor.constraint(equalTo: stack.widthAnchor)
  997. ])
  998. return stack
  999. }
  1000. private static func styleSingleLineField(_ field: NSTextField, placeholder: String) {
  1001. field.translatesAutoresizingMaskIntoConstraints = false
  1002. field.isBordered = false
  1003. field.drawsBackground = false
  1004. field.focusRingType = .none
  1005. field.font = .systemFont(ofSize: 14, weight: .regular)
  1006. field.textColor = ProfilePagePalette.primaryText
  1007. field.placeholderAttributedString = NSAttributedString(
  1008. string: placeholder,
  1009. attributes: [
  1010. .foregroundColor: ProfilePagePalette.secondaryText,
  1011. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  1012. .paragraphStyle: ProfileLayoutEnforcement.leftAlignedParagraphStyle()
  1013. ]
  1014. )
  1015. field.cell?.usesSingleLineMode = true
  1016. field.cell?.wraps = false
  1017. field.cell?.isScrollable = true
  1018. ProfileLayoutEnforcement.applyLeftAlignedTextField(field)
  1019. }
  1020. private static func roundedChrome(around field: NSTextField, minHeight: CGFloat) -> NSView {
  1021. let wrap = NSView()
  1022. wrap.translatesAutoresizingMaskIntoConstraints = false
  1023. wrap.wantsLayer = true
  1024. wrap.layer?.backgroundColor = ProfilePagePalette.fieldFill.cgColor
  1025. wrap.layer?.cornerRadius = 10
  1026. wrap.layer?.borderWidth = 1
  1027. wrap.layer?.borderColor = ProfilePagePalette.border.cgColor
  1028. if #available(macOS 11.0, *) {
  1029. wrap.layer?.cornerCurve = .continuous
  1030. }
  1031. wrap.addSubview(field)
  1032. ProfileLayoutEnforcement.applyForcedLTR(to: wrap)
  1033. NSLayoutConstraint.activate([
  1034. field.leftAnchor.constraint(equalTo: wrap.leftAnchor, constant: 12),
  1035. field.rightAnchor.constraint(equalTo: wrap.rightAnchor, constant: -12),
  1036. field.centerYAnchor.constraint(equalTo: wrap.centerYAnchor),
  1037. wrap.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
  1038. ])
  1039. return wrap
  1040. }
  1041. }
  1042. private final class EducationEntryView: NSView {
  1043. var onDelete: (() -> Void)?
  1044. private let subtitleLabel = NSTextField(labelWithString: "Education 1")
  1045. private let deleteButton = NSButton()
  1046. private let degreeField = NSTextField()
  1047. private let institutionField = NSTextField()
  1048. private let yearField = NSTextField()
  1049. private var degreeInstitutionRow: ProfileDualFieldRow!
  1050. override init(frame frameRect: NSRect) {
  1051. super.init(frame: frameRect)
  1052. configure()
  1053. }
  1054. required init?(coder: NSCoder) {
  1055. fatalError("init(coder:) has not been implemented")
  1056. }
  1057. func setEducationIndex(_ index: Int) {
  1058. subtitleLabel.stringValue = "Education \(index)"
  1059. }
  1060. func setDeleteHidden(_ hidden: Bool) {
  1061. deleteButton.isHidden = hidden
  1062. }
  1063. func applyCompactLayout(_ compact: Bool) {
  1064. degreeInstitutionRow.setCompact(compact)
  1065. }
  1066. private func configure() {
  1067. userInterfaceLayoutDirection = .leftToRight
  1068. ProfileLayoutEnforcement.applyForcedLTR(to: self)
  1069. wantsLayer = true
  1070. layer?.cornerRadius = 14
  1071. layer?.borderWidth = 1
  1072. layer?.borderColor = ProfilePagePalette.border.cgColor
  1073. layer?.backgroundColor = ProfilePagePalette.cardBackground.cgColor
  1074. layer?.masksToBounds = false
  1075. layer?.shadowColor = NSColor.black.cgColor
  1076. layer?.shadowOpacity = 0.04
  1077. layer?.shadowRadius = 10
  1078. layer?.shadowOffset = CGSize(width: 0, height: 5)
  1079. if #available(macOS 11.0, *) {
  1080. layer?.cornerCurve = .continuous
  1081. }
  1082. subtitleLabel.font = .systemFont(ofSize: 12, weight: .medium)
  1083. subtitleLabel.textColor = ProfilePagePalette.secondaryText
  1084. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  1085. ProfileLayoutEnforcement.applyLeftAlignedTextField(subtitleLabel)
  1086. deleteButton.translatesAutoresizingMaskIntoConstraints = false
  1087. deleteButton.isBordered = false
  1088. deleteButton.bezelStyle = .regularSquare
  1089. deleteButton.focusRingType = .none
  1090. deleteButton.contentTintColor = ProfilePagePalette.destructive
  1091. deleteButton.target = self
  1092. deleteButton.action = #selector(didTapDelete)
  1093. if #available(macOS 11.0, *) {
  1094. deleteButton.image = NSImage(systemSymbolName: "trash", accessibilityDescription: "Remove education")
  1095. deleteButton.imagePosition = .imageOnly
  1096. } else {
  1097. deleteButton.title = "Remove"
  1098. deleteButton.font = .systemFont(ofSize: 12, weight: .medium)
  1099. }
  1100. let headerSpacer = NSView()
  1101. headerSpacer.translatesAutoresizingMaskIntoConstraints = false
  1102. headerSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1103. let headerRow = NSStackView(views: [subtitleLabel, headerSpacer, deleteButton])
  1104. headerRow.orientation = .horizontal
  1105. headerRow.alignment = .centerY
  1106. headerRow.distribution = .fill
  1107. headerRow.spacing = 8
  1108. headerRow.userInterfaceLayoutDirection = .leftToRight
  1109. ProfileLayoutEnforcement.applyForcedLTR(to: headerRow)
  1110. headerRow.translatesAutoresizingMaskIntoConstraints = false
  1111. let degreeGroup = WorkExperienceEntryView.labeledFieldStack(
  1112. title: "Degree / program *",
  1113. field: degreeField,
  1114. placeholder: "e.g., BSc Computer Science"
  1115. )
  1116. let institutionGroup = WorkExperienceEntryView.labeledFieldStack(
  1117. title: "Institution *",
  1118. field: institutionField,
  1119. placeholder: "e.g., MIT"
  1120. )
  1121. degreeInstitutionRow = ProfileDualFieldRow(left: degreeGroup, right: institutionGroup, spacing: 12)
  1122. let yearGroup = WorkExperienceEntryView.labeledFieldStack(
  1123. title: "Year *",
  1124. field: yearField,
  1125. placeholder: "e.g., 2020"
  1126. )
  1127. let inner = NSStackView(views: [headerRow, degreeInstitutionRow, yearGroup])
  1128. inner.orientation = .vertical
  1129. inner.spacing = 14
  1130. inner.alignment = .leading
  1131. inner.translatesAutoresizingMaskIntoConstraints = false
  1132. inner.edgeInsets = NSEdgeInsets()
  1133. inner.userInterfaceLayoutDirection = .leftToRight
  1134. ProfileLayoutEnforcement.applyForcedLTR(to: inner)
  1135. inner.pinAllArrangedSubviewWidthsEqualToStackWidth()
  1136. addSubview(inner)
  1137. NSLayoutConstraint.activate([
  1138. inner.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ProfileEntryCardLayout.horizontalInset),
  1139. inner.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ProfileEntryCardLayout.horizontalInset),
  1140. inner.topAnchor.constraint(equalTo: topAnchor, constant: 14),
  1141. inner.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14)
  1142. ])
  1143. }
  1144. override func layout() {
  1145. super.layout()
  1146. for field in [degreeField, institutionField, yearField] {
  1147. if let wrap = field.superview, wrap.bounds.width > 2 {
  1148. let w = max(1, wrap.bounds.width - 24)
  1149. if abs(field.preferredMaxLayoutWidth - w) > 0.5 {
  1150. field.preferredMaxLayoutWidth = w
  1151. }
  1152. }
  1153. }
  1154. guard let layer = layer, layer.shadowOpacity > 0 else { return }
  1155. let r = layer.cornerRadius
  1156. layer.shadowPath = CGPath(roundedRect: bounds, cornerWidth: r, cornerHeight: r, transform: nil)
  1157. }
  1158. @objc private func didTapDelete() {
  1159. onDelete?()
  1160. }
  1161. }
  1162. // MARK: - Primary CTA
  1163. private final class ProfilePrimaryButton: NSButton {
  1164. private var trackingArea: NSTrackingArea?
  1165. private var didPushCursor = false
  1166. override init(frame frameRect: NSRect) {
  1167. super.init(frame: frameRect)
  1168. commonInit()
  1169. }
  1170. required init?(coder: NSCoder) {
  1171. super.init(coder: coder)
  1172. commonInit()
  1173. }
  1174. convenience init(title: String, target: AnyObject?, action: Selector?) {
  1175. self.init(frame: .zero)
  1176. self.title = title
  1177. self.target = target
  1178. self.action = action
  1179. }
  1180. private func commonInit() {
  1181. bezelStyle = .rounded
  1182. isBordered = false
  1183. font = .systemFont(ofSize: 15, weight: .semibold)
  1184. contentTintColor = .white
  1185. wantsLayer = true
  1186. layer?.cornerRadius = 12
  1187. if #available(macOS 11.0, *) {
  1188. layer?.cornerCurve = .continuous
  1189. }
  1190. layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
  1191. }
  1192. override func updateTrackingAreas() {
  1193. super.updateTrackingAreas()
  1194. if let trackingArea { removeTrackingArea(trackingArea) }
  1195. let area = NSTrackingArea(
  1196. rect: bounds,
  1197. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  1198. owner: self,
  1199. userInfo: nil
  1200. )
  1201. addTrackingArea(area)
  1202. trackingArea = area
  1203. }
  1204. override func mouseEntered(with event: NSEvent) {
  1205. super.mouseEntered(with: event)
  1206. layer?.backgroundColor = ProfilePagePalette.brandBlueHover.cgColor
  1207. if !didPushCursor {
  1208. NSCursor.pointingHand.push()
  1209. didPushCursor = true
  1210. }
  1211. }
  1212. override func mouseExited(with event: NSEvent) {
  1213. super.mouseExited(with: event)
  1214. layer?.backgroundColor = ProfilePagePalette.brandBlue.cgColor
  1215. if didPushCursor {
  1216. NSCursor.pop()
  1217. didPushCursor = false
  1218. }
  1219. }
  1220. override func viewWillMove(toWindow newWindow: NSWindow?) {
  1221. super.viewWillMove(toWindow: newWindow)
  1222. if newWindow == nil, didPushCursor {
  1223. NSCursor.pop()
  1224. didPushCursor = false
  1225. }
  1226. }
  1227. }