feat(collections): add tabbed overview and document reader (macOS)
CollectionOverviewView mirrors Outline's own collection tabs (Overview, Documents, Popular, sort variants), with the Overview tab rendering the collection's description through MarkdownEngine. DocumentReaderView opens a tapped document read-only via the same engine, re-fetching full content through documents.info rather than trusting the list payload.
This commit is contained in:
@@ -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
|
||||||
@@ -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<String>) {
|
||||||
|
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
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user