Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+79
-64
@@ -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<String>
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user