diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index bab44e0..e14c9f1 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -18,9 +18,12 @@ 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; + 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */; }; 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; + 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7510EB352E624C7C9656EA33 /* LibraryView.swift */; }; + 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; }; @@ -58,6 +61,7 @@ B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; }; B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; }; B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; + BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */; }; BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; }; @@ -126,9 +130,11 @@ 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = ""; }; 18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = ""; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = ""; }; + 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryGridView.swift; sourceTree = ""; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = ""; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkActions.swift; sourceTree = ""; }; 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = ""; }; 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = ""; }; 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; @@ -141,6 +147,7 @@ 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = ""; }; 6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; + 7510EB352E624C7C9656EA33 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = ""; }; 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = ""; }; @@ -153,6 +160,7 @@ 9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = ""; }; A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = ""; }; + AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryKit.swift; sourceTree = ""; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -239,6 +247,15 @@ path = MarksTests; sourceTree = ""; }; + 585E3F011CBA8ECA6D1925C0 /* Library */ = { + isa = PBXGroup; + children = ( + 1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */, + AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */, + ); + path = Library; + sourceTree = ""; + }; 58E8E316BE3F10C5149AADC3 /* Intents */ = { isa = PBXGroup; children = ( @@ -286,6 +303,14 @@ path = Services; sourceTree = ""; }; + 7F380B7CE96F87441C28DB93 /* Prototypes */ = { + isa = PBXGroup; + children = ( + 7510EB352E624C7C9656EA33 /* LibraryView.swift */, + ); + path = Prototypes; + sourceTree = ""; + }; 85E717A515682EBF67DD199A /* MarksWidget */ = { isa = PBXGroup; children = ( @@ -305,6 +330,7 @@ children = ( AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, 217E6702DE1210AC38ED16D1 /* AskView.swift */, + 240DBB87940F8D255A812EB2 /* BookmarkActions.swift */, CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, @@ -318,6 +344,8 @@ 5C29CB878BC334639E6194E2 /* SettingsView.swift */, CDB1DA808EE8041C4546DAAB /* SourcesView.swift */, B13B9F2D890C7953531AC0D2 /* TagsView.swift */, + 585E3F011CBA8ECA6D1925C0 /* Library */, + 7F380B7CE96F87441C28DB93 /* Prototypes */, ); path = Views; sourceTree = ""; @@ -540,6 +568,7 @@ B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, + BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */, 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */, FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */, CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */, @@ -557,6 +586,9 @@ 81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */, DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */, EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */, + 55CDFFAB5530D08861F85363 /* LibraryGridView.swift in Sources */, + 4153FBF538C1D3F4BC96E4C5 /* LibraryKit.swift in Sources */, + 50F3BED92EBA34F863C9F8A0 /* LibraryView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 212F713DCC289C48087B79AE /* Log.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, diff --git a/Marks/Views/BookmarkActions.swift b/Marks/Views/BookmarkActions.swift new file mode 100644 index 0000000..7832911 --- /dev/null +++ b/Marks/Views/BookmarkActions.swift @@ -0,0 +1,125 @@ +import SwiftUI + +// MARK: - Shared bookmark actions +// +// The context menu and the podcast launch path are identical whether a bookmark +// is presented as a list row or as a library card. They live here so the two +// presentations can't drift apart — the menu is a @ViewBuilder rather than a +// ViewModifier because each host already owns the sheets it needs to present, +// and a modifier would have forced a second copy of that state. + +/// Every action a bookmark offers, in the order they appear in the menu. +/// +/// `@MainActor` because the callbacks are plain (non-Sendable) UI closures — +/// without it, Swift 6 treats handing them to this function as sending them +/// across isolation domains. +@MainActor +@ViewBuilder +func bookmarkMenuItems( + bookmark: Bookmark, + viewModel: BookmarksViewModel, + openURL: OpenURLAction, + onOpen: @escaping () -> Void, + onEdit: (() -> Void)?, + onPodcast: @escaping () -> Void +) -> some View { + if let onEdit { + Button { onEdit() } label: { + Label("Edit", systemImage: "pencil") + } + } + Button { onOpen() } label: { + Label("Open", systemImage: "globe") + } + Button { + if let url = URL(string: bookmark.url) { openURL(url) } + } label: { + Label("Open in Safari", systemImage: "safari") + } + Button { onPodcast() } label: { + Label("Convert to Podcast", systemImage: "headphones") + } + Divider() + Button { + Task { await viewModel.archive(bookmark) } + } label: { + Label("Archive", systemImage: "archivebox") + } + Button(role: .destructive) { + Task { await viewModel.delete(bookmark) } + } label: { + Label("Delete", systemImage: "trash") + } +} + +/// Resolve what a "convert to podcast" tap should do. Several cached episodes +/// means the user picks; one means play it; none means generate — and +/// generating only takes over the player when it isn't already busy. +/// +/// The caller owns the sheet state because the presenting view has to. +@MainActor +func launchPodcast( + for bookmark: Bookmark, + viewModel: BookmarksViewModel, + showFullPlayer: Binding, + episodePicker: Binding +) { + let episodes = PodcastIndex.find(for: bookmark.url) + if episodes.count >= 2 { + episodePicker.wrappedValue = bookmark + } else if let ep = episodes.first { + viewModel.podcastPlayer.start( + articleUrl: ep.articleUrl, + articleTitle: ep.title ?? bookmark.displayTitle, + claude: viewModel.claude + ) + showFullPlayer.wrappedValue = true + } else if viewModel.playOrGeneratePodcast( + articleUrl: bookmark.url, + title: bookmark.displayTitle + ) { + showFullPlayer.wrappedValue = true + } +} + +/// The two sheets any bookmark presentation needs once it offers podcasts. +struct PodcastSheets: ViewModifier { + let viewModel: BookmarksViewModel + @Binding var showFullPlayer: Bool + @Binding var episodePicker: Bookmark? + + func body(content: Content) -> some View { + content + .sheet(isPresented: $showFullPlayer) { + PodcastPlayerView( + vm: viewModel.podcastPlayer, + articleUrl: viewModel.podcastPlayer.currentArticleUrl, + articleTitle: viewModel.podcastPlayer.currentArticleTitle, + claude: viewModel.claude, + stopOnDismiss: false + ) + } + .sheet(item: $episodePicker) { b in + EpisodePickerView( + bookmark: b, + vm: viewModel.podcastPlayer, + claude: viewModel.claude, + podcastGenerator: viewModel.podcastGenerator + ) + } + } +} + +extension View { + func podcastSheets( + viewModel: BookmarksViewModel, + showFullPlayer: Binding, + episodePicker: Binding + ) -> some View { + modifier(PodcastSheets( + viewModel: viewModel, + showFullPlayer: showFullPlayer, + episodePicker: episodePicker + )) + } +} diff --git a/Marks/Views/BookmarkListRow.swift b/Marks/Views/BookmarkListRow.swift index 1e19e2e..0d97c49 100644 --- a/Marks/Views/BookmarkListRow.swift +++ b/Marks/Views/BookmarkListRow.swift @@ -47,61 +47,28 @@ struct BookmarkListRow: View { } } .contextMenu { - if let onEdit { - Button { onEdit() } label: { - Label("Edit", systemImage: "pencil") - } - } - Button { onOpen() } label: { - Label("Open", systemImage: "globe") - } - Button { - if let url = URL(string: bookmark.url) { openURL(url) } - } label: { - Label("Open in Safari", systemImage: "safari") - } - Button { handlePodcast() } label: { - Label("Convert to Podcast", systemImage: "headphones") - } - Divider() - Button { - Task { await viewModel.archive(bookmark) } - } label: { - Label("Archive", systemImage: "archivebox") - } - Button(role: .destructive) { - Task { await viewModel.delete(bookmark) } - } label: { - Label("Delete", systemImage: "trash") - } - } - .sheet(isPresented: $showFullPlayer) { - PodcastPlayerView( - vm: viewModel.podcastPlayer, - articleUrl: viewModel.podcastPlayer.currentArticleUrl, - articleTitle: viewModel.podcastPlayer.currentArticleTitle, - claude: viewModel.claude, - stopOnDismiss: false + bookmarkMenuItems( + bookmark: bookmark, + viewModel: viewModel, + openURL: openURL, + onOpen: onOpen, + onEdit: onEdit, + onPodcast: handlePodcast ) } - .sheet(item: $episodePickerBookmark) { b in - EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator) - } + .podcastSheets( + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePickerBookmark + ) } private func handlePodcast() { - let episodes = PodcastIndex.find(for: bookmark.url) - if episodes.count >= 2 { - episodePickerBookmark = bookmark - } else if let ep = episodes.first { - viewModel.podcastPlayer.start( - articleUrl: ep.articleUrl, - articleTitle: ep.title ?? bookmark.displayTitle, - claude: viewModel.claude - ) - showFullPlayer = true - } else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) { - showFullPlayer = true - } + launchPodcast( + for: bookmark, + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePickerBookmark + ) } } diff --git a/Marks/Views/BookmarksView.swift b/Marks/Views/BookmarksView.swift index 100f816..caab27e 100644 --- a/Marks/Views/BookmarksView.swift +++ b/Marks/Views/BookmarksView.swift @@ -60,36 +60,37 @@ struct BookmarksView: View { @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 { - 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) - } + Group { + if layout == .cards { + LibraryGridView( + viewModel: viewModel, + filters: $libraryFilters, + selection: $librarySelection, + onOpen: { browsingBookmark = $0 }, + onEdit: { editingBookmark = $0 } + ) } else { - ForEach(viewModel.bookmarks) { bookmark in - BookmarkListRow( - bookmark: bookmark, - viewModel: viewModel, - readingProgress: readingProgress[bookmark.url] ?? 0, - onOpen: { browsingBookmark = bookmark }, - onEdit: { editingBookmark = bookmark } - ) - .onAppear { maybeLoadMore(bookmark) } - } - } - - if viewModel.isLoadingMore { - HStack { Spacer(); ProgressView(); Spacer() } - .listRowSeparator(.hidden) + bookmarkList } } - .listStyle(.plain) - .animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty) + // 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() } + } + .task { librarySelection = librarySelection ?? libraryFilters.first?.id } .navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks") .navigationBarTitleDisplayMode(.large) .toolbar { @@ -117,6 +118,16 @@ struct BookmarksView: View { } 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") } @@ -269,6 +280,50 @@ struct BookmarksView: View { .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, + onOpen: { browsingBookmark = bookmark }, + onEdit: { editingBookmark = bookmark } + ) + .onAppear { maybeLoadMore(bookmark) } + } + } + + if viewModel.isLoadingMore { + HStack { Spacer(); ProgressView(); Spacer() } + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .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() } + } + } + 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/LibraryGridView.swift b/Marks/Views/Library/LibraryGridView.swift new file mode 100644 index 0000000..9f9fc95 --- /dev/null +++ b/Marks/Views/Library/LibraryGridView.swift @@ -0,0 +1,112 @@ +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. +struct LibraryGridView: View { + @Bindable var viewModel: BookmarksViewModel + @Binding var filters: [LibraryFilter] + @Binding var selection: LibraryFilter.ID? + let onOpen: (Bookmark) -> Void + let onEdit: (Bookmark) -> Void + + @Environment(\.openURL) private var openURL + @State private var showFullPlayer = false + @State private var episodePicker: Bookmark? + + private var items: [LibraryItem] { + 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) + } + } + .scrollBounceBehavior(.basedOnSize) + } + .background(Paper.sheet) + .podcastSheets( + viewModel: viewModel, + showFullPlayer: $showFullPlayer, + episodePicker: $episodePicker + ) + } + + @ViewBuilder + private func card(_ item: LibraryItem) -> some 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) } + } + } + + /// 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 } + Task { await viewModel.loadMore() } + } +} diff --git a/Marks/Views/Library/LibraryKit.swift b/Marks/Views/Library/LibraryKit.swift new file mode 100644 index 0000000..3e7332d --- /dev/null +++ b/Marks/Views/Library/LibraryKit.swift @@ -0,0 +1,398 @@ +import SwiftUI + +// MARK: - Library design kit +// +// The vocabulary the library presentation is built from: a paper palette, the +// Bookmark -> LibraryItem projection, and the two pieces of chrome (color card, +// browser-tab filter strip) shared by the real screen and the standalone +// prototype in Views/Prototypes/LibraryView.swift. + +// MARK: Tokens + +enum Paper { + /// Dark mode is not an inversion of this palette — paper stock lit from a + /// different angle. The ground keeps the same warm cast (it is brown-black, + /// not neutral black) so the swatches sit on it the way ink sits on paper. + static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F) + static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) + static var rule: Color { ink.opacity(0.28) } + + /// Swatches lifted from the reference, each paired with a dark-mode + /// counterpart. Order matters — items hash into it. + /// + /// The dark variants are not the light ones dimmed uniformly. The pale end + /// of the palette (shell, blush, pale blue) would glare as bright slabs + /// against a dark ground, so it drops a long way; the dark end (forest, + /// navy) would vanish into the ground, so it comes *up*. Both ends + /// converge on the same mid band, which is what keeps twelve swatches + /// distinguishable from each other in either scheme. + static let swatchPairs: [(light: UInt32, dark: UInt32)] = [ + (0xFECD00, 0xD8AD10), // yellow + (0xED663F, 0xC4552F), // vermilion + (0xFE9D6B, 0xC87E53), // peach + (0xAB6A1C, 0x8B5717), // ochre + (0x033B00, 0x1F4D1B), // forest — lifted off the ground + (0x001A55, 0x1E3167), // navy — lifted off the ground + (0x115AB5, 0x1B5596), // blue + (0xD4E0E8, 0x7C8E99), // pale blue — dropped hard + (0xD4DCCF, 0x828E7C), // sage — dropped hard + (0xE0D1BB, 0x94806A), // sand — dropped hard + (0xF5D1BC, 0xA47A63), // blush — dropped hard + (0xFDEDE0, 0x8E8175), // shell — dropped hard + ] + + static func swatch(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return dynamic(light: pair.light, dark: pair.dark) + } + + /// Text that stays legible on `swatch(index)`. Resolved per scheme rather + /// than once, because a swatch can be light in one scheme and mid in the + /// other — a single luminance test would get one of them wrong. + static func inkOn(_ index: Int) -> Color { + let pair = swatchPairs[index % swatchPairs.count] + return Color(uiColor: UIColor { traits in + let dark = traits.userInterfaceStyle == .dark + let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55 + let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF) + return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1) + }) + } + + static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(uiColor: UIColor { traits in + UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) + }) + } + + static func luminance(of hex: UInt32) -> Double { + let r = Double((hex >> 16) & 0xFF) / 255 + let g = Double((hex >> 8) & 0xFF) / 255 + let b = Double(hex & 0xFF) / 255 + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } +} + +extension UIColor { + convenience init(rgb: UInt32) { + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } +} + +// MARK: Model + +struct LibraryItem: Identifiable, Hashable { + let id: Int + var title: String + /// The bottom-left mono stamp. The reference used a publication year; real + /// linkding data is all 2025–2026, so the year carries no signal and the + /// domain takes the slot instead. + var stamp: String + var source: String + var tags: [String] + /// nil = derive from the primary tag, so an untouched library still reads + /// as color-coded by subject rather than as noise. + var colorIndex: Int? + /// The page never gave us a title. Rendering the raw URL inside curly + /// quotes reads as a quotation that isn't one, so these skip the quotes. + var isUntitled = false + + var swatch: Color { Paper.swatch(resolvedIndex) } + var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) } + + /// What a card shows. Cards get one clause; the list gets the whole title. + /// Real titles are overwhelmingly "name: what it does" — at card width the + /// name is the identifier and the blurb is filler, so past 60 characters we + /// keep the name and let the list carry the rest. + var cardTitle: String { + if title.count > 60, + let colon = title.range(of: ": "), + title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 { + return String(title[.. 56 { + return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…" + } + return title + } + + /// Cards quote the title the way the reference does — except when there is + /// no real title to quote. + var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" } + var listDisplay: String { isUntitled ? title : "“\(title)”" } + + private var resolvedIndex: Int { + if let colorIndex { return colorIndex % Paper.swatchPairs.count } + // Tag first: 128 of 300 real bookmarks are github.com, so hashing the + // domain would paint half the library one color. Tags spread wider + // (top tag is 32 items). Untagged falls back to source. + let seed = tags.first ?? source + return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) }) + % Paper.swatchPairs.count + } +} + +// MARK: Bookmark -> LibraryItem + +extension LibraryItem { + init(bookmark: Bookmark) { + self.id = bookmark.id + self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "") + self.tags = bookmark.tagNames + self.colorIndex = nil + + // `displayTitle` falls back to the raw URL when linkding scraped no + // title — 15 of 300 real bookmarks. Show the domain instead and hand + // the stamp slot the path, so both slots still say something. + let raw = bookmark.displayTitle + if raw.hasPrefix("http") { + self.isUntitled = true + self.title = self.source + self.stamp = Self.path(of: bookmark.url) + } else { + self.title = Self.clean(raw) + self.stamp = Self.registered(self.source) + } + } + + /// Cards give the stamp one 11pt monospaced line, roughly 14 characters. + /// 92 of 300 real domains are longer than that, so drop the subdomain: + /// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full. + static func registered(_ host: String) -> String { + let parts = host.split(separator: ".") + guard parts.count > 2 else { return host } + // Two-part public suffixes (.co.uk, .com.au) need one more label. + let secondLevel: Set = ["co", "com", "net", "org", "ac", "gov", "edu"] + let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2 + return parts.suffix(keep).joined(separator: ".") + } + + /// Returned whole: the stamp label middle-truncates, and pre-clipping here + /// too would elide it twice ("/share…fo/194…"). + static func path(of url: String) -> String { + guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" } + return p + } + + /// Linkding stores whatever the page's said, which for the bulk of a + /// real library means "GitHub - owner/repo: <the entire README blurb>". + /// Median real title is 66 chars and the 90th percentile is 159 — the + /// reference design assumed ~30. Strip the boilerplate, then clamp. + static func clean(_ raw: String) -> String { + var t = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // "GitHub - owner/repo: blurb" -> "repo: blurb" + if t.hasPrefix("GitHub - ") { + t = String(t.dropFirst("GitHub - ".count)) + if let slash = t.firstIndex(of: "/"), + let colon = t.firstIndex(of: ":"), slash < colon { + t = String(t[t.index(after: slash)...]) + } + } + + // Trailing site furniture: "Title | Publisher", "Title - Latent.Space". + // Only strip a short trailing fragment off a title with something left + // over, so hyphenated titles survive. + for sep in [" | ", " · ", " — ", " – ", " - "] { + if let r = t.range(of: sep, options: .backwards), + t.distance(from: r.upperBound, to: t.endIndex) < 24, + t.distance(from: t.startIndex, to: r.lowerBound) > 12 { + t = String(t[..<r.lowerBound]) + } + } + + // Generous clamp: this is the list-mode title. `cardTitle` cuts harder. + if t.count > 120 { + t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…" + } + return t.isEmpty ? "Untitled" : t + } +} + +// MARK: Filters + +struct LibraryFilter: Identifiable, Hashable { + let id = UUID() + var name: String + /// nil = "everything", the tab you can't close. + var tag: String? +} + +/// Two modes, not three. A 4-column grid was in the first pass and died on real +/// data: with a median title of 66 characters every tile truncated mid-word, so +/// it read as a wall of clipped text rather than as color. +enum LibraryLayout: String, CaseIterable, Identifiable { + case cards, list + var id: String { rawValue } + + var symbol: String { + switch self { + case .cards: "rectangle.inset.filled" + case .list: "line.3.horizontal" + } + } +} + +// MARK: - Card + +struct LibraryCard: View { + let item: LibraryItem + @State private var pressed = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(item.cardDisplay) + .font(.system(size: 11, design: .serif)) + .foregroundStyle(item.inkOnSwatch) + .multilineTextAlignment(.leading) + .lineLimit(5) + .minimumScaleFactor(0.85) + Spacer(minLength: 4) + Text(item.stamp) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(item.inkOnSwatch) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(9) + .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: {}) + } +} + +// MARK: - Filter strip + +/// The browser-tab strip of saved filters. Tabs sit on a hairline that the live +/// tab erases, which is what sells the metaphor. +struct LibraryTagStrip: View { + @Binding var filters: [LibraryFilter] + @Binding var selection: LibraryFilter.ID? + /// Tags available to pin that aren't already open. + let available: [String] + var onChange: () -> Void = {} + + @Namespace private var strip + + var body: some View { + ZStack(alignment: .bottom) { + Rectangle() + .fill(Paper.rule) + .frame(height: 0.6) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(filters) { filter in + tab(filter) + } + Menu { + ForEach(available, id: \.self) { tag in + Button(tag) { add(tag: tag) } + } + if !filters.contains(where: { $0.tag == nil }) { + Divider() + Button("Everything") { add(tag: nil) } + } + } label: { + Image(systemName: "plus") + .font(.system(size: 12, weight: .light)) + .foregroundStyle(Paper.ink.opacity(0.6)) + .frame(width: 38, height: 30) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 18) + } + } + .frame(height: 30) + } + + private func tab(_ filter: LibraryFilter) -> some View { + let active = filter.id == selection + return HStack(spacing: 7) { + Text(filter.name) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) + .lineLimit(1) + if filters.count > 1 { + Button { + withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .medium)) + .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 11) + .frame(height: 30) + .background(alignment: .bottom) { + if active { + // Paper fill sits 0.6pt proud so it erases the strip rule + // beneath the live tab — the browser-tab read. + UnevenRoundedRectangle( + topLeadingRadius: 6, bottomLeadingRadius: 0, + bottomTrailingRadius: 0, topTrailingRadius: 6 + ) + .fill(Paper.sheet) + .overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6)) + .padding(.bottom, -0.6) + .matchedGeometryEffect(id: "tab", in: strip) + } + } + .contentShape(.rect) + .onTapGesture { + guard filter.id != selection else { return } + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selection = filter.id } + onChange() + } + } + + private func add(tag: String?) { + let new = LibraryFilter(name: tag ?? "Everything", tag: tag) + withAnimation(.spring(duration: 0.35, bounce: 0.1)) { + filters.append(new) + selection = new.id + } + onChange() + } + + private func close(_ filter: LibraryFilter) { + filters.removeAll { $0.id == filter.id } + if selection == filter.id { + selection = filters.first?.id + onChange() + } + } +} + +/// Open path: up the left edge, across the top, down the right — no bottom +/// stroke, so the tab merges into the page. +struct TabOutline: Shape { + func path(in rect: CGRect) -> Path { + let r: CGFloat = 6 + var p = Path() + p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) + p.addQuadCurve( + to: CGPoint(x: rect.minX + r, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) + p.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + r), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + return p + } +} diff --git a/Marks/Views/Prototypes/LibraryView.swift b/Marks/Views/Prototypes/LibraryView.swift index bd4a759..b5f03a9 100644 --- a/Marks/Views/Prototypes/LibraryView.swift +++ b/Marks/Views/Prototypes/LibraryView.swift @@ -2,243 +2,10 @@ import SwiftUI // MARK: - Library prototype // -// A self-contained mock of the "Library" browse screen: paper background, serif -// display type, monospaced chrome, and color blocks instead of thumbnails. -// Nothing here is wired into the app yet — it drives its own `LibraryItem` -// model so it can be previewed (and hosted in a scratch app) without touching -// BookmarksView. Mapping `Bookmark` -> `LibraryItem` is the last step, not the -// first: the point is to see whether color-as-index survives real data. - -// MARK: Tokens - -enum Paper { - /// Dark mode is not an inversion of this palette — paper stock lit from a - /// different angle. The ground keeps the same warm cast (it is brown-black, - /// not neutral black) so the swatches sit on it the way ink sits on paper. - static let sheet = dynamic(light: 0xF8F5EF, dark: 0x15130F) - static let ink = dynamic(light: 0x14110C, dark: 0xF1ECE1) - static var rule: Color { ink.opacity(0.28) } - - /// Swatches lifted from the reference, each paired with a dark-mode - /// counterpart. Order matters — items hash into it. - /// - /// The dark variants are not the light ones dimmed uniformly. The pale end - /// of the palette (shell, blush, pale blue) would glare as bright slabs - /// against a dark ground, so it drops a long way; the dark end (forest, - /// navy) would vanish into the ground, so it comes *up*. Both ends - /// converge on the same mid band, which is what keeps twelve swatches - /// distinguishable from each other in either scheme. - static let swatchPairs: [(light: UInt32, dark: UInt32)] = [ - (0xFECD00, 0xD8AD10), // yellow - (0xED663F, 0xC4552F), // vermilion - (0xFE9D6B, 0xC87E53), // peach - (0xAB6A1C, 0x8B5717), // ochre - (0x033B00, 0x1F4D1B), // forest — lifted off the ground - (0x001A55, 0x1E3167), // navy — lifted off the ground - (0x115AB5, 0x1B5596), // blue - (0xD4E0E8, 0x7C8E99), // pale blue — dropped hard - (0xD4DCCF, 0x828E7C), // sage — dropped hard - (0xE0D1BB, 0x94806A), // sand — dropped hard - (0xF5D1BC, 0xA47A63), // blush — dropped hard - (0xFDEDE0, 0x8E8175), // shell — dropped hard - ] - - static func swatch(_ index: Int) -> Color { - let pair = swatchPairs[index % swatchPairs.count] - return dynamic(light: pair.light, dark: pair.dark) - } - - /// Text that stays legible on `swatch(index)`. Resolved per scheme rather - /// than once, because a swatch can be light in one scheme and mid in the - /// other — a single luminance test would get one of them wrong. - static func inkOn(_ index: Int) -> Color { - let pair = swatchPairs[index % swatchPairs.count] - return Color(uiColor: UIColor { traits in - let dark = traits.userInterfaceStyle == .dark - let onLight = luminance(of: dark ? pair.dark : pair.light) > 0.55 - let text: UInt32 = onLight ? 0x14110C : (dark ? 0xF1ECE1 : 0xF8F5EF) - return UIColor(rgb: text).withAlphaComponent(onLight ? 0.86 : 1) - }) - } - - static func dynamic(light: UInt32, dark: UInt32) -> Color { - Color(uiColor: UIColor { traits in - UIColor(rgb: traits.userInterfaceStyle == .dark ? dark : light) - }) - } - - static func luminance(of hex: UInt32) -> Double { - let r = Double((hex >> 16) & 0xFF) / 255 - let g = Double((hex >> 8) & 0xFF) / 255 - let b = Double(hex & 0xFF) / 255 - return 0.2126 * r + 0.7152 * g + 0.0722 * b - } -} - -extension UIColor { - convenience init(rgb: UInt32) { - self.init( - red: CGFloat((rgb >> 16) & 0xFF) / 255, - green: CGFloat((rgb >> 8) & 0xFF) / 255, - blue: CGFloat(rgb & 0xFF) / 255, - alpha: 1 - ) - } -} - -// MARK: Model - -struct LibraryItem: Identifiable, Hashable { - let id: Int - var title: String - /// The bottom-left mono stamp. The reference used a publication year; real - /// linkding data is all 2025–2026, so the year carries no signal and the - /// domain takes the slot instead. - var stamp: String - var source: String - var tags: [String] - /// nil = derive from the primary tag, so an untouched library still reads - /// as color-coded by subject rather than as noise. - var colorIndex: Int? - /// The page never gave us a title. Rendering the raw URL inside curly - /// quotes reads as a quotation that isn't one, so these skip the quotes. - var isUntitled = false - - var swatch: Color { Paper.swatch(resolvedIndex) } - var inkOnSwatch: Color { Paper.inkOn(resolvedIndex) } - - /// What a card shows. Cards get one clause; the list gets the whole title. - /// Real titles are overwhelmingly "name: what it does" — at card width the - /// name is the identifier and the blurb is filler, so past 60 characters we - /// keep the name and let the list carry the rest. - var cardTitle: String { - if title.count > 60, - let colon = title.range(of: ": "), - title.distance(from: title.startIndex, to: colon.lowerBound) <= 40 { - return String(title[..<colon.lowerBound]) - } - if title.count > 56 { - return String(title.prefix(56)).trimmingCharacters(in: .whitespaces) + "…" - } - return title - } - - /// Cards quote the title the way the reference does — except when there is - /// no real title to quote. - var cardDisplay: String { isUntitled ? cardTitle : "“\(cardTitle)”" } - var listDisplay: String { isUntitled ? title : "“\(title)”" } - - private var resolvedIndex: Int { - if let colorIndex { return colorIndex % Paper.swatchPairs.count } - // Tag first: 128 of 300 real bookmarks are github.com, so hashing the - // domain would paint half the library one color. Tags spread wider - // (top tag is 32 items). Untagged falls back to source. - let seed = tags.first ?? source - return abs(seed.unicodeScalars.reduce(5381) { ($0 &* 33) &+ Int($1.value) }) - % Paper.swatchPairs.count - } -} - -// MARK: Bookmark -> LibraryItem - -extension LibraryItem { - init(bookmark: Bookmark) { - self.id = bookmark.id - self.source = bookmark.domain.replacingOccurrences(of: "www.", with: "") - self.tags = bookmark.tagNames - self.colorIndex = nil - - // `displayTitle` falls back to the raw URL when linkding scraped no - // title — 15 of 300 real bookmarks. Show the domain instead and hand - // the stamp slot the path, so both slots still say something. - let raw = bookmark.displayTitle - if raw.hasPrefix("http") { - self.isUntitled = true - self.title = self.source - self.stamp = Self.path(of: bookmark.url) - } else { - self.title = Self.clean(raw) - self.stamp = Self.registered(self.source) - } - } - - /// Cards give the stamp one 11pt monospaced line, roughly 14 characters. - /// 92 of 300 real domains are longer than that, so drop the subdomain: - /// `toolkit.artlist.io` -> `artlist.io`. The list still shows it in full. - static func registered(_ host: String) -> String { - let parts = host.split(separator: ".") - guard parts.count > 2 else { return host } - // Two-part public suffixes (.co.uk, .com.au) need one more label. - let secondLevel: Set<String> = ["co", "com", "net", "org", "ac", "gov", "edu"] - let keep = secondLevel.contains(String(parts[parts.count - 2])) ? 3 : 2 - return parts.suffix(keep).joined(separator: ".") - } - - /// Returned whole: the stamp label middle-truncates, and pre-clipping here - /// too would elide it twice ("/share…fo/194…"). - static func path(of url: String) -> String { - guard let p = URL(string: url)?.path, p != "/", !p.isEmpty else { return "—" } - return p - } - - /// Linkding stores whatever the page's <title> said, which for the bulk of a - /// real library means "GitHub - owner/repo: <the entire README blurb>". - /// Median real title is 66 chars and the 90th percentile is 159 — the - /// reference design assumed ~30. Strip the boilerplate, then clamp. - static func clean(_ raw: String) -> String { - var t = raw.trimmingCharacters(in: .whitespacesAndNewlines) - - // "GitHub - owner/repo: blurb" -> "repo: blurb" - if t.hasPrefix("GitHub - ") { - t = String(t.dropFirst("GitHub - ".count)) - if let slash = t.firstIndex(of: "/"), - let colon = t.firstIndex(of: ":"), slash < colon { - t = String(t[t.index(after: slash)...]) - } - } - - // Trailing site furniture: "Title | Publisher", "Title - Latent.Space". - // Only strip a short trailing fragment off a title with something left - // over, so hyphenated titles survive. - for sep in [" | ", " · ", " — ", " – ", " - "] { - if let r = t.range(of: sep, options: .backwards), - t.distance(from: r.upperBound, to: t.endIndex) < 24, - t.distance(from: t.startIndex, to: r.lowerBound) > 12 { - t = String(t[..<r.lowerBound]) - } - } - - // Generous clamp: this is the list-mode title. `cardTitle` cuts harder. - if t.count > 120 { - t = String(t.prefix(120)).trimmingCharacters(in: .whitespaces) + "…" - } - return t.isEmpty ? "Untitled" : t - } -} - -struct LibraryFilter: Identifiable, Hashable { - let id = UUID() - var name: String - /// Empty = "everything", the tab you can't close. - var tag: String? -} - -// MARK: Display mode - -/// Two modes, not three. A 4-column grid was in the first pass and died on real -/// data: with a median title of 66 characters every tile truncated mid-word, so -/// it read as a wall of clipped text rather than as color. -enum LibraryLayout: String, CaseIterable, Identifiable { - case cards, list - var id: String { rawValue } - - var symbol: String { - switch self { - case .cards: "rectangle.inset.filled" - case .list: "line.3.horizontal" - } - } -} +// The standalone browse screen the design was worked out in: its own header, +// search field, and sample data, with no view model behind it. Kept as the +// place to iterate on the look in Previews — the shipping version is +// LibraryGridView, and both draw their pieces from LibraryKit. // MARK: - Screen @@ -377,89 +144,11 @@ struct LibraryView: View { // MARK: Filter tabs private var filterStrip: some View { - ZStack(alignment: .bottom) { - Rectangle() - .fill(Paper.rule) - .frame(height: 0.6) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(filters) { filter in - tab(filter) - } - Menu { - ForEach(unusedTags, id: \.self) { tag in - Button(tag) { addFilter(tag: tag) } - } - if !filters.contains(where: { $0.tag == nil }) { - Divider() - Button("Everything") { addFilter(tag: nil) } - } - } label: { - Image(systemName: "plus") - .font(.system(size: 12, weight: .light)) - .foregroundStyle(Paper.ink.opacity(0.6)) - .frame(width: 38, height: 30) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 18) - } - } - .frame(height: 30) - } - - private func tab(_ filter: LibraryFilter) -> some View { - let active = filter.id == selectedFilter - return HStack(spacing: 7) { - Text(filter.name) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(active ? Paper.ink : Paper.ink.opacity(0.45)) - .lineLimit(1) - if filters.count > 1 { - Button { - withAnimation(.spring(duration: 0.3, bounce: 0)) { close(filter) } - } label: { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .medium)) - .foregroundStyle(Paper.ink.opacity(active ? 0.5 : 0.25)) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 11) - .frame(height: 30) - .background(alignment: .bottom) { - if active { - // Paper fill sits 0.6pt proud so it erases the strip rule - // beneath the live tab — the browser-tab read. - UnevenRoundedRectangle( - topLeadingRadius: 6, bottomLeadingRadius: 0, - bottomTrailingRadius: 0, topTrailingRadius: 6 - ) - .fill(Paper.sheet) - .overlay(TabOutline().stroke(Paper.rule, lineWidth: 0.6)) - .padding(.bottom, -0.6) - .matchedGeometryEffect(id: "tab", in: blocks) - } - } - .contentShape(.rect) - .onTapGesture { - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { selectedFilter = filter.id } - } - } - - private func addFilter(tag: String?) { - let new = LibraryFilter(name: tag?.capitalized ?? "Everything", tag: tag) - withAnimation(.spring(duration: 0.35, bounce: 0.1)) { - filters.append(new) - selectedFilter = new.id - } - } - - private func close(_ filter: LibraryFilter) { - filters.removeAll { $0.id == filter.id } - if selectedFilter == filter.id { selectedFilter = filters.first?.id } + LibraryTagStrip( + filters: $filters, + selection: $selectedFilter, + available: unusedTags + ) } // MARK: Content @@ -532,59 +221,6 @@ struct LibraryView: View { } } -// MARK: - Card - -private struct LibraryCard: View { - let item: LibraryItem - @State private var pressed = false - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(item.cardDisplay) - .font(.system(size: 11, design: .serif)) - .foregroundStyle(item.inkOnSwatch) - .multilineTextAlignment(.leading) - .lineLimit(5) - .minimumScaleFactor(0.85) - Spacer(minLength: 4) - Text(item.stamp) - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(item.inkOnSwatch) - .lineLimit(1) - .truncationMode(.middle) - } - .padding(9) - .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: {}) - } -} - -/// Open path: up the left edge, across the top, down the right — no bottom -/// stroke, so the tab merges into the page. -private struct TabOutline: Shape { - func path(in rect: CGRect) -> Path { - let r: CGFloat = 6 - var p = Path() - p.move(to: CGPoint(x: rect.minX, y: rect.maxY)) - p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r)) - p.addQuadCurve( - to: CGPoint(x: rect.minX + r, y: rect.minY), - control: CGPoint(x: rect.minX, y: rect.minY) - ) - p.addLine(to: CGPoint(x: rect.maxX - r, y: rect.minY)) - p.addQuadCurve( - to: CGPoint(x: rect.maxX, y: rect.minY + r), - control: CGPoint(x: rect.maxX, y: rect.minY) - ) - p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - return p - } -} - // MARK: - Sample data extension Array where Element == LibraryItem {