Every font size in the library design multiplied by 1.5 — card title and stamp, list title, meta, excerpt and tags, filter tabs, and the prototype's header. Geometry had to follow, or the larger type would have broken the layout it sits in: - The card grid drops from three columns to two. At 16.5pt in a 95pt column a card gets about nine characters per line and every title truncates; the text size is what sets the column count. - Filter tabs grow 30pt -> 42pt tall, the card gains padding and a slightly squarer aspect, and the list's color chip, unread dot and tag chips scale with the text they sit beside. Verified in both layouts against the live server. Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
165 lines
6.3 KiB
Swift
165 lines
6.3 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
|
|
}
|
|
}
|
|
|
|
private var tagRow: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 5) {
|
|
ForEach(effectiveTags, id: \.self) { tag in
|
|
Text(tag)
|
|
.font(.system(size: 15, design: .monospaced))
|
|
.foregroundStyle(Paper.ink.opacity(0.55))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 3)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 1)
|
|
}
|
|
.scrollBounceBehavior(.basedOnSize)
|
|
// Without this the strip clips a tag mid-word at the trailing edge and
|
|
// reads as broken text rather than as something you can scroll.
|
|
.mask(
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .black, location: 0),
|
|
.init(color: .black, location: 0.9),
|
|
.init(color: .clear, location: 1),
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
)
|
|
}
|
|
|
|
/// 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 }
|
|
}
|
|
}
|