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

Merged
admin merged 11 commits from feat/library-browse-prototype into master 2026-07-30 04:03:03 +00:00
7 changed files with 773 additions and 448 deletions
Showing only changes of commit 99add346fd - Show all commits
+32
View File
@@ -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 = "<group>"; };
18204F832C8114B6B9AB5BD8 /* IngestedSourceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSourceStore.swift; sourceTree = "<group>"; };
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryGridView.swift; sourceTree = "<group>"; };
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightBookmarkSearch.swift; sourceTree = "<group>"; };
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
@@ -141,6 +147,7 @@
64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAppIntents.swift; sourceTree = "<group>"; };
6905CD5B1864895E2F84C7DF /* TagSuggester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagSuggester.swift; sourceTree = "<group>"; };
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
7510EB352E624C7C9656EA33 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = "<group>"; };
7623601C25E481DF58371F2A /* AppIntentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentsTests.swift; sourceTree = "<group>"; };
7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IngestedSource.swift; sourceTree = "<group>"; };
@@ -153,6 +160,7 @@
9B7A85A23A13D754F6A75E4D /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSnippetViews.swift; sourceTree = "<group>"; };
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryKit.swift; sourceTree = "<group>"; };
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@@ -239,6 +247,15 @@
path = MarksTests;
sourceTree = "<group>";
};
585E3F011CBA8ECA6D1925C0 /* Library */ = {
isa = PBXGroup;
children = (
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
);
path = Library;
sourceTree = "<group>";
};
58E8E316BE3F10C5149AADC3 /* Intents */ = {
isa = PBXGroup;
children = (
@@ -286,6 +303,14 @@
path = Services;
sourceTree = "<group>";
};
7F380B7CE96F87441C28DB93 /* Prototypes */ = {
isa = PBXGroup;
children = (
7510EB352E624C7C9656EA33 /* LibraryView.swift */,
);
path = Prototypes;
sourceTree = "<group>";
};
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 = "<group>";
@@ -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 */,
+125
View File
@@ -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<Bool>,
episodePicker: Binding<Bookmark?>
) {
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<Bool>,
episodePicker: Binding<Bookmark?>
) -> some View {
modifier(PodcastSheets(
viewModel: viewModel,
showFullPlayer: showFullPlayer,
episodePicker: episodePicker
))
}
}
+18 -51
View File
@@ -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
)
}
}
+79 -24
View File
@@ -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 }
+112
View File
@@ -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() }
}
}
+398
View File
@@ -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 20252026, 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 ("/sharefo/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
}
}
// 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
}
}
+9 -373
View File
@@ -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 20252026, 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 ("/sharefo/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 {