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 */; };
|
||||
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 */,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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: {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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