Files
linkding-ios/Marks/Views/BookmarksView.swift
T
Krishna KumarandClaude Opus 5 8a777ffc83
CI / build-and-deploy (pull_request) Successful in 31s
CI / build-and-deploy (push) Successful in 32s
Replace the Tags tab with a tag picker sheet
The tab bar drops from five to four; Tags now opens as a sheet from the +
in the library's filter strip, which is the control that was already
about adding a tag.

Picking a tag pins it as a filter tab rather than pushing to a separate
per-tag list. That made TagBookmarksView redundant — the filter tab shows
the same thing, in whichever layout you're already using — so it's gone.

This also fixes what the + could reach. The old inline menu listed tags
found on the loaded page, so it offered 21 of 96 tags, and once a tag
filter was active it collapsed to just the tags co-occurring with that
one — pinning a second unrelated tag was impossible. The sheet sources
names from the tags endpoint via a new BookmarksViewModel.loadAllTags(),
and counts still come from loaded bookmarks, so a tag we haven't paged in
shows no number rather than a wrong one.

The sheet is searchable, marks already-pinned tags with a check, and
picking a pinned tag selects that tab instead of duplicating it.

Verified by driving the UI: the tab bar is now Bookmarks/Sources/
Podcasts/Search, + opens the sheet with 42 rows where the old menu had
21, and picking "ai" pinned an "ai" tab. Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-26 11:12:47 -05:00

364 lines
14 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: 7)
.fill(Color(.systemGray5))
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Color(.systemGray5))
.frame(maxWidth: .infinity)
.frame(height: 13)
Capsule()
.fill(Color(.systemGray6))
.frame(width: 140, height: 10)
}
.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")
}
}
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")
}
Button {
Task { await viewModel.toggleUnreadFilter() }
} label: {
Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
}
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
}
}
}
.overlay {
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView(
"No Bookmarks",
systemImage: "bookmark",
description: Text("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(.primary)
Text("Adding AI summaries…")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.regularMaterial)
.clipShape(RoundedRectangle(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(.blue)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
.font(.system(size: 13, weight: .medium))
if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.frame(width: 44)
} else {
ProgressView()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(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,
style: .library,
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() }
}
}