macOS: expandable/collapsible sidebar tree (collections.list, with nested documents.list per expanded collection) driving a detail pane. iOS: flat collection list pushing to a document list. Both share row views and per-platform ContentView selectors, matching the AuthView pattern.
150 lines
6.1 KiB
Swift
150 lines
6.1 KiB
Swift
#if os(macOS)
|
|
import SwiftUI
|
|
import OutlineKit
|
|
|
|
struct ContentView_macOS: View {
|
|
@Environment(SessionStore.self) private var session
|
|
@State private var selectedCollection: OutlineCollection?
|
|
@State private var documentPath: [OutlineDocument] = []
|
|
@State private var globalSearchQuery = ""
|
|
@State private var contextualSearchQuery = ""
|
|
|
|
private var trimmedGlobalQuery: String {
|
|
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
VStack(spacing: 0) {
|
|
SidebarSearchField(text: $globalSearchQuery)
|
|
Divider()
|
|
sidebar
|
|
AccountFooter()
|
|
}
|
|
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
|
|
} detail: {
|
|
detail
|
|
}
|
|
.navigationTitle(session.teamName ?? "Outpost")
|
|
.toolbar {
|
|
// Per HIG: "sidebar-toggle/back controls appear at the far leading
|
|
// edge, followed by the view title" — this is that title, not
|
|
// centered. It doesn't collide with the back button because they're
|
|
// mutually exclusive: whenever a document's pushed (back button
|
|
// visible), this shows the document's own title instead of falling
|
|
// back to the workspace badge.
|
|
ToolbarItem(placement: .navigation) {
|
|
leadingToolbarContent
|
|
}
|
|
}
|
|
// Attached at this level (not inside the detail's NavigationStack) so
|
|
// it reliably renders in the shared window toolbar instead of a
|
|
// per-screen one that can get lost when nested more deeply.
|
|
.searchable(
|
|
text: $contextualSearchQuery,
|
|
placement: .toolbar,
|
|
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var leadingToolbarContent: some View {
|
|
Group {
|
|
if !trimmedGlobalQuery.isEmpty {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "magnifyingglass")
|
|
Text("Search")
|
|
}
|
|
} else if let currentDocument = documentPath.last, let selectedCollection {
|
|
// Back button (system-provided) → collection → document, so the
|
|
// hierarchy reads left to right instead of just the leaf title.
|
|
HStack(spacing: 6) {
|
|
CollectionRowView(collection: selectedCollection)
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2.weight(.semibold))
|
|
.foregroundStyle(.tertiary)
|
|
if let emoji = currentDocument.emoji {
|
|
Text(emoji)
|
|
} else {
|
|
Image(systemName: "doc.text")
|
|
}
|
|
Text(currentDocument.title.isEmpty ? "Untitled" : currentDocument.title)
|
|
.lineLimit(1)
|
|
}
|
|
} else if let selectedCollection {
|
|
CollectionRowView(collection: selectedCollection)
|
|
} else {
|
|
HStack(spacing: 8) {
|
|
AvatarBadge(avatarURL: session.teamAvatarURL, placeholderSystemImage: "text.book.closed.fill")
|
|
Text(session.teamName ?? "Outpost")
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.font(.headline)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(.fill.tertiary, in: Capsule())
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var sidebar: some View {
|
|
if let apiClient = session.apiClient {
|
|
CollectionsTreeView(
|
|
apiClient: apiClient,
|
|
selectedCollection: $selectedCollection,
|
|
onSelectDocument: selectDocument
|
|
)
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
|
|
/// `documentPath = [document]` (replacing a same-size array in one step)
|
|
/// unreliably updates `NavigationStack` on macOS — the stack can silently
|
|
/// keep showing the previous document. Popping to root and pushing again
|
|
/// as two separate state updates (so SwiftUI processes them as two render
|
|
/// passes) is the reliable version of the same operation.
|
|
private func selectDocument(_ collection: OutlineCollection, _ document: OutlineDocument) {
|
|
selectedCollection = collection
|
|
documentPath = []
|
|
Task { @MainActor in
|
|
documentPath = [document]
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detail: some View {
|
|
if let apiClient = session.apiClient {
|
|
// A fresh NavigationStack per collection (or when entering/leaving
|
|
// search), so switching either also clears any pushed document.
|
|
NavigationStack(path: $documentPath) {
|
|
Group {
|
|
if !trimmedGlobalQuery.isEmpty {
|
|
GlobalSearchResultsView(apiClient: apiClient, query: trimmedGlobalQuery)
|
|
} else if let selectedCollection {
|
|
CollectionOverviewView(
|
|
apiClient: apiClient,
|
|
collection: selectedCollection,
|
|
searchQuery: $contextualSearchQuery
|
|
)
|
|
} else {
|
|
ContentUnavailableView(
|
|
"No Collection Selected",
|
|
systemImage: "sidebar.left",
|
|
description: Text("Choose a collection to see its documents.")
|
|
)
|
|
}
|
|
}
|
|
.navigationDestination(for: OutlineDocument.self) { document in
|
|
DocumentReaderView(apiClient: apiClient, document: document)
|
|
}
|
|
}
|
|
.id(trimmedGlobalQuery.isEmpty ? (selectedCollection?.id ?? "none") : "search")
|
|
} else {
|
|
ContentUnavailableView("Not Signed In", systemImage: "person.crop.circle.badge.exclamationmark")
|
|
}
|
|
}
|
|
}
|
|
#endif
|