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: 3) .fill(Paper.ink.opacity(0.09)) .frame(width: 34, height: 46) VStack(alignment: .leading, spacing: 7) { Capsule() .fill(Paper.ink.opacity(0.09)) .frame(maxWidth: .infinity) .frame(height: 15) Capsule() .fill(Paper.ink.opacity(0.06)) .frame(width: 140, height: 12) } .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 { PaperEmptyState( title: "No Bookmarks", systemImage: "bookmark", message: "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(Paper.ink) Text("Adding AI summaries…") .font(PaperType.meta) .foregroundStyle(Paper.secondary) } .padding(.horizontal, 20) .padding(.vertical, 12) .paperCard(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(Paper.accent) .symbolEffect(.variableColor.iterative, isActive: true) VStack(alignment: .leading, spacing: 2) { Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…") .font(PaperType.stamp) .foregroundStyle(Paper.ink) if let first = jobs.first { Text(first.title.isEmpty ? first.label : first.title) .font(PaperType.micro) .foregroundStyle(Paper.tertiary) .lineLimit(1) } } Spacer() if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 { ProgressView(value: progress) .progressViewStyle(.linear) .tint(Paper.accent) .frame(width: 44) } else { ProgressView() } } .padding(.horizontal, 16) .padding(.vertical, 10) .paperCard(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, 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() } } }