Adds a cards/list toggle to the bookmarks screen. List mode is the existing screen, untouched and still the default; cards mode is the library presentation running on real bookmarks. Structure: - LibraryKit.swift holds what both the shipping screen and the prototype draw from — the paper palette, the Bookmark -> LibraryItem projection, the color card, and the tab strip. The prototype in Views/Prototypes keeps only its standalone chrome and sample data, so the two can no longer drift. - BookmarkActions.swift extracts the context menu and the podcast launch path out of BookmarkListRow. Cards and rows now offer exactly the same actions because they are the same code. The menu is a @ViewBuilder rather than a ViewModifier: each host already owns the sheets it presents, and a modifier would have forced a second copy of that state. - LibraryGridView.swift is the Bookmark-driven grid, with the same tap-to -open, long-press-for-menu, pagination and podcast sheets as the list. Two behaviors worth calling out: 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; verified against the server that `q=#dev-tools` returns 64 tagged results where `q=dev-tools` returns 0. Leaving cards mode clears any tag filter. The classic list has no filter strip to display one, so a filter that survived the switch would be invisible and the list would look like it had lost bookmarks. The layout choice persists in @AppStorage. The toolbar button shows the layout it switches *to* — a two-state toggle labelled with its current state reads as a status light rather than a control. Verified on an iPhone 17 Pro simulator against the live linkding server in both layouts and both color schemes. Full test suite passes (16 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
333 lines
13 KiB
Swift
333 lines
13 KiB
Swift
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() }
|
|
}
|
|
}
|