Every screen now speaks the paper vocabulary rather than only the bookmarks screen: Search, Sources, Podcasts and the player, Settings, Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the Siri snippets, and the share extension's save card. The change is mostly a design system rather than per-screen tweaks. LibraryKit gains: - Semantic colors — secondary/tertiary/faint text, `raised` surfaces, and accent/alarm/affirm, so screens stop reaching for .secondary, systemGray, .blue, .red and .green. The three status colors come out of the palette (the swatch blue, vermilion and forest) rather than from the system set, so the design keeps spending one set of inks. - PaperType — the two voices made explicit. Prose (titles, summaries, anything a person wrote) is serif; anything the machine contributes (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping that split strict is what makes the design read as archival rather than as decoration. - paperSurface() / paperField() / paperCard() for grounds, inputs and raised blocks. - PaperEmptyState, because ContentUnavailableView can't be restyled — it draws its own bold system type and grey, which was the loudest remaining system voice once the screens moved onto the sheet. All nine usages are converted. The app also sets one accent for the system chrome it doesn't draw — tab bar, search fields, switches, swipe actions — which otherwise stayed system blue around paper screens. BookmarkRow is deleted. Once Search moved to the library row and TagBookmarksView was gone, nothing passed .classic, so the style fork in BookmarkListRow went with it. There is one bookmark row now. The share extension compiles LibraryKit directly, since its save card is a user-facing surface and should not be the one place still using system styling. Verified on device in both color schemes; screens toured with a temporary UI test harness (removed). Suite passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
362 lines
14 KiB
Swift
362 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Skeleton loading row
|
|
|
|
private struct SkeletonRow: View {
|
|
var delay: Double = 0
|
|
@State private var dimmed = false
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 11) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.fill(Paper.ink.opacity(0.09))
|
|
.frame(width: 34, height: 46)
|
|
VStack(alignment: .leading, spacing: 7) {
|
|
Capsule()
|
|
.fill(Paper.ink.opacity(0.09))
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 15)
|
|
Capsule()
|
|
.fill(Paper.ink.opacity(0.06))
|
|
.frame(width: 140, height: 12)
|
|
}
|
|
.padding(.top, 4)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 14)
|
|
.opacity(dimmed ? 0.45 : 1)
|
|
.onAppear {
|
|
withAnimation(
|
|
.easeInOut(duration: 0.9)
|
|
.repeatForever(autoreverses: true)
|
|
.delay(delay)
|
|
) { dimmed = true }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Row press ButtonStyle
|
|
|
|
struct RowPressStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
|
.opacity(configuration.isPressed ? 0.75 : 1)
|
|
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
// MARK: - BookmarksView
|
|
|
|
struct BookmarksView: View {
|
|
@Bindable var viewModel: BookmarksViewModel
|
|
let onDisconnect: () -> Void
|
|
|
|
@State private var showSettings = false
|
|
@State private var showCollections = false
|
|
@State private var showAddBookmark = false
|
|
@State private var editingBookmark: Bookmark?
|
|
@State private var browsingBookmark: Bookmark?
|
|
@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?
|
|
@State private var showTags = false
|
|
|
|
private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .list }
|
|
private var otherLayout: LibraryLayout { layout == .cards ? .list : .cards }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Both layouts speak the same design now, so the filter strip
|
|
// belongs to the screen rather than to one mode — which also
|
|
// means a tag filter can no longer go invisible when you switch.
|
|
LibraryTagStrip(
|
|
filters: $libraryFilters,
|
|
selection: $librarySelection,
|
|
onChange: applyTagFilter,
|
|
onBrowseTags: { showTags = true }
|
|
)
|
|
|
|
if layout == .cards {
|
|
LibraryGridView(
|
|
viewModel: viewModel,
|
|
onOpen: { browsingBookmark = $0 },
|
|
onEdit: { editingBookmark = $0 }
|
|
)
|
|
} else {
|
|
bookmarkList
|
|
}
|
|
}
|
|
// 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(Paper.sheet.ignoresSafeArea())
|
|
.task { librarySelection = librarySelection ?? libraryFilters.first?.id }
|
|
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarLeading) {
|
|
Menu {
|
|
Button {
|
|
showAsk = true
|
|
} label: {
|
|
Label("Ask Your Bookmarks", systemImage: "bubble.left.and.text.bubble.right")
|
|
}
|
|
Button {
|
|
Task { await viewModel.generateSmartCollections() }
|
|
showCollections = true
|
|
} label: {
|
|
Label("Smart Collections", systemImage: "sparkles")
|
|
}
|
|
Button {
|
|
Task { await viewModel.enrichAll() }
|
|
} label: {
|
|
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
|
}
|
|
} label: {
|
|
Image(systemName: "sparkles")
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
Button {
|
|
Task { await viewModel.toggleUnreadFilter() }
|
|
} label: {
|
|
Image(systemName: viewModel.unreadFilter
|
|
? "line.3.horizontal.decrease.circle.fill"
|
|
: "line.3.horizontal.decrease.circle")
|
|
.contentTransition(.symbolEffect(.replace))
|
|
}
|
|
Button { showSettings = true } label: {
|
|
Image(systemName: "gearshape")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
|
PaperEmptyState(
|
|
title: "No Bookmarks",
|
|
systemImage: "bookmark",
|
|
message: "Bookmarks you save will appear here."
|
|
)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
VStack(spacing: 8) {
|
|
if viewModel.podcastGenerator.hasActive {
|
|
podcastGeneratingBanner
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
|
|
MiniPlayerView(vm: viewModel.podcastPlayer) {
|
|
showFullPlayer = true
|
|
}
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
}
|
|
if viewModel.enrichmentProgress > 0 {
|
|
enrichmentBanner
|
|
}
|
|
}
|
|
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
|
|
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
|
|
}
|
|
.sheet(isPresented: $showSettings) {
|
|
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
|
}
|
|
.sheet(isPresented: $showCollections) {
|
|
CollectionsView(viewModel: viewModel)
|
|
}
|
|
.sheet(isPresented: $showAsk) {
|
|
AskView()
|
|
}
|
|
.sheet(isPresented: $showTags) {
|
|
TagsView(
|
|
viewModel: viewModel,
|
|
pinned: Set(libraryFilters.compactMap(\.tag)),
|
|
onPick: pinTag
|
|
)
|
|
}
|
|
.sheet(isPresented: $showAddBookmark) {
|
|
AddBookmarkView(viewModel: viewModel)
|
|
}
|
|
.sheet(item: $editingBookmark) { bookmark in
|
|
EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
|
|
}
|
|
.sheet(item: $browsingBookmark) { bookmark in
|
|
if let url = URL(string: bookmark.url) {
|
|
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
|
|
await viewModel.archive(bookmark)
|
|
}
|
|
.bookmarkOnscreen(bookmark)
|
|
}
|
|
}
|
|
.onChange(of: browsingBookmark) { _, new in
|
|
if new == nil { readingProgress = ReadingProgress.all() }
|
|
}
|
|
.sheet(isPresented: $showFullPlayer) {
|
|
PodcastPlayerView(
|
|
vm: viewModel.podcastPlayer,
|
|
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
|
|
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
|
|
claude: viewModel.claude,
|
|
stopOnDismiss: false
|
|
)
|
|
}
|
|
.refreshable {
|
|
await viewModel.load()
|
|
}
|
|
.task {
|
|
if viewModel.bookmarks.isEmpty {
|
|
await viewModel.load()
|
|
}
|
|
}
|
|
.alert("Error", isPresented: Binding(
|
|
get: { viewModel.error != nil },
|
|
set: { if !$0 { viewModel.error = nil } }
|
|
)) {
|
|
Button("OK") { viewModel.error = nil }
|
|
} message: {
|
|
Text(viewModel.error ?? "")
|
|
}
|
|
}
|
|
|
|
private var enrichmentBanner: some View {
|
|
HStack(spacing: 10) {
|
|
ProgressView(value: viewModel.enrichmentProgress)
|
|
.progressViewStyle(.linear)
|
|
.tint(Paper.ink)
|
|
Text("Adding AI summaries…")
|
|
.font(PaperType.meta)
|
|
.foregroundStyle(Paper.secondary)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 12)
|
|
.paperCard(cornerRadius: 12)
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
private var podcastGeneratingBanner: some View {
|
|
let jobs = viewModel.podcastGenerator.activeJobs
|
|
return HStack(spacing: 10) {
|
|
Image(systemName: "waveform")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundStyle(Paper.accent)
|
|
.symbolEffect(.variableColor.iterative, isActive: true)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
|
|
.font(PaperType.stamp)
|
|
.foregroundStyle(Paper.ink)
|
|
if let first = jobs.first {
|
|
Text(first.title.isEmpty ? first.label : first.title)
|
|
.font(PaperType.micro)
|
|
.foregroundStyle(Paper.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
Spacer()
|
|
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
|
|
ProgressView(value: progress)
|
|
.progressViewStyle(.linear)
|
|
.tint(Paper.accent)
|
|
.frame(width: 44)
|
|
} else {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.paperCard(cornerRadius: 12)
|
|
.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)
|
|
.listRowBackground(Paper.sheet)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
|
|
}
|
|
|
|
/// Pin a tag as a filter tab and switch to it. Choosing one that's already
|
|
/// pinned selects that tab instead of adding a duplicate.
|
|
private func pinTag(_ tag: String) {
|
|
if let existing = libraryFilters.first(where: { $0.tag == tag }) {
|
|
librarySelection = existing.id
|
|
} else {
|
|
let new = LibraryFilter(name: tag, tag: tag)
|
|
withAnimation(.spring(duration: 0.35, bounce: 0.1)) {
|
|
libraryFilters.append(new)
|
|
librarySelection = new.id
|
|
}
|
|
}
|
|
applyTagFilter()
|
|
}
|
|
|
|
/// Tag tabs filter server-side through linkding's `#tag` search syntax —
|
|
/// filtering the loaded page client-side would only ever search the most
|
|
/// recent 50 of 600+ bookmarks and quietly look empty.
|
|
private func applyTagFilter() {
|
|
let tag = libraryFilters.first { $0.id == librarySelection }?.tag
|
|
viewModel.searchQuery = tag.map { "#\($0)" } ?? ""
|
|
Task { await viewModel.search() }
|
|
}
|
|
|
|
private func toggleLayout() {
|
|
withAnimation(.spring(duration: 0.35, bounce: 0.05)) { layoutRaw = otherLayout.rawValue }
|
|
}
|
|
|
|
private func maybeLoadMore(_ bookmark: Bookmark) {
|
|
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
|
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
|
Task { await viewModel.loadMore() }
|
|
}
|
|
}
|