Library design: cards + redesigned list on the bookmarks screen, tag picker sheet #9
@@ -18,9 +18,12 @@
|
|||||||
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
|
||||||
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
|
337E8272EEB3B10FD0868F76 /* IngestedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DC9BBF1006495D75DE4A232 /* IngestedSource.swift */; };
|
||||||
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.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 */; };
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||||
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
|
44E22B6D9EE5C54A06207AFD /* BookmarkSearchTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */; };
|
||||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.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 */; };
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||||
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
|
||||||
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.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 */; };
|
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
|
||||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
|
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
|
||||||
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.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 */; };
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
||||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
|
||||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
@@ -239,6 +247,15 @@
|
|||||||
path = MarksTests;
|
path = MarksTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
585E3F011CBA8ECA6D1925C0 /* Library */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1BF2B010DADCCFBC282D37F0 /* LibraryGridView.swift */,
|
||||||
|
AA5C9BFD9C0DD3876CC32B3A /* LibraryKit.swift */,
|
||||||
|
);
|
||||||
|
path = Library;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
58E8E316BE3F10C5149AADC3 /* Intents */ = {
|
58E8E316BE3F10C5149AADC3 /* Intents */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -286,6 +303,14 @@
|
|||||||
path = Services;
|
path = Services;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
7F380B7CE96F87441C28DB93 /* Prototypes */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7510EB352E624C7C9656EA33 /* LibraryView.swift */,
|
||||||
|
);
|
||||||
|
path = Prototypes;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
85E717A515682EBF67DD199A /* MarksWidget */ = {
|
85E717A515682EBF67DD199A /* MarksWidget */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -305,6 +330,7 @@
|
|||||||
children = (
|
children = (
|
||||||
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
||||||
217E6702DE1210AC38ED16D1 /* AskView.swift */,
|
217E6702DE1210AC38ED16D1 /* AskView.swift */,
|
||||||
|
240DBB87940F8D255A812EB2 /* BookmarkActions.swift */,
|
||||||
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
|
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
|
||||||
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||||
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||||
@@ -318,6 +344,8 @@
|
|||||||
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||||
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
|
CDB1DA808EE8041C4546DAAB /* SourcesView.swift */,
|
||||||
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||||
|
585E3F011CBA8ECA6D1925C0 /* Library */,
|
||||||
|
7F380B7CE96F87441C28DB93 /* Prototypes */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -540,6 +568,7 @@
|
|||||||
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
|
||||||
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
|
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
|
||||||
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||||
|
BC866F8D6189334650ADCB95 /* BookmarkActions.swift in Sources */,
|
||||||
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
|
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */,
|
||||||
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
|
FBAE1329DD9C3152FBB53AD4 /* BookmarkEntity.swift in Sources */,
|
||||||
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
|
||||||
@@ -557,6 +586,9 @@
|
|||||||
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */,
|
81F3155F05559C648FDEB36C /* IngestedSourceStore.swift in Sources */,
|
||||||
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */,
|
||||||
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.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 */,
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
212F713DCC289C48087B79AE /* Log.swift in Sources */,
|
212F713DCC289C48087B79AE /* Log.swift in Sources */,
|
||||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||||
|
|||||||
@@ -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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,61 +47,28 @@ struct BookmarkListRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
if let onEdit {
|
bookmarkMenuItems(
|
||||||
Button { onEdit() } label: {
|
bookmark: bookmark,
|
||||||
Label("Edit", systemImage: "pencil")
|
viewModel: viewModel,
|
||||||
}
|
openURL: openURL,
|
||||||
}
|
onOpen: onOpen,
|
||||||
Button { onOpen() } label: {
|
onEdit: onEdit,
|
||||||
Label("Open", systemImage: "globe")
|
onPodcast: handlePodcast
|
||||||
}
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(item: $episodePickerBookmark) { b in
|
.podcastSheets(
|
||||||
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
|
viewModel: viewModel,
|
||||||
}
|
showFullPlayer: $showFullPlayer,
|
||||||
|
episodePicker: $episodePickerBookmark
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePodcast() {
|
private func handlePodcast() {
|
||||||
let episodes = PodcastIndex.find(for: bookmark.url)
|
launchPodcast(
|
||||||
if episodes.count >= 2 {
|
for: bookmark,
|
||||||
episodePickerBookmark = bookmark
|
viewModel: viewModel,
|
||||||
} else if let ep = episodes.first {
|
showFullPlayer: $showFullPlayer,
|
||||||
viewModel.podcastPlayer.start(
|
episodePicker: $episodePickerBookmark
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,36 +60,37 @@ struct BookmarksView: View {
|
|||||||
@State private var showFullPlayer = false
|
@State private var showFullPlayer = false
|
||||||
@State private var showAsk = false
|
@State private var showAsk = false
|
||||||
@State private var readingProgress: [String: Double] = ReadingProgress.all()
|
@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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
Group {
|
||||||
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
if layout == .cards {
|
||||||
ForEach(0..<3, id: \.self) { i in
|
LibraryGridView(
|
||||||
SkeletonRow(delay: Double(i) * 0.13)
|
viewModel: viewModel,
|
||||||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
filters: $libraryFilters,
|
||||||
.listRowSeparator(.visible)
|
selection: $librarySelection,
|
||||||
}
|
onOpen: { browsingBookmark = $0 },
|
||||||
|
onEdit: { editingBookmark = $0 }
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
ForEach(viewModel.bookmarks) { bookmark in
|
bookmarkList
|
||||||
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)
|
// The large-title area draws from the content behind it, so the
|
||||||
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
// 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")
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
||||||
.navigationBarTitleDisplayMode(.large)
|
.navigationBarTitleDisplayMode(.large)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -117,6 +118,16 @@ struct BookmarksView: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
HStack(spacing: 16) {
|
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: {
|
Button { showAddBookmark = true } label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
}
|
}
|
||||||
@@ -269,6 +280,50 @@ struct BookmarksView: View {
|
|||||||
.padding(.horizontal, 16)
|
.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) {
|
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||||
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||||
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[..<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,243 +2,10 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Library prototype
|
// MARK: - Library prototype
|
||||||
//
|
//
|
||||||
// A self-contained mock of the "Library" browse screen: paper background, serif
|
// The standalone browse screen the design was worked out in: its own header,
|
||||||
// display type, monospaced chrome, and color blocks instead of thumbnails.
|
// search field, and sample data, with no view model behind it. Kept as the
|
||||||
// Nothing here is wired into the app yet — it drives its own `LibraryItem`
|
// place to iterate on the look in Previews — the shipping version is
|
||||||
// model so it can be previewed (and hosted in a scratch app) without touching
|
// LibraryGridView, and both draw their pieces from LibraryKit.
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Screen
|
// MARK: - Screen
|
||||||
|
|
||||||
@@ -377,89 +144,11 @@ struct LibraryView: View {
|
|||||||
// MARK: Filter tabs
|
// MARK: Filter tabs
|
||||||
|
|
||||||
private var filterStrip: some View {
|
private var filterStrip: some View {
|
||||||
ZStack(alignment: .bottom) {
|
LibraryTagStrip(
|
||||||
Rectangle()
|
filters: $filters,
|
||||||
.fill(Paper.rule)
|
selection: $selectedFilter,
|
||||||
.frame(height: 0.6)
|
available: unusedTags
|
||||||
|
)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Content
|
// 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
|
// MARK: - Sample data
|
||||||
|
|
||||||
extension Array where Element == LibraryItem {
|
extension Array where Element == LibraryItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user