All checks were successful
CI / build-and-deploy (push) Successful in 20s
Replace the fire-and-forget share toast with an interactive SwiftUI card. On open it checks linkding's /api/bookmarks/check/ so an already-saved URL routes to an Update (no more silent duplicates) and a new URL shows a save form with title, tags, notes, and a read-later toggle. Tag suggestions are hybrid: fetch the user's existing tag vocabulary (/api/tags/) and AI tag ideas (/v1/marks/enrich via anonymous device auth), auto-fill known-vocabulary matches into the field and surface net-new ideas as tappable chips. Best-effort and non-blocking — failures never block save. Also: - Refresh bookmarks when the app returns to the foreground (scenePhase). - Render bookmark dates as a static relative label instead of the live ticking RelativeDateTime timer. - Share LinkdingAPI/Bookmark/ServerConfig/MarksAuth into the ShareExtension target via project.yml (durable across xcodegen regen) and regenerate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
170 lines
6.1 KiB
Swift
170 lines
6.1 KiB
Swift
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))
|
|
}
|
|
}
|
|
}
|