Files
linkding-ios/Marks/Views/BookmarkActions.swift
T
Krishna KumarandClaude Opus 5 99add346fd
CI / build-and-deploy (push) Successful in 19s
CI / build-and-deploy (pull_request) Successful in 20s
Wire the library presentation into BookmarksView
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
2026-07-26 01:41:23 -05:00

126 lines
4.0 KiB
Swift

import SwiftUI
// MARK: - Shared bookmark actions
//
// The context menu and the podcast launch path are identical whether a bookmark
// is presented as a list row or as a library card. They live here so the two
// presentations can't drift apart — the menu is a @ViewBuilder rather than a
// ViewModifier because each host already owns the sheets it needs to present,
// and a modifier would have forced a second copy of that state.
/// Every action a bookmark offers, in the order they appear in the menu.
///
/// `@MainActor` because the callbacks are plain (non-Sendable) UI closures —
/// without it, Swift 6 treats handing them to this function as sending them
/// across isolation domains.
@MainActor
@ViewBuilder
func bookmarkMenuItems(
bookmark: Bookmark,
viewModel: BookmarksViewModel,
openURL: OpenURLAction,
onOpen: @escaping () -> Void,
onEdit: (() -> Void)?,
onPodcast: @escaping () -> Void
) -> some View {
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
}
Button { onOpen() } label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button { onPodcast() } 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")
}
}
/// Resolve what a "convert to podcast" tap should do. Several cached episodes
/// means the user picks; one means play it; none means generate — and
/// generating only takes over the player when it isn't already busy.
///
/// The caller owns the sheet state because the presenting view has to.
@MainActor
func launchPodcast(
for bookmark: Bookmark,
viewModel: BookmarksViewModel,
showFullPlayer: Binding<Bool>,
episodePicker: Binding<Bookmark?>
) {
let episodes = PodcastIndex.find(for: bookmark.url)
if episodes.count >= 2 {
episodePicker.wrappedValue = bookmark
} else if let ep = episodes.first {
viewModel.podcastPlayer.start(
articleUrl: ep.articleUrl,
articleTitle: ep.title ?? bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer.wrappedValue = true
} else if viewModel.playOrGeneratePodcast(
articleUrl: bookmark.url,
title: bookmark.displayTitle
) {
showFullPlayer.wrappedValue = true
}
}
/// The two sheets any bookmark presentation needs once it offers podcasts.
struct PodcastSheets: ViewModifier {
let viewModel: BookmarksViewModel
@Binding var showFullPlayer: Bool
@Binding var episodePicker: Bookmark?
func body(content: Content) -> some View {
content
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
.sheet(item: $episodePicker) { b in
EpisodePickerView(
bookmark: b,
vm: viewModel.podcastPlayer,
claude: viewModel.claude,
podcastGenerator: viewModel.podcastGenerator
)
}
}
}
extension View {
func podcastSheets(
viewModel: BookmarksViewModel,
showFullPlayer: Binding<Bool>,
episodePicker: Binding<Bookmark?>
) -> some View {
modifier(PodcastSheets(
viewModel: viewModel,
showFullPlayer: showFullPlayer,
episodePicker: episodePicker
))
}
}