import SwiftUI /// The library's list presentation. Carries everything `BookmarkRow` carried — /// unread state, excerpt, tags, podcast affordance, reading progress — in the /// paper vocabulary. The favicon becomes the color chip, so the same swatch /// that identifies a card identifies its row. struct LibraryListRow: View { let bookmark: Bookmark var readingProgress: Double = 0 var onPodcast: (() -> Void)? = nil @State private var podcastTapCount = 0 @State private var podcastCached = false private var item: LibraryItem { LibraryItem(bookmark: bookmark) } /// Compact, static relative date ("6 min ago"). Using a formatter instead of /// `Text(_, style: .relative)` avoids the live per-second ticking timer. private static let relativeFormatter: RelativeDateTimeFormatter = { let f = RelativeDateTimeFormatter() f.unitsStyle = .abbreviated f.dateTimeStyle = .named return f }() var body: some View { HStack(alignment: .top, spacing: 12) { RoundedRectangle(cornerRadius: 3) .fill(item.swatch) .frame(width: 34, height: 46) .overlay(alignment: .topLeading) { if bookmark.unread { // Ink, not blue: the palette is the only color the // library spends, and an accent dot would compete with // the chip it sits on. Circle() .fill(Paper.ink) .frame(width: 10, height: 10) .overlay(Circle().stroke(Paper.sheet, lineWidth: 2)) .offset(x: -4, y: -4) } } VStack(alignment: .leading, spacing: 5) { Text(item.listDisplay) .font(.system(size: 21, design: .serif)) .foregroundStyle(Paper.ink) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 5) { Text(item.source) Text("·") Text(Self.relativeFormatter.localizedString( for: bookmark.dateAdded, relativeTo: Date() )) } .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Paper.ink.opacity(0.45)) .lineLimit(1) if let excerpt = rowExcerpt { Text(excerpt.text) .font(.system(size: 18, design: .serif)) .italic(excerpt.isAI) .foregroundStyle(Paper.ink.opacity(0.6)) .lineLimit(2) } if !effectiveTags.isEmpty { tagRow } } Spacer(minLength: 6) if let onPodcast { Button { podcastTapCount += 1 onPodcast() } label: { Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") .font(.system(size: 25.5, weight: .light)) .foregroundStyle(Paper.ink.opacity(podcastCached ? 0.75 : 0.3)) } .buttonStyle(.plain) .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) } } .padding(.vertical, 14) .contentShape(Rectangle()) .overlay(alignment: .bottom) { if readingProgress > 0.02 { GeometryReader { geo in ZStack(alignment: .leading) { Rectangle().fill(Paper.ink.opacity(0.1)) Rectangle() .fill(Paper.ink.opacity(0.5)) .frame(width: geo.size.width * min(readingProgress, 1)) } } .frame(height: 2) } } .task(id: bookmark.url) { // Stat the podcast cache off the render path: once per appearance // (and when the URL changes), not on every `body` recomputation. let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value } } /// Tags wrap onto up to two lines rather than scrolling sideways. At the /// scaled-up type a horizontal strip clipped its third chip mid-word, which /// read as broken text; wrapping shows whole tags or none. private var tagRow: some View { FlowLayout(spacing: 5, lineSpacing: 5, maxRows: 2) { ForEach(effectiveTags, id: \.self) { tag in Text(tag) .font(.system(size: 15, design: .monospaced)) .foregroundStyle(Paper.ink.opacity(0.55)) .lineLimit(1) .padding(.horizontal, 8) .padding(.vertical, 3) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Paper.rule.opacity(0.6), lineWidth: 0.6) ) } } .padding(.top, 1) } /// Excerpt shown under the title: prefer the AI summary (italic), else the /// page's scraped description / user note. nil hides the line entirely. private var rowExcerpt: (text: String, isAI: Bool)? { if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty { return (s, true) } if let e = bookmark.contentExcerpt { return (e, false) } return nil } private var effectiveTags: [String] { let base = bookmark.tagNames let ai = bookmark.aiTags ?? [] let extra = ai.filter { !base.contains($0) }.prefix(3) return (base + extra).prefix(6).map { $0 } } }