diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index e14c9f1..cdaa114 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -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 = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = ""; }; + E093C878E702891C64D21FD5 /* LibraryListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListRow.swift; sourceTree = ""; }; E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkAssistant.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; F1656ED1A2E9858235FF98B2 /* SourceSpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSpotlightIndexer.swift; sourceTree = ""; }; @@ -252,6 +254,7 @@ children = ( 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, + E093C878E702891C64D21FD5 /* LibraryListRow.swift */, ); path = Library; sourceTree = ""; @@ -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 */, diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 0d97c49..e865d43 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -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, diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index caab27e..71140ab 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -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) { diff --git a/Marks/Views/Library/LibraryGridView.swift b/Marks/Views/Library/LibraryGridView.swift index 9f9fc95..edceb8b 100644 --- a/Marks/Views/Library/LibraryGridView.swift +++ b/Marks/Views/Library/LibraryGridView.swift @@ -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 } diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift index 3e7332d..5b7dc85 100644 --- a/Marks/Views/Library/LibraryKit.swift +++ b/Marks/Views/Library/LibraryKit.swift @@ -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: {}) } } diff --git a/Marks/Views/Library/LibraryListRow.swift b/Marks/Views/Library/LibraryListRow.swift new file mode 100644 index 0000000..288b409 --- /dev/null +++ b/Marks/Views/Library/LibraryListRow.swift @@ -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 } + } +}