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
364 lines
14 KiB
Swift
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() }
|
|
}
|
|
}
|