diff --git a/Marks/Intents/IntentSupport.swift b/Marks/Intents/IntentSupport.swift index c0a09bb..ca310b2 100644 --- a/Marks/Intents/IntentSupport.swift +++ b/Marks/Intents/IntentSupport.swift @@ -3,7 +3,7 @@ import AppIntents /// Which top-level tab the app is showing. Used so an intent can switch tabs. enum AppTab: Hashable { - case bookmarks, tags, sources, podcasts, search + case bookmarks, sources, podcasts, search } /// Bridges App Intents (which run in the main app process, since there is no diff --git a/Marks/MarksApp.swift b/Marks/MarksApp.swift index a9516eb..929e545 100644 --- a/Marks/MarksApp.swift +++ b/Marks/MarksApp.swift @@ -54,9 +54,6 @@ struct MainContainer: View { Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) { BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect) } - Tab("Tags", systemImage: "tag", value: AppTab.tags) { - TagsView(viewModel: viewModel) - } Tab("Sources", systemImage: "tray.full", value: AppTab.sources) { SourcesView( library: sourceLibrary, diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 71140ab..4af771d 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -65,6 +65,7 @@ struct BookmarksView: View { 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 } @@ -78,8 +79,8 @@ struct BookmarksView: View { LibraryTagStrip( filters: $libraryFilters, selection: $librarySelection, - available: availableTags, - onChange: applyTagFilter + onChange: applyTagFilter, + onBrowseTags: { showTags = true } ) if layout == .cards { @@ -192,6 +193,13 @@ struct BookmarksView: View { .sheet(isPresented: $showAsk) { AskView() } + .sheet(isPresented: $showTags) { + TagsView( + viewModel: viewModel, + pinned: Set(libraryFilters.compactMap(\.tag)), + onPick: pinTag + ) + } .sheet(isPresented: $showAddBookmark) { AddBookmarkView(viewModel: viewModel) } @@ -319,15 +327,19 @@ struct BookmarksView: View { .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) } - /// Tags of everything currently loaded, heaviest first, minus what's - /// already pinned as a tab. - private var availableTags: [String] { - let taken = Set(libraryFilters.compactMap(\.tag)) - var counts: [String: Int] = [:] - for b in viewModel.bookmarks { - for tag in b.tagNames where !taken.contains(tag) { counts[tag, default: 0] += 1 } + /// 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 + } } - return counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key) + applyTagFilter() } /// Tag tabs filter server-side through linkding's `#tag` search syntax — diff --git a/Marks/Views/BookmarksViewModel.swift b/Marks/Views/BookmarksViewModel.swift index 0ff4d0c..5b5ea18 100644 --- a/Marks/Views/BookmarksViewModel.swift +++ b/Marks/Views/BookmarksViewModel.swift @@ -11,6 +11,9 @@ final class BookmarksViewModel { var searchQuery = "" var nextPageUrl: String? var smartCollections: [SmartCollection] = [] + /// Every tag name on the server. The loaded bookmark page only ever + /// exposes the tags of the most recent 50, which is not the vocabulary. + var allTags: [String] = [] var isGeneratingCollections = false var enrichmentProgress: Double = 0 var unreadFilter = false @@ -200,6 +203,14 @@ final class BookmarksViewModel { } } + /// Best-effort: an empty tag list just means the picker falls back to the + /// tags it can see on loaded bookmarks, so a failure here isn't worth an + /// error alert. + func loadAllTags() async { + guard let tags = try? await api.fetchTags() else { return } + allTags = tags + } + func generateSmartCollections() async { guard !bookmarks.isEmpty else { return } isGeneratingCollections = true diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index f735562..7f41ce3 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -277,9 +277,11 @@ struct LibraryCard: View { struct LibraryTagStrip: View { @Binding var filters: [LibraryFilter] @Binding var selection: LibraryFilter.ID? - /// Tags available to pin that aren't already open. - let available: [String] var onChange: () -> Void = {} + /// Opens the tag picker. It replaced an inline Menu, which could only list + /// tags found on the loaded page — and once a tag filter was active, that + /// collapsed to the handful of tags co-occurring with it. + var onBrowseTags: () -> Void = {} @Namespace private var strip @@ -294,20 +296,13 @@ struct LibraryTagStrip: View { ForEach(filters) { filter in tab(filter) } - Menu { - ForEach(available, id: \.self) { tag in - Button(tag) { add(tag: tag) } - } - if !filters.contains(where: { $0.tag == nil }) { - Divider() - Button("Everything") { add(tag: nil) } - } - } label: { + Button { onBrowseTags() } label: { Image(systemName: "plus") .font(.system(size: 18, weight: .light)) .foregroundStyle(Paper.ink.opacity(0.6)) .frame(width: 50, height: 42) } + .buttonStyle(.plain) .accessibilityLabel("Pin a tag") Spacer(minLength: 0) } @@ -359,15 +354,6 @@ struct LibraryTagStrip: View { } } - private func add(tag: String?) { - let new = LibraryFilter(name: tag ?? "Everything", tag: tag) - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { - filters.append(new) - selection = new.id - } - onChange() - } - private func close(_ filter: LibraryFilter) { filters.removeAll { $0.id == filter.id } if selection == filter.id { diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift index 5672675..5ab25a0 100644 --- a/Marks/Views/Prototypes/LibraryView.swift +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -147,7 +147,17 @@ struct LibraryView: View { LibraryTagStrip( filters: $filters, selection: $selectedFilter, - available: unusedTags + // The prototype has no tag picker sheet behind it; + pins the + // heaviest tag that isn't already open, which is enough to exercise + // the strip's layout. + onBrowseTags: { + guard let tag = unusedTags.first else { return } + let new = LibraryFilter(name: tag, tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + filters.append(new) + selectedFilter = new.id + } + } ) } diff --git a/Marks/Views/TagsView.swift b/Marks/Views/TagsView.swift index 259f203..b77f343 100644 --- a/Marks/Views/TagsView.swift +++ b/Marks/Views/TagsView.swift @@ -1,36 +1,95 @@ import SwiftUI +/// The tag surface, presented as a sheet from the library's filter strip. +/// +/// It used to be a top-level tab that pushed to a per-tag bookmark list. The +/// library's filter tabs now do that job, so this is a picker: choose a tag, +/// it becomes a tab. The old TagBookmarksView went with it. struct TagsView: View { @Bindable var viewModel: BookmarksViewModel + /// Tags already pinned as filter tabs, shown as such rather than hidden — + /// their absence would just read as a missing tag. + let pinned: Set + let onPick: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var query = "" var body: some View { NavigationStack { - List { - ForEach(allTags, id: \.tag) { entry in - NavigationLink { - TagBookmarksView(tag: entry.tag, viewModel: viewModel) - } label: { - HStack { - Text(entry.tag) - .font(.system(size: 17)) - Spacer() - Text("\(entry.count)") - .font(.system(size: 15)) - .foregroundStyle(.secondary) - } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleTags, id: \.tag) { entry in + row(entry) + Rectangle() + .fill(Paper.rule.opacity(0.4)) + .frame(height: 0.6) } } + .padding(.horizontal, 18) } + .background(Paper.sheet.ignoresSafeArea()) + .scrollDismissesKeyboard(.immediately) + .searchable(text: $query, prompt: "Filter tags") .navigationTitle("Tags") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } .overlay { - if viewModel.bookmarks.isEmpty { - ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here.")) + if visibleTags.isEmpty { + ContentUnavailableView( + query.isEmpty ? "No Tags" : "No Matching Tags", + systemImage: "tag", + description: Text( + query.isEmpty + ? "Tags from your bookmarks will appear here." + : "No tag matches “\(query)”." + ) + ) } } } + .task { await viewModel.loadAllTags() } } - private var allTags: [(tag: String, count: Int)] { + private func row(_ entry: (tag: String, count: Int)) -> some View { + Button { + onPick(entry.tag) + dismiss() + } label: { + HStack(spacing: 10) { + Text(entry.tag) + .font(.system(size: 16.5, design: .monospaced)) + .foregroundStyle(Paper.ink) + .lineLimit(1) + if pinned.contains(entry.tag) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(0.4)) + } + Spacer(minLength: 8) + // Counts come from the loaded pages, so a tag the server knows + // about but we haven't paged in yet shows no number rather than + // a wrong one. + if entry.count > 0 { + Text("\(entry.count)") + .font(.system(size: 15, design: .monospaced)) + .foregroundStyle(Paper.ink.opacity(0.4)) + } + } + .padding(.vertical, 14) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + + /// Every tag the server knows, not just those on the loaded page — the + /// filter strip's old inline menu could only offer tags from the current + /// 50 bookmarks, which collapsed to almost nothing once a filter was on. + private var visibleTags: [(tag: String, count: Int)] { var counts: [String: Int] = [:] for bookmark in viewModel.bookmarks { for tag in bookmark.tagNames { @@ -40,54 +99,10 @@ struct TagsView: View { counts[tag, default: 0] += 1 } } - return counts.map { (tag: $0.key, count: $0.value) } + let names = Set(viewModel.allTags).union(counts.keys) + return names + .filter { query.isEmpty || $0.localizedCaseInsensitiveContains(query) } + .map { (tag: $0, count: counts[$0] ?? 0) } .sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag } } } - -struct TagBookmarksView: View { - let tag: String - @Bindable var viewModel: BookmarksViewModel - - @State private var browsingBookmark: Bookmark? - - var body: some View { - List { - ForEach(filteredBookmarks) { bookmark in - BookmarkListRow( - bookmark: bookmark, - viewModel: viewModel, - onOpen: { browsingBookmark = bookmark } - ) - } - } - .listStyle(.plain) - .navigationTitle(tag) - .navigationBarTitleDisplayMode(.large) - .overlay { - if filteredBookmarks.isEmpty { - ContentUnavailableView("No Bookmarks", systemImage: "tag") - } - } - .sensoryFeedback(.selection, trigger: browsingBookmark?.id) - .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) - } - } - } - } - - private var filteredBookmarks: [Bookmark] { - viewModel.bookmarks.filter { - $0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag) - } - } -}