import SwiftUI struct BookmarkRow: View { let bookmark: Bookmark var readingProgress: Double = 0 var onPodcast: (() -> Void)? = nil @State private var podcastTapCount = 0 @State private var podcastCached = false /// 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 { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .top, spacing: 11) { FaviconView(url: bookmark.faviconUrl) .overlay(alignment: .topLeading) { if bookmark.unread { Circle() .fill(.blue) .frame(width: 8, height: 8) .offset(x: -3, y: -3) } } .padding(.top, 1) VStack(alignment: .leading, spacing: 4) { Text(bookmark.displayTitle) .font(.headline) .foregroundStyle(.primary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 4) { Text(bookmark.domain) Text("ยท") Text(Self.relativeFormatter.localizedString(for: bookmark.dateAdded, relativeTo: Date())) } .font(.footnote) .foregroundStyle(.secondary) } Spacer() if let onPodcast { Button { podcastTapCount += 1 onPodcast() } label: { Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle") .font(.title3) .foregroundStyle(podcastCached ? .blue : Color(.systemGray3)) } .buttonStyle(.plain) .padding(.top, 1) .sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount) } } if let excerpt = rowExcerpt { Text(excerpt.text) .font(.subheadline) .italic(excerpt.isAI) .foregroundStyle(.secondary) .lineLimit(2) .padding(.leading, 44) } if !effectiveTags.isEmpty { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { ForEach(effectiveTags, id: \.self) { tag in Text(tag) .font(.caption.weight(.medium)) .foregroundStyle(.secondary) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color(.systemGray6)) .clipShape(Capsule()) } } } .padding(.leading, 44) } } .padding(.vertical, 14) .contentShape(Rectangle()) .overlay(alignment: .bottom) { if readingProgress > 0.02 { GeometryReader { geo in ZStack(alignment: .leading) { Rectangle() .fill(Color(.systemGray5)) Rectangle() .fill(Color(.systemGreen)) .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 } } /// 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 } } } struct FaviconView: View { let url: String? var body: some View { Group { if let urlString = url, let faviconUrl = URL(string: urlString) { AsyncImage(url: faviconUrl) { phase in switch phase { case .success(let image): image.resizable().scaledToFit() default: placeholder } } } else { placeholder } } .frame(width: 32, height: 32) .clipShape(RoundedRectangle(cornerRadius: 7)) } private var placeholder: some View { RoundedRectangle(cornerRadius: 6) .fill(Color(.systemGray5)) .overlay { Image(systemName: "bookmark") .font(.system(size: 11, weight: .medium)) .foregroundStyle(Color(.systemGray2)) } } }