diff --git a/Outpost/Features/Home/HomeView.swift b/Outpost/Features/Home/HomeView.swift index 7a22831..ffc868f 100644 --- a/Outpost/Features/Home/HomeView.swift +++ b/Outpost/Features/Home/HomeView.swift @@ -10,7 +10,8 @@ struct HomeView: View { @State private var selectedTab: HomeTab = .recentlyViewed @State private var isShowingNewDocumentSheet = false - private let gridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] + private let pinnedGridColumns = [GridItem(.adaptive(minimum: 260), spacing: 8)] + private let tabGridColumns = [GridItem(.adaptive(minimum: 220), spacing: 12)] init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void) { self.apiClient = apiClient @@ -18,16 +19,25 @@ struct HomeView: View { _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) } + private var isShowingPinnedSection: Bool { + viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty + } + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 28) { + // The pinned section claims roughly the top half when it has + // anything to show (scrolling within itself if there are enough + // pinned documents to overflow that), and collapses away entirely + // when there's nothing pinned so the tabs get the full height. + GeometryReader { proxy in + VStack(spacing: 0) { if isShowingPinnedSection { pinnedSection + .frame(height: max(proxy.size.height / 2, 180)) + Divider() } tabSection + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .padding(24) - .frame(maxWidth: .infinity, alignment: .leading) } .toolbar { ToolbarItem(placement: .primaryAction) { @@ -48,75 +58,106 @@ struct HomeView: View { .task(id: selectedTab) { await viewModel.load(tab: selectedTab) } } - private var isShowingPinnedSection: Bool { - viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty - } - private var pinnedSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Pinned") .font(.title3.weight(.semibold)) + .padding(.horizontal, 24) + .padding(.top, 20) if viewModel.isLoadingPinned { ProgressView() - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(viewModel.pinnedDocuments) { document in - Button { - onOpenDocument(document) - } label: { - DocumentCardView(document: document) + ScrollView { + LazyVGrid(columns: pinnedGridColumns, spacing: 8) { + ForEach(viewModel.pinnedDocuments) { document in + Button { + onOpenDocument(document) + } label: { + PinnedDocumentCard(document: document) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .padding(.horizontal, 24) + .padding(.bottom, 16) } } } } private var tabSection: some View { - VStack(alignment: .leading, spacing: 16) { - Picker("", selection: $selectedTab) { - ForEach(HomeTab.allCases) { tab in - Text(tab.rawValue).tag(tab) - } - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: 520) + VStack(alignment: .leading, spacing: 0) { + tabBar + + Divider() let documents = viewModel.documents(for: selectedTab) - if viewModel.isLoadingTab && documents.isEmpty { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 40) - } else if let errorMessage = viewModel.errorMessage, documents.isEmpty { - ContentUnavailableView { - Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") - } description: { - Text(errorMessage) - } - } else if documents.isEmpty { - ContentUnavailableView( - "No Documents", - systemImage: "doc.text", - description: Text("Nothing to show here yet.") - ) - } else { - LazyVGrid(columns: gridColumns, spacing: 12) { - ForEach(documents) { document in - Button { - onOpenDocument(document) - } label: { - DocumentCardView(document: document) + Group { + if viewModel.isLoadingTab && documents.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let errorMessage = viewModel.errorMessage, documents.isEmpty { + ContentUnavailableView { + Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if documents.isEmpty { + ContentUnavailableView( + "No Documents", + systemImage: "doc.text", + description: Text("Nothing to show here yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVGrid(columns: tabGridColumns, spacing: 12) { + ForEach(documents) { document in + Button { + onOpenDocument(document) + } label: { + DocumentCardView(document: document) + } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) + .padding(24) } } } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + // Mirrors `CollectionOverviewView.tabBar`'s exact style, rather than the + // native `.pickerStyle(.segmented)` this started with. + private var tabBar: some View { + HStack(spacing: 4) { + Spacer(minLength: 0) + ForEach(HomeTab.allCases) { tab in + Button { + selectedTab = tab + } label: { + Text(tab.rawValue) + .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) + } } #endif diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index 5e49268..5b513ac 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -61,15 +61,14 @@ final class HomeViewModel { case .recentlyViewed: recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25) case .popular: - // Best-effort: the vendored spec's `Sorting.sort` is a - // free-form string, not an enum, with no documented - // popularity key. If the server doesn't recognize - // "viewCount" it most likely just falls back to a default - // order rather than erroring - worth eyeballing against a - // real server. - popular = try await apiClient.documentsList( - DocumentsListRequest(sort: "viewCount", direction: "DESC", limit: 25) - ) + // `sort: "viewCount"` was a guess and the server rejected it + // outright ("sort: Invalid input") — sort is validated + // server-side against a fixed set, not free-form like the + // vendored spec's typing implies. Same conclusion as + // `CollectionTab.popular`: there's no real popularity + // ranking exposed via the REST API, so this falls back to + // the default list order rather than guessing again. + popular = try await apiClient.documentsList(DocumentsListRequest(limit: 25)) case .recentlyUpdated: recentlyUpdated = try await apiClient.documentsList( DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25) diff --git a/Outpost/Features/Home/PinnedDocumentCard.swift b/Outpost/Features/Home/PinnedDocumentCard.swift new file mode 100644 index 0000000..04d19bc --- /dev/null +++ b/Outpost/Features/Home/PinnedDocumentCard.swift @@ -0,0 +1,46 @@ +#if os(macOS) +import SwiftUI +import OutlineKit + +/// Deliberately distinct from `DocumentCardView` — the pinned section is for +/// a quick scan of a small curated set, not browsing, so this is a dense +/// single-line row rather than a tall card, with an explicit pin glyph so +/// it doesn't read the same as the tab grids below it. +struct PinnedDocumentCard: View { + @Environment(StarStore.self) private var starStore + let document: OutlineDocument + + var body: some View { + HStack(spacing: 10) { + if let emoji = document.emoji { + Text(emoji) + .font(.title3) + } else { + Image(systemName: "doc.text") + .font(.body) + .foregroundStyle(.secondary) + } + + Text(document.title.isEmpty ? "Untitled" : document.title) + .font(.callout.weight(.medium)) + .lineLimit(1) + + Spacer(minLength: 0) + + if starStore.isStarred(documentId: document.id) { + Image(systemName: "star.fill") + .font(.caption2) + .foregroundStyle(.yellow) + } + + Image(systemName: "pin.fill") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8)) + } +} +#endif