fix(home): distinct pinned card style, matching tab bar, drop broken sort

- Pinned section uses a new dense PinnedDocumentCard (single-line,
  explicit pin glyph) instead of the same tall card as the tab grids -
  it's meant for a quick scan of a small curated set, not browsing,
  and needed to actually show a pin so pinned docs are recognizable
  at a glance.
- Tab bar now matches CollectionOverviewView.tabBar's exact style
  instead of the native segmented picker.
- Popular tab's sort: "viewCount" was a guess and the server rejected
  it outright ("sort: Invalid input") - sort is validated against a
  fixed set server-side, not free-form like the vendored spec's typing
  implies. Falls back to default order now, same conclusion already
  reached for CollectionTab.popular - no real popularity ranking is
  exposed via the REST API.
- Layout: pinned section now claims roughly the top half of the page
  (scrolling within itself if there are more pinned docs than fit)
  when there's anything pinned, collapsing away entirely otherwise so
  the tabs get full height.
This commit is contained in:
2026-08-14 16:16:27 +01:00
parent c3083bf0c0
commit 23193034e6
3 changed files with 145 additions and 59 deletions
+65 -24
View File
@@ -10,7 +10,8 @@ struct HomeView: View {
@State private var selectedTab: HomeTab = .recentlyViewed @State private var selectedTab: HomeTab = .recentlyViewed
@State private var isShowingNewDocumentSheet = false @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) { init(apiClient: OutlineAPIClient, onOpenDocument: @escaping (OutlineDocument) -> Void) {
self.apiClient = apiClient self.apiClient = apiClient
@@ -18,16 +19,25 @@ struct HomeView: View {
_viewModel = State(initialValue: HomeViewModel(apiClient: apiClient)) _viewModel = State(initialValue: HomeViewModel(apiClient: apiClient))
} }
private var isShowingPinnedSection: Bool {
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
}
var body: some View { var body: some View {
ScrollView { // The pinned section claims roughly the top half when it has
VStack(alignment: .leading, spacing: 28) { // 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 { if isShowingPinnedSection {
pinnedSection pinnedSection
.frame(height: max(proxy.size.height / 2, 180))
Divider()
} }
tabSection tabSection
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
} }
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
@@ -48,64 +58,64 @@ struct HomeView: View {
.task(id: selectedTab) { await viewModel.load(tab: selectedTab) } .task(id: selectedTab) { await viewModel.load(tab: selectedTab) }
} }
private var isShowingPinnedSection: Bool {
viewModel.isLoadingPinned || !viewModel.pinnedDocuments.isEmpty
}
private var pinnedSection: some View { private var pinnedSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Pinned") Text("Pinned")
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.padding(.horizontal, 24)
.padding(.top, 20)
if viewModel.isLoadingPinned { if viewModel.isLoadingPinned {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
LazyVGrid(columns: gridColumns, spacing: 12) { ScrollView {
LazyVGrid(columns: pinnedGridColumns, spacing: 8) {
ForEach(viewModel.pinnedDocuments) { document in ForEach(viewModel.pinnedDocuments) { document in
Button { Button {
onOpenDocument(document) onOpenDocument(document)
} label: { } label: {
DocumentCardView(document: document) PinnedDocumentCard(document: document)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
.padding(.horizontal, 24)
.padding(.bottom, 16)
}
} }
} }
} }
private var tabSection: some View { private var tabSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 0) {
Picker("", selection: $selectedTab) { tabBar
ForEach(HomeTab.allCases) { tab in
Text(tab.rawValue).tag(tab) Divider()
}
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: 520)
let documents = viewModel.documents(for: selectedTab) let documents = viewModel.documents(for: selectedTab)
Group {
if viewModel.isLoadingTab && documents.isEmpty { if viewModel.isLoadingTab && documents.isEmpty {
ProgressView() ProgressView()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 40)
} else if let errorMessage = viewModel.errorMessage, documents.isEmpty { } else if let errorMessage = viewModel.errorMessage, documents.isEmpty {
ContentUnavailableView { ContentUnavailableView {
Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle") Label("Couldn't Load Documents", systemImage: "exclamationmark.triangle")
} description: { } description: {
Text(errorMessage) Text(errorMessage)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if documents.isEmpty { } else if documents.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"No Documents", "No Documents",
systemImage: "doc.text", systemImage: "doc.text",
description: Text("Nothing to show here yet.") description: Text("Nothing to show here yet.")
) )
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
LazyVGrid(columns: gridColumns, spacing: 12) { ScrollView {
LazyVGrid(columns: tabGridColumns, spacing: 12) {
ForEach(documents) { document in ForEach(documents) { document in
Button { Button {
onOpenDocument(document) onOpenDocument(document)
@@ -115,8 +125,39 @@ struct HomeView: View {
.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 #endif
+8 -9
View File
@@ -61,15 +61,14 @@ final class HomeViewModel {
case .recentlyViewed: case .recentlyViewed:
recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25) recentlyViewed = try await apiClient.listViewedDocuments(offset: 0, limit: 25)
case .popular: case .popular:
// Best-effort: the vendored spec's `Sorting.sort` is a // `sort: "viewCount"` was a guess and the server rejected it
// free-form string, not an enum, with no documented // outright ("sort: Invalid input") sort is validated
// popularity key. If the server doesn't recognize // server-side against a fixed set, not free-form like the
// "viewCount" it most likely just falls back to a default // vendored spec's typing implies. Same conclusion as
// order rather than erroring - worth eyeballing against a // `CollectionTab.popular`: there's no real popularity
// real server. // ranking exposed via the REST API, so this falls back to
popular = try await apiClient.documentsList( // the default list order rather than guessing again.
DocumentsListRequest(sort: "viewCount", direction: "DESC", limit: 25) popular = try await apiClient.documentsList(DocumentsListRequest(limit: 25))
)
case .recentlyUpdated: case .recentlyUpdated:
recentlyUpdated = try await apiClient.documentsList( recentlyUpdated = try await apiClient.documentsList(
DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25) DocumentsListRequest(sort: "updatedAt", direction: "DESC", limit: 25)
@@ -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