Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9
@@ -14,6 +14,7 @@
|
|||||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||||
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.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 */; };
|
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
|
||||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
||||||
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = "<group>"; };
|
||||||
@@ -252,6 +254,7 @@
|
|||||||
children = (
|
children = (
|
||||||
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
|
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
|
||||||
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
|
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
|
||||||
|
E093C878E702891C64D21FD5 /* LibraryListRow.swift */,
|
||||||
);
|
);
|
||||||
path = Library;
|
path = Library;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -588,6 +591,7 @@
|
|||||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */,
|
||||||
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */,
|
55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */,
|
||||||
4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */,
|
4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */,
|
||||||
|
1F1CB72BBCFFB33B6533D5C9 /* LibraryListRow.swift in Sources */,
|
||||||
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */,
|
50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */,
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
212F713DCC289C48087B79AE /* Log.swift in Sources */,
|
212F713DCC289C48087B79AE /* Log.swift in Sources */,
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import SwiftUI
|
|||||||
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
|
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
|
||||||
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
|
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
|
||||||
struct BookmarkListRow: View {
|
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 bookmark: Bookmark
|
||||||
let viewModel: BookmarksViewModel
|
let viewModel: BookmarksViewModel
|
||||||
var readingProgress: Double = 0
|
var readingProgress: Double = 0
|
||||||
|
var style: Style = .classic
|
||||||
let onOpen: () -> Void
|
let onOpen: () -> Void
|
||||||
var onEdit: (() -> Void)? = nil
|
var onEdit: (() -> Void)? = nil
|
||||||
|
|
||||||
@@ -14,15 +21,13 @@ struct BookmarkListRow: View {
|
|||||||
@State private var episodePickerBookmark: Bookmark?
|
@State private var episodePickerBookmark: Bookmark?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
BookmarkRow(
|
row
|
||||||
bookmark: bookmark,
|
|
||||||
readingProgress: readingProgress,
|
|
||||||
onPodcast: handlePodcast
|
|
||||||
)
|
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { onOpen() }
|
.onTapGesture { onOpen() }
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
.listRowInsets(EdgeInsets(top: 0, leading: 18, bottom: 0, trailing: 18))
|
||||||
.listRowSeparator(.visible)
|
.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
|
// Delete is destructive and not undoable — require an explicit tap on the
|
||||||
// revealed button rather than letting a single full swipe delete instantly.
|
// revealed button rather than letting a single full swipe delete instantly.
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
.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() {
|
private func handlePodcast() {
|
||||||
launchPodcast(
|
launchPodcast(
|
||||||
for: bookmark,
|
for: bookmark,
|
||||||
|
|||||||
@@ -71,12 +71,20 @@ struct BookmarksView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
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 {
|
if layout == .cards {
|
||||||
LibraryGridView(
|
LibraryGridView(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
filters: $libraryFilters,
|
|
||||||
selection: $librarySelection,
|
|
||||||
onOpen: { browsingBookmark = $0 },
|
onOpen: { browsingBookmark = $0 },
|
||||||
onEdit: { editingBookmark = $0 }
|
onEdit: { editingBookmark = $0 }
|
||||||
)
|
)
|
||||||
@@ -87,9 +95,7 @@ struct BookmarksView: View {
|
|||||||
// The large-title area draws from the content behind it, so the
|
// The large-title area draws from the content behind it, so the
|
||||||
// paper ground has to reach past the safe area or the library
|
// paper ground has to reach past the safe area or the library
|
||||||
// appears to start halfway down a white screen.
|
// appears to start halfway down a white screen.
|
||||||
.background {
|
.background(Paper.sheet.ignoresSafeArea())
|
||||||
if layout == .cards { Paper.sheet.ignoresSafeArea() }
|
|
||||||
}
|
|
||||||
.task { librarySelection = librarySelection ?? libraryFilters.first?.id }
|
.task { librarySelection = librarySelection ?? libraryFilters.first?.id }
|
||||||
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
@@ -294,6 +300,7 @@ struct BookmarksView: View {
|
|||||||
bookmark: bookmark,
|
bookmark: bookmark,
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
readingProgress: readingProgress[bookmark.url] ?? 0,
|
readingProgress: readingProgress[bookmark.url] ?? 0,
|
||||||
|
style: .library,
|
||||||
onOpen: { browsingBookmark = bookmark },
|
onOpen: { browsingBookmark = bookmark },
|
||||||
onEdit: { editingBookmark = bookmark }
|
onEdit: { editingBookmark = bookmark }
|
||||||
)
|
)
|
||||||
@@ -304,24 +311,36 @@ struct BookmarksView: View {
|
|||||||
if viewModel.isLoadingMore {
|
if viewModel.isLoadingMore {
|
||||||
HStack { Spacer(); ProgressView(); Spacer() }
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
.listRowSeparator(.hidden)
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Paper.sheet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Leaving the library also drops its tag filter. The classic list has no
|
/// Tags of everything currently loaded, heaviest first, minus what's
|
||||||
/// filter strip to show one, so a filter that survived the switch would be
|
/// already pinned as a tab.
|
||||||
/// invisible — the list would just look like it had lost bookmarks.
|
private var availableTags: [String] {
|
||||||
private func toggleLayout() {
|
let taken = Set(libraryFilters.compactMap(\.tag))
|
||||||
let next = otherLayout
|
var counts: [String: Int] = [:]
|
||||||
let hadTagFilter = libraryFilters.first { $0.id == librarySelection }?.tag != nil
|
for b in viewModel.bookmarks {
|
||||||
withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = next.rawValue }
|
for tag in b.tagNames where !taken.contains(tag) { counts[tag, default: 0] += 1 }
|
||||||
if next == .list && hadTagFilter {
|
|
||||||
librarySelection = libraryFilters.first { $0.tag == nil }?.id
|
|
||||||
viewModel.searchQuery = ""
|
|
||||||
Task { await viewModel.search() }
|
|
||||||
}
|
}
|
||||||
|
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) {
|
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// The library presentation of `viewModel.bookmarks`: color cards on paper,
|
/// The library's card presentation of `viewModel.bookmarks`. Drops into
|
||||||
/// over a browser-tab strip of tag filters. Drops into BookmarksView's content
|
/// BookmarksView's content area in place of the List and carries the same
|
||||||
/// area in place of the List, and carries the same actions — tap to open, long
|
/// actions — tap to open, long press for the full menu. The filter strip above
|
||||||
/// press for the full menu.
|
/// it belongs to BookmarksView, since both layouts share it.
|
||||||
struct LibraryGridView: View {
|
struct LibraryGridView: View {
|
||||||
@Bindable var viewModel: BookmarksViewModel
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
@Binding var filters: [LibraryFilter]
|
|
||||||
@Binding var selection: LibraryFilter.ID?
|
|
||||||
let onOpen: (Bookmark) -> Void
|
let onOpen: (Bookmark) -> Void
|
||||||
let onEdit: (Bookmark) -> Void
|
let onEdit: (Bookmark) -> Void
|
||||||
|
|
||||||
@@ -19,45 +17,25 @@ struct LibraryGridView: View {
|
|||||||
viewModel.bookmarks.map(LibraryItem.init(bookmark:))
|
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 {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
ScrollView {
|
||||||
LibraryTagStrip(
|
LazyVGrid(
|
||||||
filters: $filters,
|
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3),
|
||||||
selection: $selection,
|
spacing: 8
|
||||||
available: availableTags,
|
) {
|
||||||
onChange: applyFilter
|
ForEach(items) { item in
|
||||||
)
|
card(item)
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.scrollBounceBehavior(.basedOnSize)
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 40)
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
ProgressView().padding(.bottom, 28)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.scrollBounceBehavior(.basedOnSize)
|
||||||
.background(Paper.sheet)
|
.background(Paper.sheet)
|
||||||
.podcastSheets(
|
.podcastSheets(
|
||||||
viewModel: viewModel,
|
viewModel: viewModel,
|
||||||
@@ -71,39 +49,31 @@ struct LibraryGridView: View {
|
|||||||
// The grid renders LibraryItems, but every action needs the Bookmark it
|
// The grid renders LibraryItems, but every action needs the Bookmark it
|
||||||
// came from. Ids are linkding's, so this is a direct lookup.
|
// came from. Ids are linkding's, so this is a direct lookup.
|
||||||
if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) {
|
if let bookmark = viewModel.bookmarks.first(where: { $0.id == item.id }) {
|
||||||
LibraryCard(item: item)
|
Button { onOpen(bookmark) } label: {
|
||||||
.contentShape(.rect)
|
LibraryCard(item: item)
|
||||||
.onTapGesture { onOpen(bookmark) }
|
}
|
||||||
.contextMenu {
|
.buttonStyle(RowPressStyle())
|
||||||
bookmarkMenuItems(
|
.contextMenu {
|
||||||
bookmark: bookmark,
|
bookmarkMenuItems(
|
||||||
viewModel: viewModel,
|
bookmark: bookmark,
|
||||||
openURL: openURL,
|
viewModel: viewModel,
|
||||||
onOpen: { onOpen(bookmark) },
|
openURL: openURL,
|
||||||
onEdit: { onEdit(bookmark) },
|
onOpen: { onOpen(bookmark) },
|
||||||
onPodcast: {
|
onEdit: { onEdit(bookmark) },
|
||||||
launchPodcast(
|
onPodcast: {
|
||||||
for: bookmark,
|
launchPodcast(
|
||||||
viewModel: viewModel,
|
for: bookmark,
|
||||||
showFullPlayer: $showFullPlayer,
|
viewModel: viewModel,
|
||||||
episodePicker: $episodePicker
|
showFullPlayer: $showFullPlayer,
|
||||||
)
|
episodePicker: $episodePicker
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
.onAppear { maybeLoadMore(bookmark) }
|
}
|
||||||
|
.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) {
|
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||||
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||||
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||||
|
|||||||
@@ -240,9 +240,13 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
// MARK: - Card
|
// 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 {
|
struct LibraryCard: View {
|
||||||
let item: LibraryItem
|
let item: LibraryItem
|
||||||
@State private var pressed = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
@@ -263,9 +267,6 @@ struct LibraryCard: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
.aspectRatio(0.70, contentMode: .fit)
|
.aspectRatio(0.70, contentMode: .fit)
|
||||||
.background(RoundedRectangle(cornerRadius: 5).fill(item.swatch))
|
.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: {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user