Files
linkding-ios/Marks/Views/BookmarkRow.swift

147 lines
5.1 KiB
Swift

import SwiftUI
struct BookmarkRow: View {
let bookmark: Bookmark
var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 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: 17, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 4) {
Text(bookmark.domain)
Text("·")
Text(bookmark.dateAdded, style: .relative)
}
.font(.system(size: 13))
.foregroundStyle(.tertiary)
}
Spacer()
if let onPodcast {
Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 26))
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
}
.buttonStyle(.plain)
.padding(.top, 1)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
}
}
if let summary = bookmark.aiSummary {
Text(summary)
.font(.system(size: 14))
.italic()
.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(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.systemGray6))
.clipShape(Capsule())
}
}
}
.padding(.leading, 44)
}
// 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, 44)
}
}
.padding(.vertical, 14)
.contentShape(Rectangle())
}
private var podcastCached: Bool {
FileManager.default.fileExists(atPath: ClaudeService.cachedPodcastURL(for: bookmark.url).path)
}
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))
}
}
}