Files
linkding-ios/Marks/Views/BookmarksView.swift
T
Krishna KumarandClaude Opus 5 76df237473
CI / build-and-deploy (pull_request) Failing after 2s
CI / build-and-deploy (push) Successful in 32s
Stop paperSurface() undoing the serif navigation titles
Only the bookmarks screen had picked up the serif title. Every other
screen — Search, Sources, Podcasts, Settings, Ask, the sheets — was still
system bold sans, and the reason was paperSurface(): setting
.toolbarBackground makes SwiftUI build a fresh UINavigationBarAppearance
and discard the one PaperAppearance installed, text attributes included.
Bookmarks was the only screen not using the helper, which is why it alone
looked right.

The modifier no longer sets a toolbar background. It doesn't need one —
the bar is transparent by appearance and the screen already paints the
paper ground beneath it.

Also labels the bookmarks toolbar buttons (Settings, Add bookmark, Unread
filter, AI actions), which were bare SF Symbols announcing nothing to
VoiceOver.

Verified in both schemes: large titles on Bookmarks/Sources/Podcasts/
Search and inline titles on the Settings and Ask sheets all render serif,
on paper grounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 14:04:22 -05:00

366 lines
15 KiB
Swift

import SwiftUI
// MARK: - Skeleton loading row
private struct SkeletonRow: View {
var delay: Double = 0
@State private var dimmed = false
var body: some View {
HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 3)
.fill(Paper.ink.opacity(0.09))
.frame(width: 34, height: 46)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Paper.ink.opacity(0.09))
.frame(maxWidth: .infinity)
.frame(height: 15)
Capsule()
.fill(Paper.ink.opacity(0.06))
.frame(width: 140, height: 12)
}
.padding(.top, 4)
Spacer()
}
.padding(.vertical, 14)
.opacity(dimmed ? 0.45 : 1)
.onAppear {
withAnimation(
.easeInOut(duration: 0.9)
.repeatForever(autoreverses: true)
.delay(delay)
) { dimmed = true }
}
}
}
// MARK: - Row press ButtonStyle
struct RowPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
}
}
// MARK: - BookmarksView
struct BookmarksView: View {
@Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void
@State private var showSettings = false
@State private var showCollections = false
@State private var showAddBookmark = false
@State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false
@State private var showAsk = false
@State private var readingProgress: [String: Double] = ReadingProgress.all()
@AppStorage("bookmarksLayout") private var layoutRaw = LibraryLayout.list.rawValue
@State private var libraryFilters: [LibraryFilter] = [
LibraryFilter(name: "Everything", tag: nil)
]
@State private var librarySelection: LibraryFilter.ID?
@State private var showTags = false
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list }
private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards }
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Both layouts speak the same design now, so the filter strip
// belongs to the screen rather than to one mode — which also
// means a tag filter can no longer go invisible when you switch.
LibraryTagStrip(
filters: $libraryFilters,
selection: $librarySelection,
onChange: applyTagFilter,
onBrowseTags: { showTags = true }
)
if layout == .cards {
LibraryGridView(
viewModel: viewModel,
onOpen: { browsingBookmark = $0 },
onEdit: { editingBookmark = $0 }
)
} else {
bookmarkList
}
}
// The large-title area draws from the content behind it, so the
// paper ground has to reach past the safe area or the library
// appears to start halfway down a white screen.
.background(Paper.sheet.ignoresSafeArea())
.task { librarySelection = librarySelection ?? libraryFilters.first?.id }
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Button {
showAsk = true
} label: {
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
}
Button {
Task { await viewModel.generateSmartCollections() }
showCollections = true
} label: {
Label("Smart Collections", systemImage: "sparkles")
}
Button {
Task { await viewModel.enrichAll() }
} label: {
Label("Enrich All with AI", systemImage: "wand.and.stars")
}
} label: {
Image(systemName: "sparkles")
}
.accessibilityLabel("AI actions")
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
// Shows where the tap goes, not where you are — a
// two-state toggle labelled with its current state
// reads as a status light rather than a control.
Button { toggleLayout() } label: {
Image(systemName: otherLayout.symbol)
.contentTransition(.symbolEffect(.replace))
}
.accessibilityLabel(
otherLayout == .cards ? "Show as cards" : "Show as list"
)
Button { showAddBookmark = true } label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add bookmark")
Button {
Task { await viewModel.toggleUnreadFilter() }
} label: {
Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
}
.accessibilityLabel("Unread filter")
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
.accessibilityLabel("Settings")
}
}
}
.overlay {
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
PaperEmptyState(
title: "No Bookmarks",
systemImage: "bookmark",
message: "Bookmarks you save will appear here."
)
.transition(.opacity)
}
}
.overlay(alignment: .bottom) {
VStack(spacing: 8) {
if viewModel.podcastGenerator.hasActive {
podcastGeneratingBanner
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if viewModel.enrichmentProgress > 0 {
enrichmentBanner
}
}
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
.padding(.bottom, 8)
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
}
.sheet(isPresented: $showCollections) {
CollectionsView(viewModel: viewModel)
}
.sheet(isPresented: $showAsk) {
AskView()
}
.sheet(isPresented: $showTags) {
TagsView(
viewModel: viewModel,
pinned: Set(libraryFilters.compactMap(\.tag)),
onPick: pinTag
)
}
.sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel)
}
.sheet(item: $editingBookmark) { bookmark in
EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
}
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
await viewModel.archive(bookmark)
}
.bookmarkOnscreen(bookmark)
}
}
.onChange(of: browsingBookmark) { _, new in
if new == nil { readingProgress = ReadingProgress.all() }
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
.refreshable {
await viewModel.load()
}
.task {
if viewModel.bookmarks.isEmpty {
await viewModel.load()
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}
}
private var enrichmentBanner: some View {
HStack(spacing: 10) {
ProgressView(value: viewModel.enrichmentProgress)
.progressViewStyle(.linear)
.tint(Paper.ink)
Text("Adding AI summaries…")
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.paperCard(cornerRadius: 12)
.padding(.horizontal, 16)
}
private var podcastGeneratingBanner: some View {
let jobs = viewModel.podcastGenerator.activeJobs
return HStack(spacing: 10) {
Image(systemName: "waveform")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Paper.accent)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
.font(PaperType.stamp)
.foregroundStyle(Paper.ink)
if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
.lineLimit(1)
}
}
Spacer()
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(Paper.accent)
.frame(width: 44)
} else {
ProgressView()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.paperCard(cornerRadius: 12)
.padding(.horizontal, 16)
}
private var bookmarkList: some View {
List {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ForEach(0..<3, id: \.self) { i in
SkeletonRow(delay: Double(i) * 0.13)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
}
} else {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
.onAppear { maybeLoadMore(bookmark) }
}
}
if viewModel.isLoadingMore {
HStack { Spacer(); ProgressView(); Spacer() }
.listRowSeparator(.hidden)
.listRowBackground(Paper.sheet)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
}
/// Pin a tag as a filter tab and switch to it. Choosing one that's already
/// pinned selects that tab instead of adding a duplicate.
private func pinTag(_ tag: String) {
if let existing = libraryFilters.first(where: { $0.tag == tag }) {
librarySelection = existing.id
} else {
let new = LibraryFilter(name: tag, tag: tag)
withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
libraryFilters.append(new)
librarySelection = new.id
}
}
applyTagFilter()
}
/// Tag tabs filter server-side through linkding's `#tag` search syntax —
/// filtering the loaded page client-side would only ever search the most
/// recent 50 of 600+ bookmarks and quietly look empty.
private func applyTagFilter() {
let tag = libraryFilters.first { $0.id == librarySelection }?.tag
viewModel.searchQuery = tag.map { "#\($0)" } ?? ""
Task { await viewModel.search() }
}
private func toggleLayout() {
withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = otherLayout.rawValue }
}
private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
Task { await viewModel.loadMore() }
}
}