281 lines
13 KiB
Swift
281 lines
13 KiB
Swift
import SwiftUI
|
||
|
||
// Mock domain model for the social-media cockpit. Mirrors the design specs:
|
||
// content-drop-composer (drop + legs), approval-card, fleet-roster, content-social
|
||
// contract (9 surfaces + OF/Fansly anchors), and the 1-wk-offset schedule.
|
||
|
||
// MARK: - Surfaces (content-social contract + OnlyFans)
|
||
|
||
enum Surface: String, CaseIterable, Identifiable {
|
||
case onlyfans, fansly, x, instagram, threads, bluesky, reddit, blog
|
||
var id: String { rawValue }
|
||
var label: String {
|
||
switch self {
|
||
case .onlyfans: return "OnlyFans"
|
||
case .fansly: return "Fansly"
|
||
case .x: return "X"
|
||
case .instagram: return "Instagram"
|
||
case .threads: return "Threads"
|
||
case .bluesky: return "Bluesky"
|
||
case .reddit: return "Reddit"
|
||
case .blog: return "Blog"
|
||
}
|
||
}
|
||
var glyph: String {
|
||
switch self {
|
||
case .onlyfans: return "OF"
|
||
case .fansly: return "Fa"
|
||
case .x: return "𝕏"
|
||
case .instagram: return "IG"
|
||
case .threads: return "Th"
|
||
case .bluesky: return "Bs"
|
||
case .reddit: return "Re"
|
||
case .blog: return "▤"
|
||
}
|
||
}
|
||
var isNSFWAnchor: Bool { self == .onlyfans || self == .fansly }
|
||
}
|
||
|
||
enum LegRole: String { case anchor, teaser, longform }
|
||
enum TosStatus: String { case ok, flagged, blocked }
|
||
|
||
struct DropLeg: Identifiable {
|
||
let id = UUID()
|
||
let surface: Surface
|
||
let role: LegRole
|
||
let caption: String
|
||
let linkTarget: String?
|
||
let ppvPrice: Double?
|
||
let tos: TosStatus
|
||
}
|
||
|
||
enum DropState: String {
|
||
case clustering, arcDraft = "arc-draft", derived, scheduled, dispatched, partial, done
|
||
}
|
||
|
||
public struct ContentDrop: Identifiable {
|
||
public let id = UUID()
|
||
let title: String
|
||
let arc: String
|
||
let assetCount: Int
|
||
let clusterSource: String
|
||
let legs: [DropLeg]
|
||
let teaseAt: Date?
|
||
let dropAt: Date
|
||
let followupAt: Date?
|
||
let state: DropState
|
||
|
||
public var displayTitle: String { title }
|
||
}
|
||
|
||
// MARK: - Approval card (supervised autonomy centerpiece)
|
||
|
||
enum Stakes: String { case low, medium, high }
|
||
|
||
public struct PendingApproval: Identifiable {
|
||
public let id = UUID()
|
||
// Backend content_posts id — the approve / set-aside endpoints target this row.
|
||
// nil for mock/composed items (no server row to mutate). `var` so it stays in
|
||
// the memberwise init with a default (a `let` default is omitted from it).
|
||
var postId: UUID? = nil
|
||
let title: String
|
||
let surfaces: [Surface]
|
||
let kind: String // "content-post"
|
||
let stakes: Stakes
|
||
let confidence: Double
|
||
let why: String
|
||
let legPreviews: [(surface: Surface, caption: String, tos: TosStatus)]
|
||
|
||
public var displayTitle: String { title }
|
||
public var primaryCaption: String { legPreviews.first?.caption ?? "" }
|
||
}
|
||
|
||
// Transient receipt shown after approving — supports a brief undo window.
|
||
public struct ApprovalReceipt: Identifiable {
|
||
public let id = UUID()
|
||
let title: String
|
||
let edited: Bool
|
||
let restored: PendingApproval
|
||
}
|
||
|
||
// MARK: - Fleet
|
||
|
||
enum SpecialistStatus: String {
|
||
case auto, idle, degraded, draftOnly = "draft-only", retired
|
||
var dot: String {
|
||
switch self {
|
||
case .auto: return "●"; case .idle: return "○"; case .degraded: return "⚠"
|
||
case .draftOnly: return "◐"; case .retired: return "⊘"
|
||
}
|
||
}
|
||
var group: String {
|
||
switch self {
|
||
case .auto: return "Working now"; case .idle: return "Idle"
|
||
case .degraded: return "Degraded"; case .draftOnly: return "Draft-only"; case .retired: return "Retired"
|
||
}
|
||
}
|
||
}
|
||
|
||
public struct Specialist: Identifiable {
|
||
public let id = UUID()
|
||
let name: String
|
||
let status: SpecialistStatus
|
||
let trust: Double?
|
||
let countToday: Int
|
||
let countLabel: String // "today" | "drafts"
|
||
let note: String?
|
||
|
||
public var displayName: String { name }
|
||
}
|
||
|
||
// MARK: - Agent actions ticker
|
||
|
||
public struct AgentAction: Identifiable {
|
||
public let id = UUID()
|
||
let time: String
|
||
let surface: Surface?
|
||
let summary: String
|
||
let ok: Bool
|
||
}
|
||
|
||
// MARK: - Asset library (the photo pool — classified newest-first)
|
||
|
||
public enum ContentClass: String, CaseIterable {
|
||
case explicit, suggestive, sfw, bts // bts = behind-the-scenes
|
||
var label: String { self == .bts ? "BTS" : rawValue.capitalized }
|
||
}
|
||
|
||
public struct Asset: Identifiable {
|
||
public let id: UUID
|
||
/// Platform `content_assets.id` — nil for mock/composed assets. Drives the
|
||
/// authenticated image fetch; also the STABLE identity so the live poll
|
||
/// (which re-decodes the list every few seconds) doesn't churn `ForEach`
|
||
/// identity and re-flash every thumbnail.
|
||
let assetId: UUID?
|
||
let label: String
|
||
let contentClass: ContentClass
|
||
let isExplicit: Bool
|
||
let qualityScore: Double // 0…1 (AI quality rank)
|
||
let capturedAt: Date
|
||
let hue: Double // placeholder swatch hue (shown until/unless the image loads)
|
||
let scheduled: Bool
|
||
|
||
init(assetId: UUID? = nil, label: String, contentClass: ContentClass, isExplicit: Bool,
|
||
qualityScore: Double, capturedAt: Date, hue: Double, scheduled: Bool) {
|
||
self.id = assetId ?? UUID()
|
||
self.assetId = assetId
|
||
self.label = label
|
||
self.contentClass = contentClass
|
||
self.isExplicit = isExplicit
|
||
self.qualityScore = qualityScore
|
||
self.capturedAt = capturedAt
|
||
self.hue = hue
|
||
self.scheduled = scheduled
|
||
}
|
||
}
|
||
|
||
// MARK: - Analytics (per-surface performance)
|
||
|
||
public struct SurfaceMetric: Identifiable {
|
||
public let id = UUID()
|
||
let surface: Surface
|
||
let posts: Int
|
||
let impressions: Int
|
||
let engagementPct: Double
|
||
let conversions: Int // store unlocks / new subs
|
||
}
|
||
|
||
// MARK: - Mock data
|
||
|
||
enum Mock {
|
||
static func at(_ h: Int, _ m: Int, day: Int = 0) -> Date {
|
||
// Deterministic dates (Date.now is unavailable in this build context anyway).
|
||
var c = DateComponents(); c.year = 2026; c.month = 6; c.day = 8 + day; c.hour = h; c.minute = m
|
||
return Calendar(identifier: .gregorian).date(from: c) ?? Date(timeIntervalSince1970: 0)
|
||
}
|
||
|
||
static let specialists: [Specialist] = [
|
||
Specialist(name: "content-onlyfans", status: .auto, trust: 0.85, countToday: 3, countLabel: "today", note: nil),
|
||
Specialist(name: "publisher", status: .auto, trust: 0.88, countToday: 11, countLabel: "today", note: nil),
|
||
Specialist(name: "producer", status: .idle, trust: 0.74, countToday: 0, countLabel: "today", note: nil),
|
||
Specialist(name: "strategist", status: .idle, trust: 0.78, countToday: 0, countLabel: "today", note: nil),
|
||
Specialist(name: "content-x", status: .degraded, trust: 0.71, countToday: 2, countLabel: "today",
|
||
note: "rate-limited by X — retrying in 14m"),
|
||
Specialist(name: "content-social", status: .draftOnly, trust: 0.58, countToday: 8, countLabel: "drafts", note: nil),
|
||
]
|
||
|
||
static let drops: [ContentDrop] = [
|
||
ContentDrop(
|
||
title: "Red Dress, Berlin",
|
||
arc: "A slow unwind after the Berlin tour — the dress comes off one frame at a time. Teases land mid-week, the full set drops Friday, a soft follow-up keeps the thread warm into the weekend.",
|
||
assetCount: 14, clusterSource: "shoot · Jun 3",
|
||
legs: [
|
||
DropLeg(surface: .onlyfans, role: .anchor, caption: "the full Berlin set — 14 frames, unlocked 🤍", linkTarget: nil, ppvPrice: 18, tos: .ok),
|
||
DropLeg(surface: .fansly, role: .anchor, caption: "Berlin, all of it. 14 new.", linkTarget: nil, ppvPrice: 16, tos: .ok),
|
||
DropLeg(surface: .x, role: .teaser, caption: "berlin did something to me 🌹 full set on the storefront", linkTarget: "quinn.link/store", ppvPrice: nil, tos: .ok),
|
||
DropLeg(surface: .instagram, role: .teaser, caption: "red dress diaries — link in bio", linkTarget: "quinn.link/store", ppvPrice: nil, tos: .flagged),
|
||
DropLeg(surface: .blog, role: .longform, caption: "Red Dress, Berlin — a tour diary", linkTarget: nil, ppvPrice: nil, tos: .ok),
|
||
],
|
||
teaseAt: at(11, 0, day: 0), dropAt: at(20, 0, day: 2), followupAt: at(13, 0, day: 4),
|
||
state: .scheduled),
|
||
ContentDrop(
|
||
title: "Sunday Slow Morning",
|
||
arc: "Soft, backlit, low-stakes. Backfills the gap between hot drops — stocked content riding the offset.",
|
||
assetCount: 8, clusterSource: "stocked · May",
|
||
legs: [
|
||
DropLeg(surface: .onlyfans, role: .anchor, caption: "lazy sunday, just us 🤍", linkTarget: nil, ppvPrice: nil, tos: .ok),
|
||
DropLeg(surface: .x, role: .teaser, caption: "coffee, sheets, nowhere to be ☕", linkTarget: "quinn.link/store", ppvPrice: nil, tos: .ok),
|
||
DropLeg(surface: .bluesky, role: .teaser, caption: "slow morning energy", linkTarget: "quinn.link/store", ppvPrice: nil, tos: .ok),
|
||
],
|
||
teaseAt: at(10, 0, day: 5), dropAt: at(19, 0, day: 6), followupAt: nil,
|
||
state: .scheduled),
|
||
]
|
||
|
||
static let pending: [PendingApproval] = [
|
||
PendingApproval(
|
||
title: "Berlin teaser → X + Instagram",
|
||
surfaces: [.x, .instagram],
|
||
kind: "content-post", stakes: .medium, confidence: 0.78,
|
||
why: "IG leg flagged: caption links to storefront, not of.com (K3b ok). Explicit frame routed to OF only.",
|
||
legPreviews: [
|
||
(.x, "berlin did something to me 🌹 full set on the storefront", .ok),
|
||
(.instagram, "red dress diaries — link in bio", .flagged),
|
||
]),
|
||
PendingApproval(
|
||
title: "Reddit cross-post → r/ subs",
|
||
surfaces: [.reddit],
|
||
kind: "content-post", stakes: .high, confidence: 0.61,
|
||
why: "Per-subreddit rules vary (K3a-4). Draft-only until you pick subs.",
|
||
legPreviews: [(.reddit, "[OC] Berlin set — verification in comments", .flagged)]),
|
||
]
|
||
|
||
static let actions: [AgentAction] = [
|
||
AgentAction(time: "11:02", surface: .x, summary: "teaser posted · Berlin", ok: true),
|
||
AgentAction(time: "10:58", surface: .onlyfans, summary: "drop scheduled 20:00 Fri", ok: true),
|
||
AgentAction(time: "10:41", surface: .bluesky, summary: "cross-post ok", ok: true),
|
||
AgentAction(time: "10:30", surface: .instagram, summary: "draft held — needs approval", ok: false),
|
||
AgentAction(time: "09:52", surface: .fansly, summary: "anchor scheduled", ok: true),
|
||
AgentAction(time: "09:14", surface: .x, summary: "rate-limited — retry queued", ok: false),
|
||
AgentAction(time: "08:40", surface: .blog, summary: "longform drafted", ok: true),
|
||
]
|
||
|
||
static let assets: [Asset] = [
|
||
Asset(label: "berlin_0142", contentClass: .explicit, isExplicit: true, qualityScore: 0.94, capturedAt: at(18, 2, day: -3), hue: 0.96, scheduled: true),
|
||
Asset(label: "berlin_0138", contentClass: .suggestive, isExplicit: false, qualityScore: 0.88, capturedAt: at(18, 1, day: -3), hue: 0.93, scheduled: true),
|
||
Asset(label: "berlin_0131", contentClass: .explicit, isExplicit: true, qualityScore: 0.91, capturedAt: at(17, 58, day: -3), hue: 0.98, scheduled: false),
|
||
Asset(label: "berlin_0120", contentClass: .sfw, isExplicit: false, qualityScore: 0.79, capturedAt: at(17, 50, day: -3), hue: 0.90, scheduled: false),
|
||
Asset(label: "studio_0904", contentClass: .suggestive, isExplicit: false, qualityScore: 0.83, capturedAt: at(14, 12, day: -9), hue: 0.02, scheduled: false),
|
||
Asset(label: "studio_0888", contentClass: .bts, isExplicit: false, qualityScore: 0.61, capturedAt: at(14, 6, day: -9), hue: 0.08, scheduled: false),
|
||
Asset(label: "sunday_0451", contentClass: .suggestive, isExplicit: false, qualityScore: 0.86, capturedAt: at(9, 30, day: -16), hue: 0.05, scheduled: true),
|
||
Asset(label: "sunday_0440", contentClass: .sfw, isExplicit: false, qualityScore: 0.72, capturedAt: at(9, 22, day: -16), hue: 0.94, scheduled: false),
|
||
]
|
||
|
||
static let metrics: [SurfaceMetric] = [
|
||
SurfaceMetric(surface: .onlyfans, posts: 6, impressions: 4200, engagementPct: 0.31, conversions: 38),
|
||
SurfaceMetric(surface: .fansly, posts: 4, impressions: 1800, engagementPct: 0.27, conversions: 12),
|
||
SurfaceMetric(surface: .x, posts: 14, impressions: 52000, engagementPct: 0.041, conversions: 21),
|
||
SurfaceMetric(surface: .instagram, posts: 3, impressions: 9800, engagementPct: 0.062, conversions: 4),
|
||
SurfaceMetric(surface: .bluesky, posts: 7, impressions: 6400, engagementPct: 0.055, conversions: 6),
|
||
SurfaceMetric(surface: .reddit, posts: 2, impressions: 14000, engagementPct: 0.088, conversions: 9),
|
||
]
|
||
}
|