// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // LibraryViews.swift — la bibliothèque personnelle : Favoris par collections // (multi-univers), Historique consulté, Recherches sauvegardées & ALERTES // honnêtes (deltas réels recomptés via l'API). Stores repris de l'app iOS KA. import SwiftUI // MARK: - Favoris (collections) struct FavoritesView: View { @EnvironmentObject private var favorites: FavoritesStore @Environment(\.colorScheme) private var scheme @State private var selectedCollection: UUID? @State private var selected: KAItem? @State private var newName = "" private var current: FavoritesStore.FavCollection? { favorites.collections.first { $0.id == selectedCollection } ?? favorites.collections.first } var body: some View { VStack(spacing: 0) { header Divider().opacity(0.4) HStack(spacing: 0) { grid .frame(maxWidth: .infinity) Divider().opacity(0.4) Group { if let item = selected { ItemDetailPane(item: item) } else { KAEmptyState(symbol: "heart", title: "Vos favoris, tous univers confondus", message: "Un 4½, une auto et un resto peuvent vivre dans la même collection.") .frame(maxHeight: .infinity) .background(KATheme.paper(scheme)) } } .frame(width: 440) } } } private var header: some View { HStack(spacing: 10) { Text("Favoris").font(.title2.weight(.bold)) Picker("Collection", selection: Binding( get: { selectedCollection ?? favorites.collections.first?.id ?? UUID() }, set: { selectedCollection = $0; selected = nil } )) { ForEach(favorites.collections) { c in Text("\(c.name) (\(c.items.count))").tag(c.id) } } .labelsHidden() .frame(maxWidth: 260) if let c = current, favorites.collections.count > 1 { Button { favorites.removeCollection(c.id) selectedCollection = favorites.collections.first?.id selected = nil } label: { Image(systemName: "trash") } .help("Supprimer la collection « \(c.name) » (les éléments sont retirés des favoris)") } Spacer() TextField("Nouvelle collection…", text: $newName) .textFieldStyle(.roundedBorder) .frame(maxWidth: 200) .onSubmit { addCollection() } Button("Créer") { addCollection() } .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) } .padding(.horizontal, 16).padding(.vertical, 11) } private var grid: some View { ScrollView { if let c = current, !c.items.isEmpty { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(c.items) { item in Button { selected = item } label: { KAItemCard(item: item, selected: selected?.id == item.id) } .buttonStyle(KAPressStyle()) .contextMenu { Button("Retirer des favoris") { favorites.toggle(item) if selected?.id == item.id { selected = nil } } if favorites.collections.count > 1 { Menu("Déplacer vers") { ForEach(favorites.collections.filter { $0.id != c.id }) { other in Button(other.name) { favorites.move(item, to: other.id) } } } } } } } .padding(14) } else { KAEmptyState(symbol: "heart.slash", title: "Aucun favori ici", message: "Cliquez le cœur sur n'importe quelle fiche pour la garder — consultable hors ligne.") .padding(30) } } .background(KATheme.paper(scheme)) } private func addCollection() { favorites.addCollection(newName) newName = "" selectedCollection = favorites.collections.last?.id } } // MARK: - Historique consulté struct HistoryView: View { @EnvironmentObject private var recents: RecentsStore @Environment(\.colorScheme) private var scheme @State private var selected: KAItem? var body: some View { VStack(spacing: 0) { HStack { Text("Historique").font(.title2.weight(.bold)) Text("\(recents.viewed.count) fiche\(recents.viewed.count > 1 ? "s" : "") consultée\(recents.viewed.count > 1 ? "s" : "")") .font(.caption).foregroundStyle(.secondary) Spacer() Button("Effacer l'historique") { recents.clearHistory(); selected = nil } .disabled(recents.viewed.isEmpty) } .padding(.horizontal, 16).padding(.vertical, 11) Divider().opacity(0.4) HStack(spacing: 0) { ScrollView { if recents.viewed.isEmpty { KAEmptyState(symbol: "clock.arrow.circlepath", title: "Rien encore", message: "Les fiches que vous consultez apparaîtront ici, consultables hors ligne.") .padding(30) } else { LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], spacing: 12) { ForEach(recents.viewed) { item in Button { selected = item } label: { KAItemCard(item: item, selected: selected?.id == item.id) } .buttonStyle(KAPressStyle()) } } .padding(14) } } .background(KATheme.paper(scheme)) .frame(maxWidth: .infinity) Divider().opacity(0.4) Group { if let item = selected { ItemDetailPane(item: item) } else { KAEmptyState(symbol: "clock", title: "Choisissez une fiche", message: "Votre historique reste local, sur ce Mac.") .frame(maxHeight: .infinity) .background(KATheme.paper(scheme)) } } .frame(width: 440) } } } } // MARK: - Recherches sauvegardées & alertes struct AlertsView: View { @EnvironmentObject private var recents: RecentsStore @EnvironmentObject private var state: AppState @Environment(\.colorScheme) private var scheme @State private var refreshing = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 14) { HStack { Text("Recherches & alertes").font(.title2.weight(.bold)) Spacer() Button { refreshing = true Task { await recents.refreshAlerts(); refreshing = false } } label: { if refreshing { ProgressView().controlSize(.small) } else { Label("Recompter maintenant", systemImage: "arrow.clockwise").font(.caption) } } .disabled(refreshing || recents.savedSearches.isEmpty) } Text("KA recompte le total réel de chaque recherche via l'API du site et affiche le delta — jamais de faux chiffre. Sauvegardez une recherche depuis n'importe quel univers (bouton « Sauvegarder + alerte »).") .font(.caption).foregroundStyle(.secondary) if recents.savedSearches.isEmpty { KAEmptyState(symbol: "bell.slash", title: "Aucune recherche sauvegardée", message: "Dans un univers, lancez une recherche ou des filtres puis « Sauvegarder + alerte ».") .padding(20) } else { LazyVStack(spacing: 10) { ForEach(recents.savedSearches) { s in row(s) } } } } .padding(18) .frame(maxWidth: 860) .frame(maxWidth: .infinity) } .background(KATheme.paper(scheme)) } private func row(_ s: SavedSearch) -> some View { let u = Ecosystem.universe(s.universeID) return HStack(spacing: 12) { Image(systemName: u?.symbol ?? "magnifyingglass") .foregroundStyle(u?.accent ?? .gray) .frame(width: 30, height: 30) .background((u?.accent ?? .gray).opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 7) { Text(s.name.isEmpty ? "(filtres seulement)" : s.name) .font(.subheadline.weight(.bold)) if s.newCount > 0 { Text("+\(s.newCount) nouveauté\(s.newCount > 1 ? "s" : "")") .font(.system(.caption2, design: .monospaced).weight(.bold)) .padding(.horizontal, 7).padding(.vertical, 2) .background(Color.red.opacity(0.85), in: Capsule()) .foregroundStyle(.white) } } HStack(spacing: 6) { if let u { KAChip(text: u.wordmark, accent: u.accent) } if !s.params.isEmpty { Text(s.params.map { "\($0.key)=\($0.value)" }.sorted().joined(separator: " · ")) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(.secondary).lineLimit(1) } if let total = s.lastTotal { Text("\(total.fr) résultats") .font(.system(size: 10, design: .monospaced).weight(.bold)) .foregroundStyle(u?.accent ?? .secondary) } if let d = s.lastChecked { Text("vérifié \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))") .font(.system(size: 10)).foregroundStyle(.tertiary) } } } Spacer() Toggle("Alerte", isOn: Binding( get: { s.alertsOn }, set: { v in if let i = recents.savedSearches.firstIndex(where: { $0.id == s.id }) { recents.savedSearches[i].alertsOn = v } })) .toggleStyle(.switch) .controlSize(.mini) .labelsHidden() .help(s.alertsOn ? "Alerte active" : "Alerte en pause") Button("Ouvrir") { if s.newCount > 0 { recents.markSeen(s.id) } state.openUniverse(s.universeID, query: s.query, params: s.params) } .tint(u?.accent) Button { recents.remove(s.id) } label: { Image(systemName: "trash") } .help("Supprimer cette recherche sauvegardée") } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .kaCard(accent: s.newCount > 0 ? .red : u?.accent) } }