Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9

Merged
admin merged 11 commits from feat/library-browse-prototype into master 2026-07-30 04:03:03 +00:00
6 changed files with 279 additions and 98 deletions
Showing only changes of commit 3c7a783b6c - Show all commits
+4
View File
@@ -14,6 +14,7 @@
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E093C878E702891C64D21FD5 /* LibraryListRow.swift */; };
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
@@ -180,6 +181,7 @@
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E093C878E702891C64D21FD5 /* LibraryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListRow.swift; sourceTree = "<group>"; };
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
@@ -252,6 +254,7 @@
children = (
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
E093C878E702891C64D21FD5 /* LibraryListRow.swift */,
);
path = Library;
sourceTree = "<group>";
@@ -588,6 +591,7 @@
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */,
4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */,
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */,
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */,
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
212F713DCC289C48087B79AE /* Log.swift in Sources */,
+29 -6
View File
@@ -3,9 +3,16 @@ import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
struct BookmarkListRow: View {
/// Which visual language the row speaks. `.library` is the paper design
/// used by the bookmarks screen; `.classic` is the system-styled row the
/// Tags and Search screens still use, so redesigning one doesn't silently
/// restyle the others.
enum Style { case classic, library }
let bookmark: Bookmark
let viewModel: BookmarksViewModel
var readingProgress: Double = 0
var style: Style = .classic
let onOpen: () -> Void
var onEdit: (() -> Void)? = nil
@@ -14,15 +21,13 @@ struct BookmarkListRow: View {
@State private var episodePickerBookmark: Bookmark?
var body: some View {
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
row
.contentShape(Rectangle())
.onTapGesture { onOpen() }
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
.listRowSeparator(.visible)
.listRowSeparatorTint(style == .library ? Paper.rule.opacity(0.5) : nil)
.listRowBackground(style == .library ? Paper.sheet : nil)
// Delete is destructive and not undoable require an explicit tap on the
// revealed button rather than letting a single full swipe delete instantly.
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
@@ -63,6 +68,24 @@ struct BookmarkListRow: View {
)
}
@ViewBuilder
private var row: some View {
switch style {
case .classic:
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
case .library:
LibraryListRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
}
}
private func handlePodcast() {
launchPodcast(
for: bookmark,
+36 -17
View File
@@ -71,12 +71,20 @@ struct BookmarksView: View {
var body: some View {
NavigationStack {
Group {
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,
filters: $libraryFilters,
selection: $librarySelection,
onOpen: { browsingBookmark = $0 },
onEdit: { editingBookmark = $0 }
)
@@ -87,9 +95,7 @@ struct BookmarksView: View {
// 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() }
}
.background(Paper.sheet.ignoresSafeArea())
.task { librarySelection = librarySelection ?? libraryFilters.first?.id }
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large)
@@ -294,6 +300,7 @@ struct BookmarksView: View {
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
style: .library,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
@@ -304,24 +311,36 @@ struct BookmarksView: View {
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)
}
/// 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() }
/// 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) {
+41 -71
View File
@@ -1,13 +1,11 @@
import SwiftUI
/// The library presentation of `viewModel.bookmarks`: color cards on paper,
/// over a browser-tab strip of tag filters. Drops into BookmarksView's content
/// area in place of the List, and carries the same actions tap to open, long
/// press for the full menu.
/// The library's card presentation of `viewModel.bookmarks`. Drops into
/// BookmarksView's content area in place of the List and carries the same
/// actions tap to open, long press for the full menu. The filter strip above
/// it belongs to BookmarksView, since both layouts share it.
struct LibraryGridView: View {
@Bindable var viewModel: BookmarksViewModel
@Binding var filters: [LibraryFilter]
@Binding var selection: LibraryFilter.ID?
let onOpen: (Bookmark) -> Void
let onEdit: (Bookmark) -> Void
@@ -19,45 +17,25 @@ struct LibraryGridView: View {
viewModel.bookmarks.map(LibraryItem.init(bookmark:))
}
/// Tags of everything currently loaded, heaviest first, minus what's
/// already pinned as a tab.
private var availableTags: [String] {
let taken = Set(filters.compactMap(\.tag))
var counts: [String: Int] = [:]
for b in viewModel.bookmarks where !b.tagNames.isEmpty {
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)
}
var body: some View {
VStack(spacing: 0) {
LibraryTagStrip(
filters: $filters,
selection: $selection,
available: availableTags,
onChange: applyFilter
)
ScrollView {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
spacing: 8
) {
ForEach(items) { item in
card(item)
}
}
.padding(.horizontal, 18)
.padding(.top, 16)
.padding(.bottom, 40)
if viewModel.isLoadingMore {
ProgressView().padding(.bottom, 28)
ScrollView {
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
spacing: 8
) {
ForEach(items) { item in
card(item)
}
}
.scrollBounceBehavior(.basedOnSize)
.padding(.horizontal, 18)
.padding(.top, 16)
.padding(.bottom, 40)
if viewModel.isLoadingMore {
ProgressView().padding(.bottom, 28)
}
}
.scrollBounceBehavior(.basedOnSize)
.background(Paper.sheet)
.podcastSheets(
viewModel: viewModel,
@@ -71,39 +49,31 @@ struct LibraryGridView: View {
// The grid renders LibraryItems, but every action needs the Bookmark it
// came from. Ids are linkding's, so this is a direct lookup.
if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) {
LibraryCard(item: item)
.contentShape(.rect)
.onTapGesture { onOpen(bookmark) }
.contextMenu {
bookmarkMenuItems(
bookmark: bookmark,
viewModel: viewModel,
openURL: openURL,
onOpen: { onOpen(bookmark) },
onEdit: { onEdit(bookmark) },
onPodcast: {
launchPodcast(
for: bookmark,
viewModel: viewModel,
showFullPlayer: $showFullPlayer,
episodePicker: $episodePicker
)
}
)
}
.onAppear { maybeLoadMore(bookmark) }
Button { onOpen(bookmark) } label: {
LibraryCard(item: item)
}
.buttonStyle(RowPressStyle())
.contextMenu {
bookmarkMenuItems(
bookmark: bookmark,
viewModel: viewModel,
openURL: openURL,
onOpen: { onOpen(bookmark) },
onEdit: { onEdit(bookmark) },
onPodcast: {
launchPodcast(
for: bookmark,
viewModel: viewModel,
showFullPlayer: $showFullPlayer,
episodePicker: $episodePicker
)
}
)
}
.onAppear { maybeLoadMore(bookmark) }
}
}
/// 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 applyFilter() {
let tag = filters.first { $0.id == selection }?.tag
viewModel.searchQuery = tag.map { "#\($0)" } ?? ""
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 }
+5 -4
View File
@@ -240,9 +240,13 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
// MARK: - Card
/// Purely presentational. Press feedback is deliberately *not* here a
/// zero-duration long-press gesture on the card swallows any tap the host
/// attaches, which silently broke tap-to-open. Hosts wrap this in a Button
/// with `RowPressStyle` instead, which gets the same scale without competing
/// for the gesture.
struct LibraryCard: View {
let item: LibraryItem
@State private var pressed = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -263,9 +267,6 @@ struct LibraryCard: View {
.frame(maxWidth: .infinity, alignment: .topLeading)
.aspectRatio(0.70, contentMode: .fit)
.background(RoundedRectangle(cornerRadius: 5).fill(item.swatch))
.scaleEffect(pressed ? 0.965 : 1)
.animation(.spring(duration: 0.2, bounce: 0), value: pressed)
.onLongPressGesture(minimumDuration: 0, pressing: { pressed = $0 }, perform: {})
}
}
+164
View File
@@ -0,0 +1,164 @@
import SwiftUI
/// The library's list presentation. Carries everything `BookmarkRow` carried
/// unread state, excerpt, tags, podcast affordance, reading progress in the
/// paper vocabulary. The favicon becomes the color chip, so the same swatch
/// that identifies a card identifies its row.
struct LibraryListRow: View {
let bookmark: Bookmark
var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0
@State private var podcastCached = false
private var item: LibraryItem { LibraryItem(bookmark: bookmark) }
/// Compact, static relative date ("6 min ago"). Using a formatter instead of
/// `Text(_, style: .relative)` avoids the live per-second ticking timer.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
HStack(alignment: .top, spacing: 12) {
RoundedRectangle(cornerRadius: 3)
.fill(item.swatch)
.frame(width: 26, height: 34)
.overlay(alignment: .topLeading) {
if bookmark.unread {
// Ink, not blue: the palette is the only color the
// library spends, and an accent dot would compete with
// the chip it sits on.
Circle()
.fill(Paper.ink)
.frame(width: 7, height: 7)
.overlay(Circle().stroke(Paper.sheet, lineWidth: 1.5))
.offset(x: -3, y: -3)
}
}
VStack(alignment: .leading, spacing: 5) {
Text(item.listDisplay)
.font(.system(size: 14, design: .serif))
.foregroundStyle(Paper.ink)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 5) {
Text(item.source)
Text("·")
Text(Self.relativeFormatter.localizedString(
for: bookmark.dateAdded, relativeTo: Date()
))
}
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Paper.ink.opacity(0.45))
.lineLimit(1)
if let excerpt = rowExcerpt {
Text(excerpt.text)
.font(.system(size: 12, design: .serif))
.italic(excerpt.isAI)
.foregroundStyle(Paper.ink.opacity(0.6))
.lineLimit(2)
}
if !effectiveTags.isEmpty {
tagRow
}
}
Spacer(minLength: 6)
if let onPodcast {
Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 17, weight: .light))
.foregroundStyle(Paper.ink.opacity(podcastCached ? 0.75 : 0.3))
}
.buttonStyle(.plain)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
}
}
.padding(.vertical, 12)
.contentShape(Rectangle())
.overlay(alignment: .bottom) {
if readingProgress > 0.02 {
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle().fill(Paper.ink.opacity(0.1))
Rectangle()
.fill(Paper.ink.opacity(0.5))
.frame(width: geo.size.width * min(readingProgress, 1))
}
}
.frame(height: 2)
}
}
.task(id: bookmark.url) {
// Stat the podcast cache off the render path: once per appearance
// (and when the URL changes), not on every `body` recomputation.
let path = ClaudeService.cachedPodcastURL(for: bookmark.url).path
podcastCached = await Task.detached { FileManager.default.fileExists(atPath: path) }.value
}
}
private var tagRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 5) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.system(size: 10, design: .monospaced))
.foregroundStyle(Paper.ink.opacity(0.55))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.overlay(
RoundedRectangle(cornerRadius: 3)
.stroke(Paper.rule.opacity(0.6), lineWidth: 0.6)
)
}
}
.padding(.vertical, 1)
}
.scrollBounceBehavior(.basedOnSize)
// Without this the strip clips a tag mid-word at the trailing edge and
// reads as broken text rather than as something you can scroll.
.mask(
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black, location: 0.9),
.init(color: .clear, location: 1),
],
startPoint: .leading,
endPoint: .trailing
)
)
}
/// Excerpt shown under the title: prefer the AI summary (italic), else the
/// page's scraped description / user note. nil hides the line entirely.
private var rowExcerpt: (text: String, isAI: Bool)? {
if let s = bookmark.aiSummary?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {
return (s, true)
}
if let e = bookmark.contentExcerpt {
return (e, false)
}
return nil
}
private var effectiveTags: [String] {
let base = bookmark.tagNames
let ai = bookmark.aiTags ?? []
let extra = ai.filter { !base.contains($0) }.prefix(3)
return (base + extra).prefix(6).map { $0 }
}
}