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? private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list } private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards } var body: some View { NavigationStack { Group { if layout == .cards { LibraryGridView( viewModel: viewModel, filters: $libraryFilters, selection: $librarySelection, 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 { if layout == .cards { 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: $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, onOpen: { browsingBookmark = bookmark }, onEdit: { editingBookmark = bookmark } ) .onAppear { maybeLoadMore(bookmark) } } } if viewModel.isLoadingMore { HStack { Spacer(); ProgressView(); Spacer() } .listRowSeparator(.hidden) } } .listStyle(.plain) .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) } /// Leaving the library also drops its tag filter. The classic list has no /// filter strip to show one, so a filter that survived the switch would be /// invisible — the list would just look like it had lost bookmarks. private func toggleLayout() { let next = otherLayout let hadTagFilter = libraryFilters.first { $0.id == librarySelection }?.tag != nil withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = next.rawValue } if next == .list && hadTagFilter { librarySelection = libraryFilters.first { $0.tag == nil }?.id viewModel.searchQuery = "" 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() } } }