Two changes. Tapping a card did nothing. LibraryCard carried its own press-scale effect via onLongPressGesture(minimumDuration: 0), and a zero-duration long press fires immediately and swallows the tap the host attaches. It was invisible in the prototype because nothing was listening for taps there. LibraryCard is now purely presentational and the grid wraps it in a Button with the existing RowPressStyle, which gets the same scale without competing for the gesture. The list now speaks the same design as the cards: paper ground, the color chip where the favicon was, serif quoted title, monospaced domain/date, serif-italic AI summary, monospaced tag chips. Everything the old row carried is still there — unread state, excerpt, tags, podcast affordance, reading progress — along with all swipe actions, since it is still a List. BookmarkListRow takes a style rather than being rewritten, because the Tags and Search screens use the same row and should not be silently restyled by a change aimed at the bookmarks screen. The tag filter strip moved up to BookmarksView. Both layouts speak the same language now, so the strip belongs to the screen rather than to one mode — which also retires the rule that leaving cards mode had to clear the tag filter to stop it becoming invisible. The tag chip strip fades at its trailing edge; without it a tag clipped mid-word reads as broken text rather than as something scrollable. Verified in both layouts and both color schemes against the live server. Full suite passes (16 tests). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
352 lines
14 KiB
Swift
352 lines
14 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 {
|
|
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,
|
|
available: availableTags,
|
|
onChange: applyTagFilter
|
|
)
|
|
|
|
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 {
|
|
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,
|
|
style: .library,
|
|
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)
|
|
}
|
|
|
|
/// Tags of everything currently loaded, heaviest first, minus what's
|
|
/// already pinned as a tab.
|
|
private var availableTags: [String] {
|
|
let taken = Set(libraryFilters.compactMap(\.tag))
|
|
var counts: [String: Int] = [:]
|
|
for b in viewModel.bookmarks {
|
|
for tag in b.tagNames where !taken.contains(tag) { counts[tag, default: 0] += 1 }
|
|
}
|
|
return counts.sorted { ($0.value, $1.key) > ($1.value, $0.key) }.map(\.key)
|
|
}
|
|
|
|
/// 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() }
|
|
}
|
|
}
|