All checks were successful
CI / build-and-deploy (push) Successful in 25s
- BrowserState: inject scroll-tracking JS after page load, report position via WKScriptMessageHandler (weak ref to avoid retain cycle) - BrowserView: thin blue progress bar at top of viewport; saves progress to UserDefaults on dismiss; restores scroll position on revisit - BookmarkRow: 2px progress bar below tags for partially-read articles - BookmarksView: loads ReadingProgress.all() on appear, refreshes when browser sheet dismisses - ReadingProgress: static helper for UserDefaults-backed [url: Double] store; removes entry when article is fully read (≥99%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
119 lines
4.0 KiB
Swift
119 lines
4.0 KiB
Swift
import SwiftUI
|
|
|
|
struct BookmarkRow: View {
|
|
let bookmark: Bookmark
|
|
var readingProgress: Double = 0
|
|
|
|
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(.system(size: 22, weight: .medium))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Text(bookmark.domain)
|
|
.font(.system(size: 16))
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
|
|
if let summary = bookmark.aiSummary {
|
|
Text(summary)
|
|
.font(.system(size: 15))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.padding(.leading, 40)
|
|
}
|
|
|
|
if !effectiveTags.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(effectiveTags, id: \.self) { tag in
|
|
Text(tag)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundStyle(.blue)
|
|
.padding(.horizontal, 9)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.1))
|
|
.clipShape(Capsule())
|
|
}
|
|
}
|
|
}
|
|
.padding(.leading, 40)
|
|
}
|
|
|
|
// Reading progress indicator
|
|
if readingProgress > 0.02 {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Rectangle()
|
|
.fill(Color(.systemGray5))
|
|
Rectangle()
|
|
.fill(Color.blue.opacity(0.55))
|
|
.frame(width: geo.size.width * min(readingProgress, 1))
|
|
}
|
|
}
|
|
.frame(height: 2)
|
|
.clipShape(Capsule())
|
|
.padding(.leading, 40)
|
|
}
|
|
}
|
|
.padding(.vertical, 14)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
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: 26, height: 26)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
}
|
|
|
|
private var placeholder: some View {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color(.systemGray5))
|
|
.overlay {
|
|
Image(systemName: "bookmark")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(Color(.systemGray2))
|
|
}
|
|
}
|
|
}
|