cocottetech/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/AssetLibraryView.swift
Natalie d114d9d375 feat(cockpit-kit): 📸 add bump screenshot overlay
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-10 05:00:56 -07:00

199 lines
8.3 KiB
Swift

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
}
}
}
}