Нема описа

ProfilesListPageView.swift 12KB

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