|
|
@@ -4,7 +4,7 @@
|
|
4
|
4
|
//
|
|
5
|
5
|
// Template gallery for the CV Maker sidebar destination. Light-theme rendering
|
|
6
|
6
|
// inspired by a dark reference UI: page header, category toggle, style chips,
|
|
7
|
|
-// 4-column thumbnail grid with hover Preview overlay, and a sticky bottom CTA.
|
|
|
7
|
+// 4-column thumbnail grid and a sticky bottom CTA.
|
|
8
|
8
|
//
|
|
9
|
9
|
|
|
10
|
10
|
import Cocoa
|
|
|
@@ -512,7 +512,6 @@ final class CVMakerPageView: NSView {
|
|
512
|
512
|
static let ctaBackground = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 1)
|
|
513
|
513
|
static let ctaHover = NSColor(srgbRed: 28 / 255, green: 70 / 255, blue: 140 / 255, alpha: 1)
|
|
514
|
514
|
static let ctaText = NSColor.white
|
|
515
|
|
- static let overlayTint = NSColor.black.withAlphaComponent(0.45)
|
|
516
|
515
|
static let selectionGlow = NSColor(srgbRed: 37 / 255, green: 87 / 255, blue: 167 / 255, alpha: 0.55)
|
|
517
|
516
|
static let gradientTop = NSColor(srgbRed: 250 / 255, green: 252 / 255, blue: 1, alpha: 1)
|
|
518
|
517
|
static let gradientBottom = NSColor(srgbRed: 236 / 255, green: 244 / 255, blue: 1, alpha: 1)
|
|
|
@@ -534,11 +533,14 @@ final class CVMakerPageView: NSView {
|
|
534
|
533
|
private var selectedGroup: CVCategoryGroup = .designBased
|
|
535
|
534
|
private var selectedFamily: CVDesignFamily? = nil // nil == "All"
|
|
536
|
535
|
private var selectedTemplateID: String? = "paper-white"
|
|
|
536
|
+ /// Exactly one gallery card — avoids multiple highlighted cards when catalog entries share the same `template.id`.
|
|
|
537
|
+ private var selectedTemplateCardToken: UUID?
|
|
537
|
538
|
/// Shown immediately; replaced when `CVTemplateFetchService` returns AI-generated entries.
|
|
538
|
539
|
private var activeCatalog: [CVTemplate] = CVTemplateCatalog.all
|
|
539
|
540
|
private var groupTabButtons: [CVCategoryGroup: CVChipButton] = [:]
|
|
540
|
541
|
private var familyChipButtons: [CVDesignFamily?: CVChipButton] = [:]
|
|
541
|
|
- private var templateCardsByID: [String: CVTemplateCard] = [:]
|
|
|
542
|
+ /// Every visible gallery card (not keyed by id — duplicate AI ids would collapse in a dictionary and break single-selection visuals).
|
|
|
543
|
+ private var templateCardsInGrid: [CVTemplateCard] = []
|
|
542
|
544
|
|
|
543
|
545
|
private var appliedGridColumnCount: Int = 0
|
|
544
|
546
|
|
|
|
@@ -764,7 +766,7 @@ final class CVMakerPageView: NSView {
|
|
764
|
766
|
gridStack.removeArrangedSubview($0)
|
|
765
|
767
|
$0.removeFromSuperview()
|
|
766
|
768
|
}
|
|
767
|
|
- templateCardsByID.removeAll()
|
|
|
769
|
+ templateCardsInGrid.removeAll()
|
|
768
|
770
|
|
|
769
|
771
|
let templates = visibleTemplates
|
|
770
|
772
|
if templates.isEmpty {
|
|
|
@@ -772,6 +774,7 @@ final class CVMakerPageView: NSView {
|
|
772
|
774
|
empty.font = .systemFont(ofSize: 13)
|
|
773
|
775
|
empty.textColor = Palette.secondaryText
|
|
774
|
776
|
gridStack.addArrangedSubview(empty)
|
|
|
777
|
+ selectedTemplateCardToken = nil
|
|
775
|
778
|
return
|
|
776
|
779
|
}
|
|
777
|
780
|
|
|
|
@@ -792,10 +795,12 @@ final class CVMakerPageView: NSView {
|
|
792
|
795
|
let template = templates[position]
|
|
793
|
796
|
let card = CVTemplateCard(template: template, palette: palette())
|
|
794
|
797
|
card.translatesAutoresizingMaskIntoConstraints = false
|
|
795
|
|
- card.onSelect = { [weak self] in self?.didSelectTemplate(template.id) }
|
|
796
|
|
- card.onPreview = { [weak self] in self?.didPreviewTemplate(template.id) }
|
|
|
798
|
+ card.onSelect = { [weak self] in
|
|
|
799
|
+ guard let self else { return }
|
|
|
800
|
+ self.didSelectCard(card)
|
|
|
801
|
+ }
|
|
797
|
802
|
row.addArrangedSubview(card)
|
|
798
|
|
- templateCardsByID[template.id] = card
|
|
|
803
|
+ templateCardsInGrid.append(card)
|
|
799
|
804
|
} else {
|
|
800
|
805
|
let filler = NSView()
|
|
801
|
806
|
filler.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -807,8 +812,15 @@ final class CVMakerPageView: NSView {
|
|
807
|
812
|
index += columns
|
|
808
|
813
|
}
|
|
809
|
814
|
|
|
810
|
|
- if selectedTemplateID == nil || templateCardsByID[selectedTemplateID ?? ""] == nil {
|
|
811
|
|
- selectedTemplateID = templates.first?.id
|
|
|
815
|
+ if let sid = selectedTemplateID,
|
|
|
816
|
+ let match = templateCardsInGrid.first(where: { $0.templateID == sid }) {
|
|
|
817
|
+ selectedTemplateCardToken = match.selectionToken
|
|
|
818
|
+ } else if let first = templateCardsInGrid.first {
|
|
|
819
|
+ selectedTemplateCardToken = first.selectionToken
|
|
|
820
|
+ selectedTemplateID = first.templateID
|
|
|
821
|
+ } else {
|
|
|
822
|
+ selectedTemplateCardToken = nil
|
|
|
823
|
+ selectedTemplateID = nil
|
|
812
|
824
|
}
|
|
813
|
825
|
applySelectionToCards()
|
|
814
|
826
|
}
|
|
|
@@ -835,8 +847,9 @@ final class CVMakerPageView: NSView {
|
|
835
|
847
|
}
|
|
836
|
848
|
|
|
837
|
849
|
private func applySelectionToCards() {
|
|
838
|
|
- for (id, card) in templateCardsByID {
|
|
839
|
|
- card.isSelected = (id == selectedTemplateID)
|
|
|
850
|
+ let token = selectedTemplateCardToken
|
|
|
851
|
+ for card in templateCardsInGrid {
|
|
|
852
|
+ card.isSelected = (card.selectionToken == token)
|
|
840
|
853
|
}
|
|
841
|
854
|
}
|
|
842
|
855
|
|
|
|
@@ -855,8 +868,7 @@ final class CVMakerPageView: NSView {
|
|
855
|
868
|
previewAccentRed: Palette.previewAccentRed,
|
|
856
|
869
|
previewAccentBlue: Palette.previewAccentBlue,
|
|
857
|
870
|
primaryText: Palette.primaryText,
|
|
858
|
|
- secondaryText: Palette.secondaryText,
|
|
859
|
|
- overlayTint: Palette.overlayTint
|
|
|
871
|
+ secondaryText: Palette.secondaryText
|
|
860
|
872
|
)
|
|
861
|
873
|
}
|
|
862
|
874
|
|
|
|
@@ -878,21 +890,12 @@ final class CVMakerPageView: NSView {
|
|
878
|
890
|
updateSelectedChipStates()
|
|
879
|
891
|
}
|
|
880
|
892
|
|
|
881
|
|
- private func didSelectTemplate(_ id: String) {
|
|
882
|
|
- selectedTemplateID = id
|
|
|
893
|
+ private func didSelectCard(_ card: CVTemplateCard) {
|
|
|
894
|
+ selectedTemplateCardToken = card.selectionToken
|
|
|
895
|
+ selectedTemplateID = card.templateID
|
|
883
|
896
|
applySelectionToCards()
|
|
884
|
897
|
}
|
|
885
|
898
|
|
|
886
|
|
- private func didPreviewTemplate(_ id: String) {
|
|
887
|
|
- selectedTemplateID = id
|
|
888
|
|
- applySelectionToCards()
|
|
889
|
|
- guard let template = activeCatalog.first(where: { $0.id == id }) else { return }
|
|
890
|
|
- presentPlaceholderAlert(
|
|
891
|
|
- title: "Preview \"\(template.name)\"",
|
|
892
|
|
- message: "Full-page previews and PDF export are coming soon."
|
|
893
|
|
- )
|
|
894
|
|
- }
|
|
895
|
|
-
|
|
896
|
899
|
@objc private func didTapUseTemplate() {
|
|
897
|
900
|
guard let id = selectedTemplateID,
|
|
898
|
901
|
let template = activeCatalog.first(where: { $0.id == id }) else {
|
|
|
@@ -1173,22 +1176,20 @@ private final class CVChipButton: NSView {
|
|
1173
|
1176
|
|
|
1174
|
1177
|
// MARK: - Template card
|
|
1175
|
1178
|
|
|
1176
|
|
-/// Premium gallery card: live résumé thumbnail, glass-style preview overlay, soft
|
|
1177
|
|
-/// shadow, and an animated brand border when selected.
|
|
|
1179
|
+/// Premium gallery card: live résumé thumbnail, soft shadow, and an animated
|
|
|
1180
|
+/// brand border when selected.
|
|
1178
|
1181
|
private final class CVTemplateCard: NSView {
|
|
1179
|
1182
|
var onSelect: (() -> Void)?
|
|
1180
|
|
- var onPreview: (() -> Void)?
|
|
1181
|
1183
|
var isSelected: Bool = false { didSet { applyChrome() } }
|
|
|
1184
|
+ /// Distinguishes this card from others that may share the same catalog `template.id`.
|
|
|
1185
|
+ let selectionToken = UUID()
|
|
|
1186
|
+ var templateID: String { template.id }
|
|
1182
|
1187
|
private let template: CVTemplate
|
|
1183
|
1188
|
private let palette: CVTemplateCardPalette
|
|
1184
|
1189
|
private let previewSurface = NSView()
|
|
1185
|
1190
|
private let preview: CVTemplatePreviewView
|
|
1186
|
1191
|
private let nameLabel = NSTextField(labelWithString: "")
|
|
1187
|
1192
|
private let categoryLabel = NSTextField(labelWithString: "")
|
|
1188
|
|
- private let overlay = NSView()
|
|
1189
|
|
- private let overlayBadge = NSView()
|
|
1190
|
|
- private let overlayBadgeLabel = NSTextField(labelWithString: "Preview")
|
|
1191
|
|
- private let overlayBadgeIcon = NSImageView()
|
|
1192
|
1193
|
private var trackingArea: NSTrackingArea?
|
|
1193
|
1194
|
private var isHovering: Bool = false
|
|
1194
|
1195
|
private var didPushCursor: Bool = false
|
|
|
@@ -1248,8 +1249,6 @@ private final class CVTemplateCard: NSView {
|
|
1248
|
1249
|
|
|
1249
|
1250
|
addSubview(previewSurface)
|
|
1250
|
1251
|
addSubview(footer)
|
|
1251
|
|
- addSubview(overlay)
|
|
1252
|
|
- configureOverlay()
|
|
1253
|
1252
|
|
|
1254
|
1253
|
NSLayoutConstraint.activate([
|
|
1255
|
1254
|
previewSurface.topAnchor.constraint(equalTo: topAnchor),
|
|
|
@@ -1265,12 +1264,7 @@ private final class CVTemplateCard: NSView {
|
|
1265
|
1264
|
footer.topAnchor.constraint(equalTo: previewSurface.bottomAnchor),
|
|
1266
|
1265
|
footer.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
1267
|
1266
|
footer.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
1268
|
|
- footer.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
1269
|
|
-
|
|
1270
|
|
- overlay.topAnchor.constraint(equalTo: previewSurface.topAnchor),
|
|
1271
|
|
- overlay.leadingAnchor.constraint(equalTo: previewSurface.leadingAnchor),
|
|
1272
|
|
- overlay.trailingAnchor.constraint(equalTo: previewSurface.trailingAnchor),
|
|
1273
|
|
- overlay.bottomAnchor.constraint(equalTo: previewSurface.bottomAnchor)
|
|
|
1267
|
+ footer.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
1274
|
1268
|
])
|
|
1275
|
1269
|
applyChrome()
|
|
1276
|
1270
|
}
|
|
|
@@ -1285,50 +1279,6 @@ private final class CVTemplateCard: NSView {
|
|
1285
|
1279
|
layer?.shadowPath = CGPath(roundedRect: bounds, cornerWidth: 24, cornerHeight: 24, transform: nil)
|
|
1286
|
1280
|
}
|
|
1287
|
1281
|
|
|
1288
|
|
- private func configureOverlay() {
|
|
1289
|
|
- overlay.translatesAutoresizingMaskIntoConstraints = false
|
|
1290
|
|
- overlay.wantsLayer = true
|
|
1291
|
|
- overlay.layer?.backgroundColor = palette.overlayTint.cgColor
|
|
1292
|
|
- overlay.alphaValue = 0
|
|
1293
|
|
- overlay.isHidden = false
|
|
1294
|
|
-
|
|
1295
|
|
- overlayBadge.translatesAutoresizingMaskIntoConstraints = false
|
|
1296
|
|
- overlayBadge.wantsLayer = true
|
|
1297
|
|
- overlayBadge.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.22).cgColor
|
|
1298
|
|
- overlayBadge.layer?.cornerRadius = 20
|
|
1299
|
|
- overlayBadge.layer?.borderWidth = 1
|
|
1300
|
|
- overlayBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.45).cgColor
|
|
1301
|
|
-
|
|
1302
|
|
- overlayBadgeIcon.translatesAutoresizingMaskIntoConstraints = false
|
|
1303
|
|
- overlayBadgeIcon.image = NSImage(systemSymbolName: "eye", accessibilityDescription: nil)
|
|
1304
|
|
- overlayBadgeIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
|
|
1305
|
|
- overlayBadgeIcon.contentTintColor = .white
|
|
1306
|
|
-
|
|
1307
|
|
- overlayBadgeLabel.font = .systemFont(ofSize: 12.5, weight: .semibold)
|
|
1308
|
|
- overlayBadgeLabel.textColor = .white
|
|
1309
|
|
- overlayBadgeLabel.isBordered = false
|
|
1310
|
|
- overlayBadgeLabel.drawsBackground = false
|
|
1311
|
|
- overlayBadgeLabel.isEditable = false
|
|
1312
|
|
- overlayBadgeLabel.isSelectable = false
|
|
1313
|
|
-
|
|
1314
|
|
- let badgeStack = NSStackView(views: [overlayBadgeIcon, overlayBadgeLabel])
|
|
1315
|
|
- badgeStack.orientation = .horizontal
|
|
1316
|
|
- badgeStack.spacing = 7
|
|
1317
|
|
- badgeStack.alignment = .centerY
|
|
1318
|
|
- badgeStack.translatesAutoresizingMaskIntoConstraints = false
|
|
1319
|
|
-
|
|
1320
|
|
- overlayBadge.addSubview(badgeStack)
|
|
1321
|
|
- overlay.addSubview(overlayBadge)
|
|
1322
|
|
- NSLayoutConstraint.activate([
|
|
1323
|
|
- badgeStack.leadingAnchor.constraint(equalTo: overlayBadge.leadingAnchor, constant: 16),
|
|
1324
|
|
- badgeStack.trailingAnchor.constraint(equalTo: overlayBadge.trailingAnchor, constant: -16),
|
|
1325
|
|
- badgeStack.topAnchor.constraint(equalTo: overlayBadge.topAnchor, constant: 9),
|
|
1326
|
|
- badgeStack.bottomAnchor.constraint(equalTo: overlayBadge.bottomAnchor, constant: -9),
|
|
1327
|
|
- overlayBadge.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
|
1328
|
|
- overlayBadge.centerYAnchor.constraint(equalTo: overlay.centerYAnchor)
|
|
1329
|
|
- ])
|
|
1330
|
|
- }
|
|
1331
|
|
-
|
|
1332
|
1282
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
1333
|
1283
|
guard let superview else { return super.hitTest(point) }
|
|
1334
|
1284
|
let local = convert(point, from: superview)
|
|
|
@@ -1336,13 +1286,8 @@ private final class CVTemplateCard: NSView {
|
|
1336
|
1286
|
}
|
|
1337
|
1287
|
|
|
1338
|
1288
|
override func mouseDown(with event: NSEvent) {
|
|
1339
|
|
- let local = convert(event.locationInWindow, from: nil)
|
|
1340
|
|
- if overlay.frame.contains(local) {
|
|
1341
|
|
- onPreview?()
|
|
1342
|
|
- } else {
|
|
1343
|
|
- playTapPulse()
|
|
1344
|
|
- onSelect?()
|
|
1345
|
|
- }
|
|
|
1289
|
+ playTapPulse()
|
|
|
1290
|
+ onSelect?()
|
|
1346
|
1291
|
}
|
|
1347
|
1292
|
|
|
1348
|
1293
|
private func playTapPulse() {
|
|
|
@@ -1372,7 +1317,6 @@ private final class CVTemplateCard: NSView {
|
|
1372
|
1317
|
override func mouseEntered(with event: NSEvent) {
|
|
1373
|
1318
|
super.mouseEntered(with: event)
|
|
1374
|
1319
|
isHovering = true
|
|
1375
|
|
- animateOverlay(visible: true)
|
|
1376
|
1320
|
applyChrome()
|
|
1377
|
1321
|
if !didPushCursor {
|
|
1378
|
1322
|
NSCursor.pointingHand.push()
|
|
|
@@ -1383,7 +1327,6 @@ private final class CVTemplateCard: NSView {
|
|
1383
|
1327
|
override func mouseExited(with event: NSEvent) {
|
|
1384
|
1328
|
super.mouseExited(with: event)
|
|
1385
|
1329
|
isHovering = false
|
|
1386
|
|
- animateOverlay(visible: false)
|
|
1387
|
1330
|
applyChrome()
|
|
1388
|
1331
|
if didPushCursor {
|
|
1389
|
1332
|
NSCursor.pop()
|
|
|
@@ -1400,16 +1343,6 @@ private final class CVTemplateCard: NSView {
|
|
1400
|
1343
|
}
|
|
1401
|
1344
|
}
|
|
1402
|
1345
|
|
|
1403
|
|
- private func animateOverlay(visible: Bool) {
|
|
1404
|
|
- let target: CGFloat = visible ? 1 : 0
|
|
1405
|
|
- NSAnimationContext.runAnimationGroup { context in
|
|
1406
|
|
- context.duration = 0.18
|
|
1407
|
|
- context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
1408
|
|
- context.allowsImplicitAnimation = true
|
|
1409
|
|
- overlay.animator().alphaValue = target
|
|
1410
|
|
- }
|
|
1411
|
|
- }
|
|
1412
|
|
-
|
|
1413
|
1346
|
private func applyChrome() {
|
|
1414
|
1347
|
if isSelected {
|
|
1415
|
1348
|
layer?.borderColor = palette.borderSelected.cgColor
|