import SwiftUI // Asset library — the photo pool, newest-first, AI-classified. Real thumbnails // stream from platform.api's authenticated image proxy; a class-tinted swatch // stands in until each loads (and stays for mock, which has no backing media). // Shows content-class + quality + whether it's already scheduled into a drop. // `decodeImage` is shared from Components.swift. public struct AssetLibraryView: View { @Environment(\.tokens) private var t var model: CockpitModel public init(model: CockpitModel) { self.model = model } private let columns = [GridItem(.adaptive(minimum: 104), spacing: 12)] public var body: some View { Scroll { VStack(alignment: .leading, spacing: t.s4) { if let s = model.ingest { ingestionPanel(s) } SectionLabel(text: "Assets · \(model.assets.count) · newest first") Text("AI classifies newest-first; the planner schedules on the 1-week offset.") .font(.system(size: 11)).foregroundStyle(t.ink3) LazyVGrid(columns: columns, spacing: 12) { ForEach(model.assets) { tile($0) } } } .padding(t.s5) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(t.bg) .task { await model.pollAssetsLive() } // live: grid + counts grow as the worker runs } // MARK: - Ingestion management (the Person governs the worker from here) @ViewBuilder private func ingestionPanel(_ s: IngestStatus) -> some View { Card { VStack(alignment: .leading, spacing: t.s3) { HStack { SectionLabel(text: "Ingestion") Spacer() stateBadge(s.state) } HStack(spacing: t.s2) { ProgressView(value: s.progress).tint(t.accent) Text("\(s.processed) / \(s.totalPhotos)") .font(.system(size: 11, design: .monospaced)).foregroundStyle(t.ink2) } HStack(spacing: t.s4) { countChip("hot", s.hotCount, t.accent) countChip("stocked", s.stockedCount, t.ink3) countChip("18+", s.explicitCount, t.warn) if s.failedCount > 0 { countChip("failed", s.failedCount, t.bad) } } if let err = s.lastError, !err.isEmpty { Text(err).font(.system(size: 10)).foregroundStyle(t.bad).lineLimit(2) } HStack(spacing: t.s2) { controlButton( s.state == .paused ? "Resume" : "Run now", system: s.state == .paused ? "play.fill" : "bolt.fill", filled: true ) { Task { await model.controlIngest(s.state == .paused ? .resume : .run) } } controlButton( s.enabled ? "Auto · on" : "Auto · off", system: "infinity", filled: s.enabled ) { Task { await model.controlIngest(s.enabled ? .disable : .enable) } } if s.state != .paused { controlButton("Pause", system: "pause.fill", filled: false) { Task { await model.controlIngest(.pause) } } } if s.backfillVideos { controlButton("Backfilling videos…", system: "film", filled: true) {} } else { controlButton("Backfill videos", system: "film", filled: false) { Task { await model.controlIngest(.backfillVideos) } } } } } } } private func stateBadge(_ st: IngestStatus.RunState) -> some View { let (label, color): (String, Color) = { switch st { case .idle: return ("idle", t.ink3) case .running: return ("running", t.ok) case .paused: return ("paused", t.warn) } }() return Text(label.uppercased()) .font(.system(size: 9, weight: .bold)).tracking(0.6) .foregroundStyle(color) .padding(.horizontal, 7).padding(.vertical, 3) .background(color.opacity(0.14)).clipShape(Capsule()) } private func countChip(_ label: String, _ n: Int, _ color: Color) -> some View { HStack(spacing: 3) { Text("\(n)").font(.system(size: 12, weight: .bold, design: .monospaced)).foregroundStyle(color) Text(label).font(.system(size: 9)).foregroundStyle(t.ink3) } } private func controlButton( _ label: String, system: String, filled: Bool, _ action: @escaping () -> Void ) -> some View { Button(action: action) { HStack(spacing: 4) { Image(systemName: system).font(.system(size: 10, weight: .bold)) Text(label).font(.system(size: 11, weight: .semibold)) } .padding(.horizontal, 10).padding(.vertical, 6) .foregroundStyle(filled ? t.accentFg : t.ink2) .background(filled ? t.accent : t.bgElev) .clipShape(Capsule()) } .buttonStyle(.plain) } private func classColor(_ c: ContentClass) -> Color { switch c { case .explicit: return t.accent case .suggestive: return t.warn case .sfw: return t.ink3 case .bts: return t.ink3 } } private func tile(_ a: Asset) -> some View { VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .topTrailing) { AssetThumbnail(model: model, asset: a) .frame(height: 104) .clipShape(RoundedRectangle(cornerRadius: t.radiusSm)) if a.scheduled { Image(systemName: "calendar.badge.checkmark") .font(.system(size: 11, weight: .bold)).foregroundStyle(t.accentFg) .padding(5).background(t.accent).clipShape(Circle()).padding(6) } } HStack(spacing: 4) { Text(a.contentClass.label).font(.system(size: 9, weight: .bold)) .foregroundStyle(classColor(a.contentClass)) if a.isExplicit { Text("18+").font(.system(size: 8, weight: .bold)).foregroundStyle(t.accent) } Spacer() Text(String(format: "%.2f", a.qualityScore)) .font(.system(size: 9, design: .monospaced)).foregroundStyle(t.ink3) } Text(a.label).font(.system(size: 10, design: .monospaced)).foregroundStyle(t.ink3).lineLimit(1) } } } /// One asset tile's image. Streams the real bytes from the authenticated proxy /// (best-effort) and caches the decoded image in local state. Until it loads — and /// permanently for mock assets with no backing media — it shows a class-tinted /// swatch so the grid is never blank. Loads ONCE per asset: `.task(id:)` keys on /// the stable asset identity, so the 4s live-poll re-decoding the list doesn't /// retrigger fetches for tiles already shown. private struct AssetThumbnail: View { @Environment(\.tokens) private var t let model: CockpitModel let asset: Asset @State private var image: Image? var body: some View { Group { if let image { image.resizable().aspectRatio(contentMode: .fill) } else { LinearGradient( colors: [Color(hue: asset.hue, saturation: 0.42, brightness: 0.42), Color(hue: asset.hue, saturation: 0.30, brightness: 0.22)], startPoint: .topLeading, endPoint: .bottomTrailing) .overlay( Image(systemName: "photo") .font(.system(size: 22)).foregroundStyle(.white.opacity(0.5))) } } .task(id: asset.id) { guard image == nil else { return } if let data = await model.imageData(for: asset), let decoded = decodeImage(data) { image = decoded } } } }