Açıklama Yok

MyProfilePageView.swift 57KB

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