- ClaudeService: replace direct OpenRouter calls with backend endpoints (/v1/marks/enrich, /v1/marks/search, /v1/marks/collections) - ClaudeService: add podcast methods (generate, status, downloadAudio) - SettingsView: replace OpenRouter key field with backend URL + API key - PodcastPlayerView: new sheet — generate → poll → AVPlayer playback - BrowserView: headphones toolbar button triggers podcast for current URL - BookmarksView: "Convert to Podcast" context menu item + sheet Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
8.5 KiB
Swift
207 lines
8.5 KiB
Swift
import SwiftUI
|
|
|
|
struct BookmarksView: View {
|
|
@Bindable var viewModel: BookmarksViewModel
|
|
let onDisconnect: () -> Void
|
|
|
|
@Environment(\.openURL) private var openURL
|
|
@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 podcastBookmark: Bookmark?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(viewModel.bookmarks) { bookmark in
|
|
BookmarkRow(bookmark: bookmark)
|
|
.onTapGesture {
|
|
browsingBookmark = bookmark
|
|
}
|
|
.onAppear { maybeLoadMore(bookmark) }
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
|
.listRowSeparator(.visible)
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button(role: .destructive) {
|
|
Task { await viewModel.delete(bookmark) }
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading) {
|
|
Button {
|
|
Task { await viewModel.archive(bookmark) }
|
|
} label: {
|
|
Label("Archive", systemImage: "archivebox")
|
|
}
|
|
.tint(.orange)
|
|
Button {
|
|
editingBookmark = bookmark
|
|
} label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
}
|
|
.tint(.blue)
|
|
}
|
|
.contextMenu {
|
|
Button {
|
|
editingBookmark = bookmark
|
|
} label: {
|
|
Label("Edit", systemImage: "pencil")
|
|
}
|
|
Button {
|
|
browsingBookmark = bookmark
|
|
} label: {
|
|
Label("Open", systemImage: "globe")
|
|
}
|
|
Button {
|
|
if let url = URL(string: bookmark.url) { openURL(url) }
|
|
} label: {
|
|
Label("Open in Safari", systemImage: "safari")
|
|
}
|
|
if viewModel.claude != nil {
|
|
Button {
|
|
podcastBookmark = bookmark
|
|
} label: {
|
|
Label("Convert to Podcast", systemImage: "headphones")
|
|
}
|
|
}
|
|
Divider()
|
|
Button {
|
|
Task { await viewModel.archive(bookmark) }
|
|
} label: {
|
|
Label("Archive", systemImage: "archivebox")
|
|
}
|
|
Button(role: .destructive) {
|
|
Task { await viewModel.delete(bookmark) }
|
|
} label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
if viewModel.isLoadingMore {
|
|
HStack { Spacer(); ProgressView(); Spacer() }
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
if viewModel.claude != nil {
|
|
Menu {
|
|
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) {
|
|
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")
|
|
}
|
|
Button { showSettings = true } label: {
|
|
Image(systemName: "gearshape")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
|
ProgressView()
|
|
}
|
|
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
|
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if viewModel.enrichmentProgress > 0 {
|
|
enrichmentBanner
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
|
}
|
|
.sheet(isPresented: $showCollections) {
|
|
CollectionsView(viewModel: viewModel)
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
.sheet(item: $podcastBookmark) { bookmark in
|
|
if let claude = viewModel.claude {
|
|
PodcastPlayerView(articleUrl: bookmark.url, claude: claude)
|
|
}
|
|
}
|
|
.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)
|
|
.padding(.bottom, 8)
|
|
}
|
|
|
|
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() }
|
|
}
|
|
}
|