At the scaled-up type a horizontal tag strip clipped its third chip mid-word, and a fade only made the clipping prettier. Tags now wrap: you see whole tags or none. SwiftUI has no wrapping stack, so this adds a small FlowLayout capped at maxRows. Subviews past the cap are placed off-screen at zero size rather than left unplaced — a Layout that declines to place a subview gets it laid out at the origin instead of dropped, which would have stacked the leftover tags on top of the first row. Verified by temporarily forcing maxRows to 1: the overflow disappears cleanly, with no ghost chips. Worth knowing: the cap does discard tags on real data. The heaviest bookmarks carry five linkding tags plus AI tags, and two rows hold about four chips at this size, so the tail is hidden rather than truncated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
153 lines
6.0 KiB
Swift
153 lines
6.0 KiB
Swift
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 }
|
|
}
|
|
}
|