199 lines
8.3 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|