Explorar o código

Improve job cards: top-right actions and full-width rows

- Lay out each card with title and Apply/Saved/dismiss on the top row and description full width below.
- Track last search results for dismiss refresh; add Apply (Indeed search), Saved toggle, and dismiss.
- Make JobListing Hashable for saved-job set storage; refine description preferred width from stack bounds.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 hai 2 meses
pai
achega
729b8ee003

+ 1 - 1
App for Indeed/Models/DashboardModels.swift

@@ -11,7 +11,7 @@ struct SidebarItem {
     let badge: String?
 }
 
-struct JobListing {
+struct JobListing: Hashable {
     let title: String
     let description: String
 }

+ 143 - 20
App for Indeed/Views/DashboardView.swift

@@ -62,6 +62,10 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private var selectedSidebarIndex: Int = 0
     /// Full list from `DashboardData`; results are shown after the user runs a search.
     private var catalogJobListings: [JobListing] = []
+    /// Last successful search result set (used when removing a card with the dismiss control).
+    private var lastSearchResults: [JobListing] = []
+    private var lastNoResultsQuery: String?
+    private var savedJobs: Set<JobListing> = []
 
     override init(frame frameRect: NSRect) {
         super.init(frame: frameRect)
@@ -90,6 +94,7 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         }
         configureSidebar()
         catalogJobListings = data.jobListings
+        savedJobs = []
         configureJobListings([], noResultsForQuery: nil)
         updateMainContentVisibility()
     }
@@ -248,12 +253,19 @@ final class DashboardView: NSView, NSTextFieldDelegate {
     private func updateJobListingDescriptionWidths() {
         let containerWidth = jobListingsContainer.bounds.width
         guard containerWidth > 1 else { return }
-        let innerWidth = containerWidth - 32
+        let buttonStripReserve: CGFloat = 200
+        let fallbackTextColumn = max(1, containerWidth - 32 - buttonStripReserve)
         var didChange = false
         for card in jobListingsStack.arrangedSubviews {
             guard let desc = card.viewWithTag(502) as? NSTextField else { continue }
-            if abs(desc.preferredMaxLayoutWidth - innerWidth) > 0.5 {
-                desc.preferredMaxLayoutWidth = innerWidth
+            let columnWidth: CGFloat
+            if let column = desc.superview, column.bounds.width > 1 {
+                columnWidth = column.bounds.width
+            } else {
+                columnWidth = fallbackTextColumn
+            }
+            if abs(desc.preferredMaxLayoutWidth - columnWidth) > 0.5 {
+                desc.preferredMaxLayoutWidth = columnWidth
                 desc.invalidateIntrinsicContentSize()
                 didChange = true
             }
@@ -264,7 +276,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         }
     }
 
-    private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?) {
+    private func configureJobListings(_ jobs: [JobListing], noResultsForQuery: String?, updateLastResults: Bool = true) {
+        if updateLastResults {
+            lastSearchResults = jobs
+            lastNoResultsQuery = noResultsForQuery
+        }
         jobListingsStack.arrangedSubviews.forEach {
             jobListingsStack.removeArrangedSubview($0)
             $0.removeFromSuperview()
@@ -312,28 +328,130 @@ final class DashboardView: NSView, NSTextFieldDelegate {
         descriptionField.tag = 502
         descriptionField.translatesAutoresizingMaskIntoConstraints = false
 
-        let inner = NSStackView(views: [titleField, descriptionField])
-        inner.orientation = .vertical
-        inner.spacing = 6
-        inner.alignment = .leading
-        inner.translatesAutoresizingMaskIntoConstraints = false
-
-        card.addSubview(inner)
+        let applyButton = JobPayloadButton(title: "Apply", target: self, action: #selector(didTapJobApply(_:)))
+        applyButton.jobPayload = job
+        applyButton.isBordered = false
+        applyButton.bezelStyle = .rounded
+        applyButton.font = .systemFont(ofSize: 13, weight: .semibold)
+        applyButton.wantsLayer = true
+        applyButton.layer?.cornerRadius = 6
+        applyButton.layer?.backgroundColor = Theme.brandBlue.cgColor
+        applyButton.contentTintColor = Theme.proCTAText
+        applyButton.focusRingType = .none
+        applyButton.setContentHuggingPriority(.required, for: .horizontal)
+        applyButton.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+        let savedButton = JobPayloadButton(title: "Saved", target: self, action: #selector(didTapJobSaved(_:)))
+        savedButton.jobPayload = job
+        savedButton.setButtonType(.toggle)
+        savedButton.isBordered = false
+        savedButton.bezelStyle = .rounded
+        savedButton.font = .systemFont(ofSize: 13, weight: .semibold)
+        savedButton.focusRingType = .none
+        savedButton.state = savedJobs.contains(job) ? .on : .off
+        styleJobSavedButton(savedButton)
+        savedButton.setContentHuggingPriority(.required, for: .horizontal)
+        savedButton.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+        let dismissButton = JobPayloadButton()
+        dismissButton.jobPayload = job
+        dismissButton.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Dismiss")
+        dismissButton.imagePosition = .imageOnly
+        dismissButton.imageScaling = .scaleProportionallyDown
+        dismissButton.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
+        dismissButton.isBordered = false
+        dismissButton.bezelStyle = .rounded
+        dismissButton.contentTintColor = Theme.secondaryText
+        dismissButton.target = self
+        dismissButton.action = #selector(didTapJobDismiss(_:))
+        dismissButton.toolTip = "Dismiss"
+        dismissButton.focusRingType = .none
+        dismissButton.setContentHuggingPriority(.required, for: .horizontal)
+
+        let buttonRow = NSStackView(views: [applyButton, savedButton, dismissButton])
+        buttonRow.orientation = .horizontal
+        buttonRow.spacing = 8
+        buttonRow.alignment = .centerY
+        buttonRow.translatesAutoresizingMaskIntoConstraints = false
+        buttonRow.setContentHuggingPriority(.required, for: .horizontal)
+        buttonRow.setContentCompressionResistancePriority(.required, for: .horizontal)
+
+        titleField.setContentHuggingPriority(.defaultLow, for: .horizontal)
+        titleField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+
+        let titleAndActionsRow = NSStackView(views: [titleField, buttonRow])
+        titleAndActionsRow.orientation = .horizontal
+        titleAndActionsRow.spacing = 14
+        titleAndActionsRow.alignment = .centerY
+        titleAndActionsRow.distribution = .fill
+        titleAndActionsRow.translatesAutoresizingMaskIntoConstraints = false
+
+        let contentColumn = NSStackView(views: [titleAndActionsRow, descriptionField])
+        contentColumn.orientation = .vertical
+        contentColumn.spacing = 6
+        contentColumn.alignment = .width
+        contentColumn.translatesAutoresizingMaskIntoConstraints = false
+
+        card.addSubview(contentColumn)
         NSLayoutConstraint.activate([
-            inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
-            inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
-            inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
-            inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14),
-
-            titleField.leadingAnchor.constraint(equalTo: inner.leadingAnchor),
-            titleField.trailingAnchor.constraint(equalTo: inner.trailingAnchor),
-            descriptionField.leadingAnchor.constraint(equalTo: inner.leadingAnchor),
-            descriptionField.trailingAnchor.constraint(equalTo: inner.trailingAnchor)
+            contentColumn.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
+            contentColumn.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
+            contentColumn.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
+            contentColumn.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14),
+
+            applyButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72),
+            applyButton.heightAnchor.constraint(equalToConstant: 28),
+            savedButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72),
+            savedButton.heightAnchor.constraint(equalToConstant: 28),
+            dismissButton.widthAnchor.constraint(equalToConstant: 28),
+            dismissButton.heightAnchor.constraint(equalToConstant: 28)
         ])
 
         return card
     }
 
+    private func styleJobSavedButton(_ button: NSButton) {
+        button.wantsLayer = true
+        button.layer?.cornerRadius = 6
+        let on = button.state == .on
+        if on {
+            button.layer?.backgroundColor = Theme.selectionFill.cgColor
+            button.layer?.borderWidth = 1
+            button.layer?.borderColor = Theme.brandBlue.cgColor
+            button.contentTintColor = Theme.brandBlue
+        } else {
+            button.layer?.backgroundColor = Theme.cardBackground.cgColor
+            button.layer?.borderWidth = 1
+            button.layer?.borderColor = Theme.border.cgColor
+            button.contentTintColor = Theme.primaryText
+        }
+    }
+
+    @objc private func didTapJobApply(_ sender: NSButton) {
+        guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
+        let allowed = CharacterSet.urlQueryAllowed
+        let q = job.title.addingPercentEncoding(withAllowedCharacters: allowed) ?? ""
+        guard let url = URL(string: "https://www.indeed.com/jobs?q=\(q)") else { return }
+        NSWorkspace.shared.open(url)
+    }
+
+    @objc private func didTapJobSaved(_ sender: NSButton) {
+        guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
+        if savedJobs.contains(job) {
+            savedJobs.remove(job)
+        } else {
+            savedJobs.insert(job)
+        }
+        sender.state = savedJobs.contains(job) ? .on : .off
+        styleJobSavedButton(sender)
+    }
+
+    @objc private func didTapJobDismiss(_ sender: NSButton) {
+        guard let job = (sender as? JobPayloadButton)?.jobPayload else { return }
+        lastSearchResults.removeAll { $0 == job }
+        configureJobListings(lastSearchResults, noResultsForQuery: lastNoResultsQuery)
+    }
+
     private func configureSearchBar() {
         let pillCorner: CGFloat = 27
         let barHeight: CGFloat = 54
@@ -820,6 +938,11 @@ final class DashboardView: NSView, NSTextFieldDelegate {
 
 }
 
+/// `NSButton` that carries a `JobListing` for card actions (`representedObject` is unavailable on `NSButton` in this target).
+private final class JobPayloadButton: NSButton {
+    var jobPayload: JobListing?
+}
+
 /// Document view for the job list `NSScrollView`; flipped coordinates keep short result sets aligned to the top of the clip (avoids a large empty band above the cards on macOS).
 private final class JobListingsDocumentView: NSView {
     override var isFlipped: Bool { true }