SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
10.1 KB · 237 lines swift
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// MenuBarView.swift — le MenuBarExtra « Ka » : pouls de l'écosystème en direct3// (compteurs 5 min), recherche universelle rapide (résultats compacts, clic →4// fiche web à la source), pastilles des 13 services, bouton « Ouvrir KA ».5import SwiftUI67struct MenuBarView: View {8    @EnvironmentObject private var pulse: EcosystemPulse9    @EnvironmentObject private var state: AppState10    @Environment(\.openWindow) private var openWindow11    @Environment(\.colorScheme) private var scheme1213    @State private var query = ""14    @State private var results: [KAItem] = []15    @State private var searching = false16    @State private var searched = ""1718    var body: some View {19        VStack(alignment: .leading, spacing: 10) {20            header21            KASearchField(prompt: "Recherche rapide partout…", text: $query) {22                Task { await quickSearch() }23            }24            .onChange(of: query) {25                if query.isEmpty { results = []; searched = "" }26            }27            if searching || !searched.isEmpty {28                searchResults29            } else {30                pulseGrid31            }32            Divider().opacity(0.5)33            statusStrip34            footer35        }36        .padding(12)37        .frame(width: 350)38        .background(KATheme.paper(scheme))39        .onAppear { state.openMain = { focusMainWindow(openWindow) } }40    }4142    // MARK: en-tête4344    private var header: some View {45        HStack {46            GroupeKAMark(size: 15)47            Spacer()48            Button {49                focusMainWindow(openWindow)50            } label: {51                Label("Ouvrir KA", systemImage: "macwindow")52                    .font(.caption.weight(.bold))53                    .padding(.horizontal, 10).padding(.vertical, 5)54                    .background(KATheme.inkLight, in: Capsule())55                    .foregroundStyle(KATheme.lime)56            }57            .buttonStyle(.plain)58        }59    }6061    // MARK: le pouls (compteurs des 12 univers)6263    private var pulseGrid: some View {64        VStack(alignment: .leading, spacing: 6) {65            Text("Le pouls de l'écosystème")66                .font(.system(.caption2, design: .monospaced).weight(.bold))67                .textCase(.uppercase)68                .foregroundStyle(.secondary)69            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 6) {70                ForEach(Ecosystem.all) { u in71                    Button {72                        state.selection = .universe(u.id)73                        focusMainWindow(openWindow)74                    } label: {75                        HStack(spacing: 6) {76                            KALogo(id: u.id, size: 14,77                                   fallbackSymbol: u.symbol, fallbackAccent: u.accent)78                            KAWordmark(universe: u, size: 10)79                            Spacer(minLength: 2)80                            Text(pulse.totals[u.id].map { Double($0).compact } ?? "—")81                                .font(.system(.caption2, design: .monospaced).weight(.bold))82                                .foregroundStyle(pulse.totals[u.id] == nil ? .secondary : u.accent)83                                .contentTransition(.numericText())84                        }85                        .padding(.horizontal, 8).padding(.vertical, 6)86                        .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))87                        .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)88                            .strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1))89                    }90                    .buttonStyle(.plain)91                    .help("\(u.name) — \(u.tagline)")92                }93            }94        }95    }9697    // MARK: résultats compacts multi-univers (clic → fiche web à la source)9899    @ViewBuilder100    private var searchResults: some View {101        ScrollView {102            VStack(alignment: .leading, spacing: 6) {103                if searching {104                    HStack(spacing: 8) {105                        ProgressView().controlSize(.small)106                        Text("KA interroge les univers…").font(.caption).foregroundStyle(.secondary)107                    }108                    .padding(10)109                } else if results.isEmpty {110                    Text("Rien trouvé pour « \(searched) ».")111                        .font(.caption).foregroundStyle(.secondary).padding(10)112                } else {113                    ForEach(results) { item in114                        let u = Ecosystem.universe(item.universeID)115                        Button {116                            NSWorkspace.shared.open(item.url ?? u?.baseURL ?? Ecosystem.hubURL)117                        } label: {118                            HStack(spacing: 8) {119                                RoundedRectangle(cornerRadius: 3)120                                    .fill(u?.accent ?? .gray)121                                    .frame(width: 4, height: 30)122                                VStack(alignment: .leading, spacing: 1) {123                                    Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)124                                    HStack(spacing: 6) {125                                        if let p = item.priceLabel {126                                            Text(p).font(.system(.caption2, design: .rounded).weight(.bold))127                                                .foregroundStyle(u?.accent ?? .primary)128                                        }129                                        Text(u?.wordmark ?? "")130                                            .font(.system(size: 9, weight: .bold, design: .monospaced))131                                            .foregroundStyle(.secondary)132                                    }133                                }134                                Spacer()135                                Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(.tertiary)136                            }137                            .padding(.horizontal, 8).padding(.vertical, 5)138                            .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))139                            .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)140                                .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))141                        }142                        .buttonStyle(.plain)143                        .help("Ouvrir la fiche à la source")144                    }145                    Button {146                        focusMainWindow(openWindow)147                        state.openUniversalSearch()148                    } label: {149                        Text("Recherche complète dans KA →")150                            .font(.caption.weight(.semibold))151                            .foregroundStyle(KATheme.green)152                            .padding(.top, 2)153                    }154                    .buttonStyle(.plain)155                }156            }157        }158        .frame(maxHeight: 300)159    }160161    // MARK: pastilles des 13 services162163    private var statusStrip: some View {164        VStack(alignment: .leading, spacing: 6) {165            HStack {166                Text("Services")167                    .font(.system(.caption2, design: .monospaced).weight(.bold))168                    .textCase(.uppercase)169                    .foregroundStyle(.secondary)170                Spacer()171                Text("\(pulse.servicesUp)/\(pulse.health.count) en ligne")172                    .font(.system(.caption2, design: .monospaced).weight(.bold))173                    .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)174            }175            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {176                ForEach(pulse.health) { s in177                    HStack(spacing: 5) {178                        KAStatusDot(up: s.up)179                        Text(s.name.replacingOccurrences(of: "Groupe-KA", with: "Hub"))180                            .font(.system(size: 10, weight: .semibold))181                            .lineLimit(1)182                        Spacer(minLength: 0)183                    }184                    .help("\(s.id)\(s.latencyMs.map { " — \($0) ms" } ?? "")")185                }186            }187        }188    }189190    // MARK: pied191192    private var footer: some View {193        HStack {194            if let d = pulse.lastRefresh {195                Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")196                    .font(.system(size: 10)).foregroundStyle(.tertiary)197            } else {198                Text("Première actualisation…").font(.system(size: 10)).foregroundStyle(.tertiary)199            }200            Spacer()201            Button("Quitter") { NSApp.terminate(nil) }202                .font(.system(size: 10))203                .buttonStyle(.plain)204                .foregroundStyle(.secondary)205                .keyboardShortcut("q")206        }207    }208209    private func quickSearch() async {210        let q = query.trimmingCharacters(in: .whitespaces)211        guard !q.isEmpty else { return }212        searching = true213        searched = q214        let targets = Ecosystem.all.filter { $0.map != nil }215        var found: [(Universe, [KAItem])] = []216        await withTaskGroup(of: (String, [KAItem]).self) { group in217            for u in targets {218                group.addTask {219                    let items = (try? await UniverseService.fetch(u, query: q, limit: 3)) ?? []220                    return (u.id, items)221                }222            }223            var dict: [String: [KAItem]] = [:]224            for await (id, items) in group { dict[id] = items }225            found = targets.map { ($0, dict[$0.id] ?? []) }226        }227        results = found228            .sorted {229                if $0.0.id == "trouve-ka" { return false }230                if $1.0.id == "trouve-ka" { return true }231                return $0.1.count > $1.1.count232            }233            .flatMap { $0.1.prefix(2) }234        searching = false235    }236}237