SPB Git

spb/ka-macos

Public
10.2 KB · 239 lines swift
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                            Image(systemName: u.symbol)77                                .font(.caption2.weight(.semibold))78                                .foregroundStyle(u.accent)79                                .frame(width: 14)80                            KAWordmark(universe: u, size: 10)81                            Spacer(minLength: 2)82                            Text(pulse.totals[u.id].map { Double($0).compact } ?? "—")83                                .font(.system(.caption2, design: .monospaced).weight(.bold))84                                .foregroundStyle(pulse.totals[u.id] == nil ? .secondary : u.accent)85                                .contentTransition(.numericText())86                        }87                        .padding(.horizontal, 8).padding(.vertical, 6)88                        .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))89                        .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)90                            .strokeBorder(KATheme.ink(scheme).opacity(0.35), lineWidth: 1))91                    }92                    .buttonStyle(.plain)93                    .help("\(u.name) — \(u.tagline)")94                }95            }96        }97    }9899    // MARK: résultats compacts multi-univers (clic → fiche web à la source)100101    @ViewBuilder102    private var searchResults: some View {103        ScrollView {104            VStack(alignment: .leading, spacing: 6) {105                if searching {106                    HStack(spacing: 8) {107                        ProgressView().controlSize(.small)108                        Text("KA interroge les univers…").font(.caption).foregroundStyle(.secondary)109                    }110                    .padding(10)111                } else if results.isEmpty {112                    Text("Rien trouvé pour « \(searched) ».")113                        .font(.caption).foregroundStyle(.secondary).padding(10)114                } else {115                    ForEach(results) { item in116                        let u = Ecosystem.universe(item.universeID)117                        Button {118                            NSWorkspace.shared.open(item.url ?? u?.baseURL ?? Ecosystem.hubURL)119                        } label: {120                            HStack(spacing: 8) {121                                RoundedRectangle(cornerRadius: 3)122                                    .fill(u?.accent ?? .gray)123                                    .frame(width: 4, height: 30)124                                VStack(alignment: .leading, spacing: 1) {125                                    Text(item.title).font(.caption.weight(.semibold)).lineLimit(1)126                                    HStack(spacing: 6) {127                                        if let p = item.priceLabel {128                                            Text(p).font(.system(.caption2, design: .rounded).weight(.bold))129                                                .foregroundStyle(u?.accent ?? .primary)130                                        }131                                        Text(u?.wordmark ?? "")132                                            .font(.system(size: 9, weight: .bold, design: .monospaced))133                                            .foregroundStyle(.secondary)134                                    }135                                }136                                Spacer()137                                Image(systemName: "arrow.up.right").font(.system(size: 9)).foregroundStyle(.tertiary)138                            }139                            .padding(.horizontal, 8).padding(.vertical, 5)140                            .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous))141                            .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)142                                .strokeBorder(KATheme.ink(scheme).opacity(0.3), lineWidth: 1))143                        }144                        .buttonStyle(.plain)145                        .help("Ouvrir la fiche à la source")146                    }147                    Button {148                        focusMainWindow(openWindow)149                        state.openUniversalSearch()150                    } label: {151                        Text("Recherche complète dans KA →")152                            .font(.caption.weight(.semibold))153                            .foregroundStyle(KATheme.green)154                            .padding(.top, 2)155                    }156                    .buttonStyle(.plain)157                }158            }159        }160        .frame(maxHeight: 300)161    }162163    // MARK: pastilles des 13 services164165    private var statusStrip: some View {166        VStack(alignment: .leading, spacing: 6) {167            HStack {168                Text("Services")169                    .font(.system(.caption2, design: .monospaced).weight(.bold))170                    .textCase(.uppercase)171                    .foregroundStyle(.secondary)172                Spacer()173                Text("\(pulse.servicesUp)/\(pulse.health.count) en ligne")174                    .font(.system(.caption2, design: .monospaced).weight(.bold))175                    .foregroundStyle(pulse.servicesUp == pulse.health.count ? KATheme.green : .orange)176            }177            LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {178                ForEach(pulse.health) { s in179                    HStack(spacing: 5) {180                        KAStatusDot(up: s.up)181                        Text(s.name.replacingOccurrences(of: "Groupe-KA", with: "Hub"))182                            .font(.system(size: 10, weight: .semibold))183                            .lineLimit(1)184                        Spacer(minLength: 0)185                    }186                    .help("\(s.id)\(s.latencyMs.map { " — \($0) ms" } ?? "")")187                }188            }189        }190    }191192    // MARK: pied193194    private var footer: some View {195        HStack {196            if let d = pulse.lastRefresh {197                Text("Actualisé \(d.formatted(.relative(presentation: .named, unitsStyle: .narrow)))")198                    .font(.system(size: 10)).foregroundStyle(.tertiary)199            } else {200                Text("Première actualisation…").font(.system(size: 10)).foregroundStyle(.tertiary)201            }202            Spacer()203            Button("Quitter") { NSApp.terminate(nil) }204                .font(.system(size: 10))205                .buttonStyle(.plain)206                .foregroundStyle(.secondary)207                .keyboardShortcut("q")208        }209    }210211    private func quickSearch() async {212        let q = query.trimmingCharacters(in: .whitespaces)213        guard !q.isEmpty else { return }214        searching = true215        searched = q216        let targets = Ecosystem.all.filter { $0.map != nil }217        var found: [(Universe, [KAItem])] = []218        await withTaskGroup(of: (String, [KAItem]).self) { group in219            for u in targets {220                group.addTask {221                    let items = (try? await UniverseService.fetch(u, query: q, limit: 3)) ?? []222                    return (u.id, items)223                }224            }225            var dict: [String: [KAItem]] = [:]226            for await (id, items) in group { dict[id] = items }227            found = targets.map { ($0, dict[$0.id] ?? []) }228        }229        results = found230            .sorted {231                if $0.0.id == "trouve-ka" { return false }232                if $1.0.id == "trouve-ka" { return true }233                return $0.1.count > $1.1.count234            }235            .flatMap { $0.1.prefix(2) }236        searching = false237    }238}239
Files · e2e01af1478899e68831e7da506cabe4fc68c49a
Menu

Theme