Açıklama Yok

DashboardView.swift 29KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. //
  2. // DashboardView.swift
  3. // App for Indeed
  4. //
  5. import Cocoa
  6. import QuartzCore
  7. final class DashboardView: NSView, NSTextFieldDelegate {
  8. /// Indeed.com-inspired neutrals and brand blue (white surfaces, `#2557a7` accent, `#2d2d2d` / `#767676` text, `#d4d2d0` borders).
  9. private enum Theme {
  10. static let brandBlue = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  11. static let pageBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  12. static let chromeBackground = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
  13. static let sidebarBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  14. static let mainHostBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  15. /// Subtitle on the welcome hero: readable blue-gray aligned with brand.
  16. static let welcomeSubtitleText = NSColor(srgbRed: 52 / 255, green: 92 / 255, blue: 142 / 255, alpha: 1)
  17. static let selectionFill = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.12)
  18. static let cardBackground = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  19. static let toggleBackground = NSColor(srgbRed: 232 / 255, green: 232 / 255, blue: 232 / 255, alpha: 1)
  20. static let primaryText = NSColor(srgbRed: 45 / 255, green: 45 / 255, blue: 45 / 255, alpha: 1)
  21. static let secondaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  22. static let tertiaryText = NSColor(srgbRed: 118 / 255, green: 118 / 255, blue: 118 / 255, alpha: 1)
  23. static let border = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
  24. /// Job search bar outer stroke (charcoal).
  25. static let searchBarBorder = NSColor(srgbRed: 58 / 255, green: 58 / 255, blue: 58 / 255, alpha: 1)
  26. static let proCardFill = NSColor(srgbRed: 239 / 255, green: 244 / 255, blue: 252 / 255, alpha: 1)
  27. static let proCardBorder = NSColor(srgbRed: 212 / 255, green: 210 / 255, blue: 208 / 255, alpha: 1)
  28. static let proAccent = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  29. static let proCTABackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
  30. static let proCTAText = NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
  31. /// Slightly lighter blue for the top of the search-bar “Find jobs” pill (reads less flat than a solid fill).
  32. static let findJobsCTAHighlight = NSColor(srgbRed: 54 / 255, green: 110 / 255, blue: 198 / 255, alpha: 1)
  33. }
  34. private let contentStack = NSStackView()
  35. private let documentContainer = NSView()
  36. private let chromeContainer = NSView()
  37. private let sidebar = NSStackView()
  38. private let mainHost = NSView()
  39. private let mainOverlay = NSStackView()
  40. private let greetingLabel = NSTextField(labelWithString: "")
  41. private let subtitleLabel = NSTextField(labelWithString: "")
  42. private let searchBarShadowHost = NSView()
  43. private let searchCard = NSView()
  44. private let jobSearchIcon = NSImageView()
  45. private let jobKeywordsField = NSTextField()
  46. private let findJobsButton = NSButton()
  47. private let findJobsCTAHost = NSView()
  48. private let findJobsCTAChrome = NSView()
  49. private var findJobsCTAGradientLayer: CAGradientLayer?
  50. private let scrollView = NSScrollView()
  51. private var currentSidebarItems: [SidebarItem] = []
  52. private var selectedSidebarIndex: Int = 0
  53. override init(frame frameRect: NSRect) {
  54. super.init(frame: frameRect)
  55. setupLayout()
  56. }
  57. required init?(coder: NSCoder) {
  58. super.init(coder: coder)
  59. setupLayout()
  60. }
  61. override func layout() {
  62. super.layout()
  63. updateDocumentLayout()
  64. updateSearchBarShadowPath()
  65. findJobsCTAGradientLayer?.frame = findJobsCTAChrome.bounds
  66. updateFindJobsCTAShadowPath()
  67. }
  68. func render(_ data: DashboardData) {
  69. greetingLabel.stringValue = "Welcome"
  70. subtitleLabel.stringValue = data.subtitle
  71. currentSidebarItems = data.sidebarItems
  72. if selectedSidebarIndex >= currentSidebarItems.count {
  73. selectedSidebarIndex = max(0, currentSidebarItems.count - 1)
  74. }
  75. configureSidebar()
  76. updateDocumentLayout()
  77. }
  78. private func setupLayout() {
  79. wantsLayer = true
  80. layer?.backgroundColor = Theme.pageBackground.cgColor
  81. scrollView.translatesAutoresizingMaskIntoConstraints = false
  82. scrollView.hasVerticalScroller = true
  83. scrollView.drawsBackground = false
  84. addSubview(scrollView)
  85. contentStack.orientation = .horizontal
  86. contentStack.spacing = 20
  87. contentStack.distribution = .fill
  88. contentStack.translatesAutoresizingMaskIntoConstraints = false
  89. contentStack.alignment = .height
  90. contentStack.edgeInsets = NSEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
  91. documentContainer.translatesAutoresizingMaskIntoConstraints = true
  92. documentContainer.autoresizingMask = [.width]
  93. documentContainer.frame = NSRect(x: 0, y: 0, width: 1040, height: 900)
  94. chromeContainer.translatesAutoresizingMaskIntoConstraints = false
  95. chromeContainer.wantsLayer = true
  96. chromeContainer.layer?.backgroundColor = Theme.chromeBackground.cgColor
  97. chromeContainer.layer?.cornerRadius = 0
  98. documentContainer.addSubview(chromeContainer)
  99. chromeContainer.addSubview(contentStack)
  100. scrollView.documentView = documentContainer
  101. sidebar.orientation = .vertical
  102. sidebar.spacing = 10
  103. sidebar.distribution = .fill
  104. sidebar.alignment = .leading
  105. sidebar.wantsLayer = true
  106. sidebar.layer?.backgroundColor = Theme.sidebarBackground.cgColor
  107. sidebar.layer?.cornerRadius = 16
  108. sidebar.edgeInsets = NSEdgeInsets(top: 18, left: 14, bottom: 18, right: 14)
  109. sidebar.translatesAutoresizingMaskIntoConstraints = false
  110. mainHost.translatesAutoresizingMaskIntoConstraints = false
  111. mainHost.wantsLayer = true
  112. mainHost.layer?.backgroundColor = Theme.mainHostBackground.cgColor
  113. mainHost.layer?.cornerRadius = 16
  114. mainHost.layer?.masksToBounds = true
  115. sidebar.setContentHuggingPriority(.required, for: .horizontal)
  116. mainHost.setContentHuggingPriority(.defaultLow, for: .horizontal)
  117. mainHost.addSubview(mainOverlay)
  118. mainOverlay.orientation = .vertical
  119. mainOverlay.spacing = 0
  120. mainOverlay.alignment = .centerX
  121. mainOverlay.distribution = .fill
  122. mainOverlay.translatesAutoresizingMaskIntoConstraints = false
  123. mainOverlay.setContentHuggingPriority(.defaultLow, for: .vertical)
  124. greetingLabel.font = .systemFont(ofSize: 32, weight: .bold)
  125. greetingLabel.textColor = Theme.brandBlue
  126. greetingLabel.alignment = .center
  127. greetingLabel.maximumNumberOfLines = 1
  128. subtitleLabel.font = .systemFont(ofSize: 15, weight: .regular)
  129. subtitleLabel.textColor = Theme.welcomeSubtitleText
  130. subtitleLabel.alignment = .center
  131. subtitleLabel.maximumNumberOfLines = 2
  132. let topInset = NSView()
  133. topInset.translatesAutoresizingMaskIntoConstraints = false
  134. topInset.heightAnchor.constraint(equalToConstant: 32).isActive = true
  135. configureSearchBar()
  136. let titleBlock = NSStackView(views: [greetingLabel, subtitleLabel])
  137. titleBlock.orientation = .vertical
  138. titleBlock.spacing = 10
  139. titleBlock.alignment = .centerX
  140. let midSpacer = NSView()
  141. midSpacer.translatesAutoresizingMaskIntoConstraints = false
  142. midSpacer.heightAnchor.constraint(equalToConstant: 20).isActive = true
  143. let overlayBottomSpacer = NSView()
  144. overlayBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
  145. overlayBottomSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  146. overlayBottomSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  147. mainOverlay.addArrangedSubview(topInset)
  148. mainOverlay.addArrangedSubview(titleBlock)
  149. mainOverlay.addArrangedSubview(midSpacer)
  150. mainOverlay.addArrangedSubview(searchBarShadowHost)
  151. mainOverlay.addArrangedSubview(overlayBottomSpacer)
  152. contentStack.addArrangedSubview(sidebar)
  153. contentStack.addArrangedSubview(mainHost)
  154. NSLayoutConstraint.activate([
  155. scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
  156. scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
  157. scrollView.topAnchor.constraint(equalTo: topAnchor),
  158. scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
  159. chromeContainer.leadingAnchor.constraint(equalTo: documentContainer.leadingAnchor),
  160. chromeContainer.trailingAnchor.constraint(equalTo: documentContainer.trailingAnchor),
  161. chromeContainer.topAnchor.constraint(equalTo: documentContainer.topAnchor),
  162. chromeContainer.bottomAnchor.constraint(equalTo: documentContainer.bottomAnchor),
  163. contentStack.leadingAnchor.constraint(equalTo: chromeContainer.leadingAnchor),
  164. contentStack.trailingAnchor.constraint(equalTo: chromeContainer.trailingAnchor),
  165. contentStack.topAnchor.constraint(equalTo: chromeContainer.topAnchor),
  166. contentStack.bottomAnchor.constraint(equalTo: chromeContainer.bottomAnchor),
  167. sidebar.widthAnchor.constraint(equalToConstant: 218),
  168. mainHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 720),
  169. mainOverlay.leadingAnchor.constraint(equalTo: mainHost.leadingAnchor),
  170. mainOverlay.trailingAnchor.constraint(equalTo: mainHost.trailingAnchor),
  171. mainOverlay.topAnchor.constraint(equalTo: mainHost.topAnchor),
  172. mainOverlay.bottomAnchor.constraint(equalTo: mainHost.bottomAnchor, constant: -24),
  173. searchBarShadowHost.widthAnchor.constraint(equalTo: mainOverlay.widthAnchor, multiplier: 0.92),
  174. greetingLabel.leadingAnchor.constraint(equalTo: mainOverlay.leadingAnchor, constant: 24),
  175. greetingLabel.trailingAnchor.constraint(equalTo: mainOverlay.trailingAnchor, constant: -24),
  176. subtitleLabel.leadingAnchor.constraint(equalTo: greetingLabel.leadingAnchor),
  177. subtitleLabel.trailingAnchor.constraint(equalTo: greetingLabel.trailingAnchor)
  178. ])
  179. }
  180. private func configureSearchBar() {
  181. let pillCorner: CGFloat = 27
  182. let barHeight: CGFloat = 54
  183. searchBarShadowHost.translatesAutoresizingMaskIntoConstraints = false
  184. searchBarShadowHost.wantsLayer = true
  185. searchBarShadowHost.layer?.masksToBounds = false
  186. searchBarShadowHost.layer?.shadowColor = NSColor.black.withAlphaComponent(0.18).cgColor
  187. searchBarShadowHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
  188. searchBarShadowHost.layer?.shadowRadius = 10
  189. searchBarShadowHost.layer?.shadowOpacity = 1
  190. searchBarShadowHost.setContentHuggingPriority(.defaultHigh, for: .vertical)
  191. searchCard.translatesAutoresizingMaskIntoConstraints = false
  192. searchCard.wantsLayer = true
  193. searchCard.layer?.backgroundColor = Theme.cardBackground.cgColor
  194. searchCard.layer?.cornerRadius = pillCorner
  195. searchCard.layer?.borderWidth = 1
  196. searchCard.layer?.borderColor = Theme.searchBarBorder.cgColor
  197. searchCard.layer?.masksToBounds = true
  198. searchBarShadowHost.addSubview(searchCard)
  199. func configureField(_ field: NSTextField, placeholder: String) {
  200. field.translatesAutoresizingMaskIntoConstraints = false
  201. field.isBordered = false
  202. field.drawsBackground = false
  203. field.focusRingType = .none
  204. field.font = .systemFont(ofSize: 14, weight: .regular)
  205. field.textColor = Theme.primaryText
  206. field.delegate = self
  207. field.placeholderAttributedString = NSAttributedString(
  208. string: placeholder,
  209. attributes: [
  210. .foregroundColor: Theme.secondaryText,
  211. .font: NSFont.systemFont(ofSize: 14, weight: .regular)
  212. ]
  213. )
  214. field.cell?.usesSingleLineMode = true
  215. field.cell?.wraps = false
  216. field.cell?.isScrollable = true
  217. field.target = self
  218. field.action = #selector(didSubmitSearch)
  219. }
  220. jobSearchIcon.translatesAutoresizingMaskIntoConstraints = false
  221. jobSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 15, weight: .medium)
  222. jobSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Job search")
  223. jobSearchIcon.contentTintColor = Theme.primaryText
  224. configureField(jobKeywordsField, placeholder: "Job title, keywords, or company")
  225. let ctaHeight: CGFloat = 42
  226. let ctaCorner = ctaHeight / 2
  227. findJobsCTAHost.translatesAutoresizingMaskIntoConstraints = false
  228. findJobsCTAHost.wantsLayer = true
  229. findJobsCTAHost.layer?.masksToBounds = false
  230. findJobsCTAHost.layer?.shadowColor = NSColor.black.cgColor
  231. findJobsCTAHost.layer?.shadowOpacity = 0.16
  232. findJobsCTAHost.layer?.shadowOffset = CGSize(width: 0, height: 2)
  233. findJobsCTAHost.layer?.shadowRadius = 6
  234. findJobsCTAChrome.translatesAutoresizingMaskIntoConstraints = false
  235. findJobsCTAChrome.wantsLayer = true
  236. findJobsCTAChrome.layer?.masksToBounds = true
  237. findJobsCTAChrome.layer?.cornerRadius = ctaCorner
  238. if #available(macOS 11.0, *) {
  239. findJobsCTAChrome.layer?.cornerCurve = .continuous
  240. }
  241. let gradient = CAGradientLayer()
  242. gradient.colors = [Theme.findJobsCTAHighlight.cgColor, Theme.brandBlue.cgColor]
  243. gradient.startPoint = CGPoint(x: 0.5, y: 1)
  244. gradient.endPoint = CGPoint(x: 0.5, y: 0)
  245. findJobsCTAChrome.layer?.addSublayer(gradient)
  246. findJobsCTAGradientLayer = gradient
  247. findJobsButton.translatesAutoresizingMaskIntoConstraints = false
  248. findJobsButton.title = ""
  249. findJobsButton.attributedTitle = NSAttributedString(
  250. string: "Find jobs",
  251. attributes: [
  252. .font: NSFont.systemFont(ofSize: 14, weight: .semibold),
  253. .foregroundColor: Theme.proCTAText,
  254. .kern: 0.35
  255. ]
  256. )
  257. findJobsButton.isBordered = false
  258. findJobsButton.bezelStyle = .rounded
  259. findJobsButton.wantsLayer = true
  260. findJobsButton.layer?.backgroundColor = NSColor.clear.cgColor
  261. findJobsButton.focusRingType = .none
  262. findJobsButton.target = self
  263. findJobsButton.action = #selector(didSubmitSearch)
  264. findJobsButton.setContentHuggingPriority(.required, for: .horizontal)
  265. findJobsButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  266. findJobsCTAHost.addSubview(findJobsCTAChrome)
  267. findJobsCTAHost.addSubview(findJobsButton)
  268. NSLayoutConstraint.activate([
  269. findJobsCTAChrome.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor),
  270. findJobsCTAChrome.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor),
  271. findJobsCTAChrome.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor),
  272. findJobsCTAChrome.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor),
  273. findJobsButton.leadingAnchor.constraint(equalTo: findJobsCTAHost.leadingAnchor, constant: 14),
  274. findJobsButton.trailingAnchor.constraint(equalTo: findJobsCTAHost.trailingAnchor, constant: -14),
  275. findJobsButton.topAnchor.constraint(equalTo: findJobsCTAHost.topAnchor),
  276. findJobsButton.bottomAnchor.constraint(equalTo: findJobsCTAHost.bottomAnchor)
  277. ])
  278. let keywordsStack = NSStackView(views: [jobSearchIcon, jobKeywordsField])
  279. keywordsStack.orientation = .horizontal
  280. keywordsStack.spacing = 10
  281. keywordsStack.alignment = .centerY
  282. keywordsStack.translatesAutoresizingMaskIntoConstraints = false
  283. keywordsStack.edgeInsets = NSEdgeInsets(top: 0, left: 18, bottom: 0, right: 10)
  284. keywordsStack.setContentHuggingPriority(.defaultLow, for: .horizontal)
  285. let row = NSStackView(views: [keywordsStack, findJobsCTAHost])
  286. row.orientation = .horizontal
  287. row.spacing = 0
  288. row.alignment = .centerY
  289. row.distribution = .fill
  290. row.translatesAutoresizingMaskIntoConstraints = false
  291. row.edgeInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 7)
  292. searchCard.addSubview(row)
  293. NSLayoutConstraint.activate([
  294. searchCard.leadingAnchor.constraint(equalTo: searchBarShadowHost.leadingAnchor),
  295. searchCard.trailingAnchor.constraint(equalTo: searchBarShadowHost.trailingAnchor),
  296. searchCard.topAnchor.constraint(equalTo: searchBarShadowHost.topAnchor),
  297. searchCard.bottomAnchor.constraint(equalTo: searchBarShadowHost.bottomAnchor),
  298. searchBarShadowHost.heightAnchor.constraint(equalToConstant: barHeight),
  299. row.leadingAnchor.constraint(equalTo: searchCard.leadingAnchor),
  300. row.trailingAnchor.constraint(equalTo: searchCard.trailingAnchor),
  301. row.topAnchor.constraint(equalTo: searchCard.topAnchor),
  302. row.bottomAnchor.constraint(equalTo: searchCard.bottomAnchor),
  303. jobSearchIcon.widthAnchor.constraint(equalToConstant: 18),
  304. jobSearchIcon.heightAnchor.constraint(equalToConstant: 18),
  305. findJobsCTAHost.heightAnchor.constraint(equalToConstant: ctaHeight),
  306. findJobsCTAHost.widthAnchor.constraint(greaterThanOrEqualToConstant: 112)
  307. ])
  308. }
  309. private func updateFindJobsCTAShadowPath() {
  310. guard findJobsCTAHost.bounds.width > 0, findJobsCTAHost.bounds.height > 0 else { return }
  311. let r = findJobsCTAHost.bounds
  312. let radius = min(r.height / 2, r.width / 2)
  313. findJobsCTAHost.layer?.shadowPath = CGPath(
  314. roundedRect: r,
  315. cornerWidth: radius,
  316. cornerHeight: radius,
  317. transform: nil
  318. )
  319. }
  320. private func updateSearchBarShadowPath() {
  321. guard searchBarShadowHost.bounds.width > 0, searchBarShadowHost.bounds.height > 0 else { return }
  322. let r = searchBarShadowHost.bounds
  323. let radius = min(r.height / 2, 27)
  324. searchBarShadowHost.layer?.shadowPath = CGPath(
  325. roundedRect: r,
  326. cornerWidth: radius,
  327. cornerHeight: radius,
  328. transform: nil
  329. )
  330. }
  331. @objc private func didSubmitSearch() {
  332. // Hook up search submission here when wiring up real data.
  333. }
  334. func controlTextDidBeginEditing(_ obj: Notification) {
  335. applySearchFieldInsertionPoint(obj.object)
  336. }
  337. func controlTextDidChange(_ obj: Notification) {
  338. applySearchFieldInsertionPoint(obj.object)
  339. }
  340. private func applySearchFieldInsertionPoint(_ object: Any?) {
  341. guard let field = object as? NSTextField,
  342. field === jobKeywordsField,
  343. let textView = field.window?.fieldEditor(true, for: field) as? NSTextView else { return }
  344. textView.insertionPointColor = Theme.primaryText
  345. }
  346. private func configureSidebar() {
  347. let items = currentSidebarItems
  348. sidebar.arrangedSubviews.forEach {
  349. sidebar.removeArrangedSubview($0)
  350. $0.removeFromSuperview()
  351. }
  352. let brand = NSTextField(labelWithString: "Indeed AI\nJob Finder")
  353. brand.font = .systemFont(ofSize: 18, weight: .bold)
  354. brand.textColor = Theme.brandBlue
  355. brand.alignment = .left
  356. sidebar.addArrangedSubview(brand)
  357. let titleToMenuSpacer = NSView()
  358. titleToMenuSpacer.translatesAutoresizingMaskIntoConstraints = false
  359. titleToMenuSpacer.heightAnchor.constraint(equalToConstant: 24).isActive = true
  360. sidebar.addArrangedSubview(titleToMenuSpacer)
  361. items.enumerated().forEach { index, item in
  362. let isSelected = index == selectedSidebarIndex
  363. let rowHost = SidebarNavRowView { [weak self] in
  364. self?.selectSidebarItem(at: index)
  365. }
  366. rowHost.translatesAutoresizingMaskIntoConstraints = false
  367. rowHost.wantsLayer = true
  368. rowHost.layer?.cornerRadius = 8
  369. if isSelected {
  370. rowHost.layer?.backgroundColor = Theme.selectionFill.cgColor
  371. }
  372. rowHost.setAccessibilityLabel(item.title)
  373. rowHost.setAccessibilityRole(.button)
  374. rowHost.setAccessibilitySelected(isSelected)
  375. let row = NSStackView()
  376. row.orientation = .horizontal
  377. row.spacing = 8
  378. row.alignment = .centerY
  379. row.translatesAutoresizingMaskIntoConstraints = false
  380. let icon = NSImageView()
  381. icon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
  382. icon.image = NSImage(systemSymbolName: item.systemImage, accessibilityDescription: item.title)
  383. icon.contentTintColor = isSelected ? Theme.brandBlue : Theme.secondaryText
  384. let text = NSTextField(labelWithString: item.title)
  385. text.font = .systemFont(ofSize: 14, weight: .medium)
  386. text.textColor = isSelected ? Theme.brandBlue : Theme.secondaryText
  387. text.refusesFirstResponder = true
  388. row.addArrangedSubview(icon)
  389. row.addArrangedSubview(text)
  390. if let badge = item.badge {
  391. let badgeField = NSTextField(labelWithString: badge)
  392. badgeField.font = .systemFont(ofSize: 11, weight: .semibold)
  393. badgeField.textColor = Theme.primaryText
  394. badgeField.wantsLayer = true
  395. badgeField.layer?.backgroundColor = Theme.toggleBackground.cgColor
  396. badgeField.layer?.cornerRadius = 8
  397. badgeField.alignment = .center
  398. badgeField.maximumNumberOfLines = 1
  399. badgeField.lineBreakMode = .byClipping
  400. badgeField.refusesFirstResponder = true
  401. badgeField.translatesAutoresizingMaskIntoConstraints = false
  402. badgeField.widthAnchor.constraint(equalToConstant: 42).isActive = true
  403. row.addArrangedSubview(NSView())
  404. row.addArrangedSubview(badgeField)
  405. }
  406. rowHost.addSubview(row)
  407. NSLayoutConstraint.activate([
  408. row.leadingAnchor.constraint(equalTo: rowHost.leadingAnchor, constant: 10),
  409. row.trailingAnchor.constraint(equalTo: rowHost.trailingAnchor, constant: -10),
  410. row.topAnchor.constraint(equalTo: rowHost.topAnchor, constant: 8),
  411. row.bottomAnchor.constraint(equalTo: rowHost.bottomAnchor, constant: -8)
  412. ])
  413. rowHost.setContentHuggingPriority(.defaultLow, for: .horizontal)
  414. sidebar.addArrangedSubview(rowHost)
  415. let sidebarHorizontalInset = sidebar.edgeInsets.left + sidebar.edgeInsets.right
  416. rowHost.widthAnchor.constraint(equalTo: sidebar.widthAnchor, constant: -sidebarHorizontalInset).isActive = true
  417. }
  418. let sidebarBottomSpacer = NSView()
  419. sidebarBottomSpacer.translatesAutoresizingMaskIntoConstraints = false
  420. sidebarBottomSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  421. sidebarBottomSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  422. sidebar.addArrangedSubview(sidebarBottomSpacer)
  423. let upgradeCard = NSView()
  424. upgradeCard.translatesAutoresizingMaskIntoConstraints = false
  425. upgradeCard.wantsLayer = true
  426. upgradeCard.layer?.backgroundColor = Theme.proCardFill.cgColor
  427. upgradeCard.layer?.cornerRadius = 14
  428. upgradeCard.layer?.borderWidth = 1
  429. upgradeCard.layer?.borderColor = Theme.proCardBorder.cgColor
  430. upgradeCard.layer?.masksToBounds = true
  431. let accentBar = NSView()
  432. accentBar.translatesAutoresizingMaskIntoConstraints = false
  433. accentBar.wantsLayer = true
  434. accentBar.layer?.backgroundColor = Theme.proAccent.cgColor
  435. let inner = NSStackView()
  436. inner.translatesAutoresizingMaskIntoConstraints = false
  437. inner.orientation = .vertical
  438. inner.spacing = 10
  439. inner.alignment = .centerX
  440. let proIcon = NSImageView()
  441. proIcon.translatesAutoresizingMaskIntoConstraints = false
  442. proIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  443. proIcon.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
  444. proIcon.contentTintColor = Theme.proAccent
  445. let proEyebrow = NSTextField(labelWithString: "Premium")
  446. proEyebrow.font = .systemFont(ofSize: 11, weight: .heavy)
  447. proEyebrow.textColor = Theme.proAccent
  448. proEyebrow.alignment = .center
  449. let eyebrowRow = NSStackView(views: [proIcon, proEyebrow])
  450. eyebrowRow.orientation = .horizontal
  451. eyebrowRow.spacing = 6
  452. eyebrowRow.alignment = .centerY
  453. let headline = NSTextField(labelWithString: "Upgrade to Pro")
  454. headline.font = .systemFont(ofSize: 16, weight: .bold)
  455. headline.textColor = Theme.primaryText
  456. headline.alignment = .center
  457. let upgradeDescription = NSTextField(wrappingLabelWithString: "Unlimited AI matches, smart alerts, and interview prep—all in one place.")
  458. upgradeDescription.font = .systemFont(ofSize: 12, weight: .regular)
  459. upgradeDescription.textColor = Theme.secondaryText
  460. upgradeDescription.alignment = .center
  461. // Sidebar content width is 190pt (218 − edge insets); card must stay within that band.
  462. let cardWidth: CGFloat = 186
  463. let innerContentWidth = cardWidth - 28
  464. upgradeDescription.preferredMaxLayoutWidth = innerContentWidth
  465. let upgradeButton = NSButton(title: "Upgrade to Pro", target: self, action: #selector(didTapUpgradeToPro))
  466. upgradeButton.isBordered = false
  467. upgradeButton.bezelStyle = .rounded
  468. upgradeButton.font = .systemFont(ofSize: 13, weight: .bold)
  469. upgradeButton.contentTintColor = Theme.proCTAText
  470. upgradeButton.alignment = .center
  471. upgradeButton.wantsLayer = true
  472. upgradeButton.layer?.backgroundColor = Theme.proCTABackground.cgColor
  473. upgradeButton.layer?.cornerRadius = 20
  474. upgradeButton.translatesAutoresizingMaskIntoConstraints = false
  475. upgradeButton.heightAnchor.constraint(equalToConstant: 40).isActive = true
  476. inner.addArrangedSubview(eyebrowRow)
  477. inner.addArrangedSubview(headline)
  478. inner.addArrangedSubview(upgradeDescription)
  479. inner.addArrangedSubview(upgradeButton)
  480. upgradeCard.addSubview(accentBar)
  481. upgradeCard.addSubview(inner)
  482. NSLayoutConstraint.activate([
  483. upgradeCard.widthAnchor.constraint(equalToConstant: cardWidth),
  484. accentBar.topAnchor.constraint(equalTo: upgradeCard.topAnchor),
  485. accentBar.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor),
  486. accentBar.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor),
  487. accentBar.heightAnchor.constraint(equalToConstant: 2),
  488. inner.leadingAnchor.constraint(equalTo: upgradeCard.leadingAnchor, constant: 14),
  489. inner.trailingAnchor.constraint(equalTo: upgradeCard.trailingAnchor, constant: -14),
  490. inner.topAnchor.constraint(equalTo: accentBar.bottomAnchor, constant: 12),
  491. inner.bottomAnchor.constraint(equalTo: upgradeCard.bottomAnchor, constant: -14),
  492. upgradeButton.widthAnchor.constraint(equalTo: inner.widthAnchor)
  493. ])
  494. sidebar.addArrangedSubview(upgradeCard)
  495. }
  496. @objc private func didTapUpgradeToPro() {
  497. guard let url = URL(string: "https://www.indeed.com") else { return }
  498. NSWorkspace.shared.open(url)
  499. }
  500. private func selectSidebarItem(at index: Int) {
  501. guard index >= 0, index < currentSidebarItems.count, index != selectedSidebarIndex else { return }
  502. selectedSidebarIndex = index
  503. configureSidebar()
  504. }
  505. private func updateDocumentLayout() {
  506. documentContainer.layoutSubtreeIfNeeded()
  507. let fittingHeight = max(chromeContainer.fittingSize.height, bounds.height)
  508. documentContainer.frame = NSRect(x: 0, y: 0, width: bounds.width, height: fittingHeight)
  509. }
  510. }
  511. /// Captures clicks for the full sidebar pill so icon, label, and padding behave as one tab.
  512. private final class SidebarNavRowView: NSView {
  513. private let onSelect: () -> Void
  514. init(onSelect: @escaping () -> Void) {
  515. self.onSelect = onSelect
  516. super.init(frame: .zero)
  517. }
  518. @available(*, unavailable)
  519. required init?(coder: NSCoder) {
  520. fatalError("init(coder:) has not been implemented")
  521. }
  522. override func hitTest(_ point: NSPoint) -> NSView? {
  523. guard let superview else { return super.hitTest(point) }
  524. let local = convert(point, from: superview)
  525. return bounds.contains(local) ? self : nil
  526. }
  527. override func mouseDown(with event: NSEvent) {
  528. onSelect()
  529. }
  530. override func updateTrackingAreas() {
  531. super.updateTrackingAreas()
  532. trackingAreas.forEach { removeTrackingArea($0) }
  533. addTrackingArea(NSTrackingArea(
  534. rect: bounds,
  535. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  536. owner: self,
  537. userInfo: nil
  538. ))
  539. }
  540. override func mouseEntered(with event: NSEvent) {
  541. super.mouseEntered(with: event)
  542. NSCursor.pointingHand.push()
  543. }
  544. override func mouseExited(with event: NSEvent) {
  545. super.mouseExited(with: event)
  546. NSCursor.pop()
  547. }
  548. }