diff --git a/Outpost/Features/Collections/CollectionOverviewContent.swift b/Outpost/Features/Collections/CollectionOverviewContent.swift new file mode 100644 index 0000000..ad2fcbf --- /dev/null +++ b/Outpost/Features/Collections/CollectionOverviewContent.swift @@ -0,0 +1,29 @@ +#if os(macOS) +import SwiftUI +import MarkdownEngine +import OutlineKit + +/// Read-only for now — document/overview editing isn't wired up yet. Uses +/// `NativeTextViewWrapper`'s own `isEditable: false`, not `.disabled(true)`: +/// the latter blocks ALL interaction including link clicks, since +/// `isSelectable` and link-opening both still need to work. +struct CollectionOverviewContent: View { + @State private var markdown: String + + init(collection: OutlineCollection) { + _markdown = State(initialValue: collection.description ?? "") + } + + var body: some View { + ScrollView { + NativeTextViewWrapper( + text: $markdown, + configuration: .init(heightBehavior: .fitsContent), + isEditable: false + ) + .padding() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift new file mode 100644 index 0000000..a4eaf78 --- /dev/null +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -0,0 +1,153 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +struct CollectionOverviewView: View { + let collection: OutlineCollection + @State private var viewModel: DocumentsViewModel + @State private var selectedTab: CollectionTab = .overview + + // Contextual search — "search what you're looking at" — scoped to this + // collection's documents. The field itself lives on the parent + // NavigationStack (so it survives pushing into a document); this owns + // only the resulting search state. + @State private var searchViewModel: DocumentTitleSearchViewModel + @Binding var searchQuery: String + + private var trimmedSearchQuery: String { + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + } + + init(apiClient: OutlineAPIClient, collection: OutlineCollection, searchQuery: Binding) { + self.collection = collection + _viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection)) + _searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id)) + _searchQuery = searchQuery + } + + private var sortedDocuments: [OutlineDocument] { + selectedTab.sorted(viewModel.documents) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // No in-content header — the collection's icon/title live in the + // window toolbar now (via `ContentView_macOS`), so this doesn't + // duplicate it directly below. + if trimmedSearchQuery.isEmpty { + tabBar + } + + Divider() + + // All branches get the same frame so switching tabs/search doesn't + // visibly resize/jump the content area to its own ideal size. + Group { + if !trimmedSearchQuery.isEmpty { + searchResultsList + } else if selectedTab == .overview { + CollectionOverviewContent(collection: collection) + } else { + documentList + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + // No `.navigationTitle` here — it renders its own native title bubble, + // duplicating the leading toolbar item `ContentView_macOS` already + // shows (which also carries the collection > document hierarchy). + .task { await viewModel.load() } + .task(id: trimmedSearchQuery) { + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled else { return } + await searchViewModel.search(query: trimmedSearchQuery) + } + } + + // Spans the full window width, centered, directly under the toolbar — + // not a scrolling, left-aligned row anymore. + private var tabBar: some View { + HStack(spacing: 4) { + Spacer(minLength: 0) + ForEach(CollectionTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + Text(tab.label) + .font(.callout.weight(selectedTab == tab ? .semibold : .regular)) + .foregroundStyle(selectedTab == tab ? Color.primary : Color.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + } + Spacer(minLength: 0) + } + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + } + + @ViewBuilder + private var documentList: some View { + if viewModel.isLoading && viewModel.documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, viewModel.documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.load() } + } + } + } else if viewModel.documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Documents in \(collection.name) will appear here.") + ) + } else { + List(sortedDocuments) { document in + NavigationLink(value: document) { + DocumentRowView(document: document) + } + } + } + } + + @ViewBuilder + private var searchResultsList: some View { + if searchViewModel.isSearching && searchViewModel.results.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = searchViewModel.errorMessage { + ContentUnavailableView { + Label("Search Failed", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + } else if searchViewModel.results.isEmpty { + ContentUnavailableView.search(text: trimmedSearchQuery) + } else { + List(searchViewModel.results) { result in + NavigationLink(value: result.document) { + VStack(alignment: .leading, spacing: 4) { + DocumentRowView(document: result.document) + Text(result.context) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + .padding(.vertical, 2) + } + } + } + } +} +#endif diff --git a/Outpost/Features/Collections/CollectionTab.swift b/Outpost/Features/Collections/CollectionTab.swift new file mode 100644 index 0000000..71f7a0d --- /dev/null +++ b/Outpost/Features/Collections/CollectionTab.swift @@ -0,0 +1,45 @@ +import Foundation +import OutlineKit + +/// Mirrors Outline's own top tab bar on a collection's page. +enum CollectionTab: String, CaseIterable, Identifiable { + case overview + case documents + case popular + case recentlyUpdated + case recentlyPublished + case leastRecentlyUpdated + case alphabetical + + var id: String { rawValue } + + var label: String { + switch self { + case .overview: return "Overview" + case .documents: return "Documents" + case .popular: return "Popular" + case .recentlyUpdated: return "Recently Updated" + case .recentlyPublished: return "Recently Published" + case .leastRecentlyUpdated: return "Least Recently Updated" + case .alphabetical: return "A-Z" + } + } + + /// `.popular` has no real ranking signal yet — it would need view-count / + /// analytics data the REST layer doesn't expose — so it falls back to the + /// same order as `.documents` rather than fabricating a ranking. + func sorted(_ documents: [OutlineDocument]) -> [OutlineDocument] { + switch self { + case .overview, .documents, .popular: + return documents + case .recentlyUpdated: + return documents.sorted { $0.updatedAt > $1.updatedAt } + case .recentlyPublished: + return documents.sorted { ($0.publishedAt ?? $0.createdAt) > ($1.publishedAt ?? $1.createdAt) } + case .leastRecentlyUpdated: + return documents.sorted { $0.updatedAt < $1.updatedAt } + case .alphabetical: + return documents.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } + } + } +} diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift new file mode 100644 index 0000000..3a7fd31 --- /dev/null +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -0,0 +1,47 @@ +#if os(macOS) +import SwiftUI +import MarkdownEngine +import OutlineKit + +/// Read-only (`isEditable: false`), matching the Overview tab — document editing +/// isn't wired up yet, but link clicks and text selection still work. +struct DocumentReaderView: View { + @State private var viewModel: DocumentReaderViewModel + + init(apiClient: OutlineAPIClient, document: OutlineDocument) { + _viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + // No in-content header — the document's icon/title live in the + // window toolbar now (via `ContentView_macOS`). + if viewModel.isLoading && viewModel.text.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + } else if let errorMessage = viewModel.errorMessage { + ContentUnavailableView { + Label("Couldn't Load Document", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.loadFullContent() } + } + } + } else { + NativeTextViewWrapper( + text: $viewModel.text, + configuration: .init(heightBehavior: .fitsContent), + isEditable: false + ) + } + } + .padding() + } + // No `.navigationTitle` here either — same reason as CollectionOverviewView. + .task { await viewModel.loadFullContent() } + } +} +#endif diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift new file mode 100644 index 0000000..a92b37a --- /dev/null +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -0,0 +1,41 @@ +import Foundation +import Observation +import OutlineKit + +@MainActor +@Observable +final class DocumentReaderViewModel { + var title: String + var emoji: String? + var text: String + var isLoading = false + var errorMessage: String? + + private let apiClient: OutlineAPIClient + private let documentId: String + + init(apiClient: OutlineAPIClient, document: OutlineDocument) { + self.apiClient = apiClient + self.documentId = document.id + self.title = document.title + self.emoji = document.emoji + self.text = document.text + } + + /// The list endpoint's copy of a document isn't guaranteed to be the full, + /// current body — always re-fetch via `documents.info` when actually opened. + func loadFullContent() async { + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let full = try await apiClient.documentInfo(id: documentId) + title = full.title + emoji = full.emoji + text = full.text + } catch { + errorMessage = "Couldn't load this document. Check your connection and try again." + } + } +}