import SwiftUI /// The library presentation of `viewModel.bookmarks`: color cards on paper, /// over a browser-tab strip of tag filters. Drops into BookmarksView's content /// area in place of the List, and carries the same actions — tap to open, long /// press for the full menu. struct LibraryGridView: View { @Bindable var viewModel: BookmarksViewModel @Binding var filters: [LibraryFilter] @Binding var selection: LibraryFilter.ID? let onOpen: (Bookmark) -> Void let onEdit: (Bookmark) -> Void @Environment(\.openURL) private var openURL @State private var showFullPlayer = false @State private var episodePicker: Bookmark? private var items: [LibraryItem] { viewModel.bookmarks.map(LibraryItem.init(bookmark:)) } /// Tags of everything currently loaded, heaviest first, minus what's /// already pinned as a tab. private var availableTags: [String] { let taken = Set(filters.compactMap(\.tag)) var counts: [String: Int] = [:] for b in viewModel.bookmarks where !b.tagNames.isEmpty { for tag in b.tagNames where !taken.contains(tag) { counts[tag, default: 0] += 1 } } return counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key) } var body: some View { VStack(spacing: 0) { LibraryTagStrip( filters: $filters, selection: $selection, available: availableTags, onChange: applyFilter ) ScrollView { LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8 ) { ForEach(items) { item in card(item) } } .padding(.horizontal, 18) .padding(.top, 16) .padding(.bottom, 40) if viewModel.isLoadingMore { ProgressView().padding(.bottom, 28) } } .scrollBounceBehavior(.basedOnSize) } .background(Paper.sheet) .podcastSheets( viewModel: viewModel, showFullPlayer: $showFullPlayer, episodePicker: $episodePicker ) } @ViewBuilder private func card(_ item: LibraryItem) -> some View { // The grid renders LibraryItems, but every action needs the Bookmark it // came from. Ids are linkding's, so this is a direct lookup. if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) { LibraryCard(item: item) .contentShape(.rect) .onTapGesture { onOpen(bookmark) } .contextMenu { bookmarkMenuItems( bookmark: bookmark, viewModel: viewModel, openURL: openURL, onOpen: { onOpen(bookmark) }, onEdit: { onEdit(bookmark) }, onPodcast: { launchPodcast( for: bookmark, viewModel: viewModel, showFullPlayer: $showFullPlayer, episodePicker: $episodePicker ) } ) } .onAppear { maybeLoadMore(bookmark) } } } /// 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 applyFilter() { let tag = filters.first { $0.id == selection }?.tag viewModel.searchQuery = tag.map { "#\($0)" } ?? "" Task { await viewModel.search() } } 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() } } }