diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 437b7b5..18090e6 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -299,15 +299,15 @@ private struct DuplicateDocumentResponse: Decodable { let documents: [OutlineDocument] } -private struct ListStarsResponse: Decodable { - let stars: [OutlineStar] -} - private struct PinsListPayload: Decodable { let pins: [OutlinePin] let documents: [OutlineDocument] } +private struct ListStarsResponse: Decodable { + let stars: [OutlineStar] +} + private struct StarIDParams: Encodable { let id: String } diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 1013033..593fe2f 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -9,6 +9,7 @@ import OutlineKit /// client-side rather than `collections.documents`. struct CollectionDocumentsOutline: View { let apiClient: OutlineAPIClient + let collection: OutlineCollection @State private var viewModel: DocumentsViewModel let sortOption: SidebarSortOption let refreshToken: Int @@ -24,6 +25,11 @@ struct CollectionDocumentsOutline: View { /// toolbar render the real hierarchy instead of just the leaf title. let onSelectDocument: ([OutlineDocument]) -> Void + /// Loaded once per collection (`pins.list` is collection-scoped) rather + /// than per-row — a per-row `pins.list`/lookup would be an N+1 call for + /// every document in the tree. + @State private var pinsByDocumentID: [String: OutlinePin] = [:] + private var tree: [DocumentNode] { buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) } @@ -38,6 +44,7 @@ struct CollectionDocumentsOutline: View { onSelectDocument: @escaping ([OutlineDocument]) -> Void ) { self.apiClient = apiClient + self.collection = collection _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) self.sortOption = sortOption self.refreshToken = refreshToken @@ -65,8 +72,10 @@ struct CollectionDocumentsOutline: View { depth: 0, ancestors: [], selectedDocumentID: selectedDocumentID, + pinsByDocumentID: pinsByDocumentID, onSelectDocument: onSelectDocument, - onDocumentsChanged: { await viewModel.load() } + onDocumentsChanged: { await viewModel.load() }, + onPinsChanged: { await loadPins() } ) } } @@ -74,7 +83,15 @@ struct CollectionDocumentsOutline: View { // Combined into one identity rather than two separate `.task(id:)` // modifiers — each of those fires once unconditionally on first // appear, so two of them would double the initial load. - .task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() } + .task(id: "\(refreshToken)-\(externalRefreshToken)") { + await viewModel.load() + await loadPins() + } + } + + private func loadPins() async { + guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return } + pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) }) } } @@ -93,8 +110,10 @@ private struct DocumentNodeRow: View { /// Chain from root down to (not including) this node. let ancestors: [OutlineDocument] let selectedDocumentID: String? + let pinsByDocumentID: [String: OutlinePin] let onSelectDocument: ([OutlineDocument]) -> Void let onDocumentsChanged: () async -> Void + let onPinsChanged: () async -> Void @State private var isExpanded = false @@ -185,8 +204,10 @@ private struct DocumentNodeRow: View { depth: depth + 1, ancestors: ancestors + [node.document], selectedDocumentID: selectedDocumentID, + pinsByDocumentID: pinsByDocumentID, onSelectDocument: onSelectDocument, - onDocumentsChanged: onDocumentsChanged + onDocumentsChanged: onDocumentsChanged, + onPinsChanged: onPinsChanged ) } } @@ -277,7 +298,11 @@ private struct DocumentNodeRow: View { Button(starStore.isStarred(documentId: node.document.id) ? "Unstar" : "Star") { Task { await star() } } - // No `subscriptions.*` endpoint in the API — nothing to back this with. + // subscriptions.* does exist and works (confirmed against a live + // server via the reader's menu) — not shown here because + // subscriptions.list is per-document, so reflecting accurate + // per-row state for every document in the tree would mean an N+1 + // call storm. Use the reader's ⋯ menu instead. Button("Unsubscribe") {} .disabled(true) @@ -323,12 +348,11 @@ private struct DocumentNodeRow: View { Button("New Document") { isShowingNewDocumentSheet = true } - // `pins.*` is real and works (see `OutlinePin`), but "Pin to - // Collection" (non-nil `collectionId`) is distinct from the reader - // toolbar's "Pin to Home" and needs its own per-row state/N+1 - // consideration for a whole tree — not wired here yet. - Button("Pin") {} - .disabled(true) + // Scoped to this collection (`collection.id`) — "Pin to Collection", + // distinct from the reader toolbar's "Pin to Home" (collectionId: nil). + Button(pinsByDocumentID[node.document.id] != nil ? "Unpin from Collection" : "Pin to Collection") { + Task { await togglePin() } + } Divider() @@ -372,6 +396,19 @@ private struct DocumentNodeRow: View { } } + private func togglePin() async { + do { + if let pin = pinsByDocumentID[node.document.id] { + try await apiClient.deletePin(id: pin.id) + } else { + _ = try await apiClient.createPin(CreatePinRequest(documentId: node.document.id, collectionId: node.document.collectionId)) + } + await onPinsChanged() + } catch { + actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.") + } + } + private func rename() async { do { _ = try await apiClient.updateDocument(UpdateDocumentRequest(id: node.document.id, title: renameText))