feat(settings): grouped sidebar matching Outline's own settings categories

SettingsSection now carries a SettingsCategory (general/account/
workspace/integrationsInstallation) and SettingsSidebarList renders it
as a grouped, headered list — general (ours: Appearance, Offline &
Sync, Advanced, About) unlabeled at the top, then Account (Profile,
Preferences, Notifications, Passkeys, API & Access), Workspace
(Details, Authentication, Security, AI, Members, Groups, Templates,
Emojis, Applications, Shared, Links, Webhooks, Import, Export), and
Integrations & Installation (Installation).

Nav skeleton only, per instruction — everything except the ones
already built (renamed the old flat "Account" page to "Profile", its
natural new home; content unchanged) shows a "Coming Soon" placeholder
until each section's real content is specified and built one at a
time.
This commit is contained in:
2026-08-15 16:04:03 +01:00
parent 861f596f5f
commit 67bef76716
3 changed files with 144 additions and 10 deletions
@@ -6,6 +6,10 @@ import SwiftUI
/// content, rather than a separate mini sidebar nested inside a page. "Done" /// content, rather than a separate mini sidebar nested inside a page. "Done"
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree /// clears `AppNavigation.isShowingSettings`, which puts the collections tree
/// back. /// back.
///
/// Grouped by `SettingsCategory` `general` (ours) sits unlabeled at the
/// top, then Outline's own Account/Workspace/Integrations & Installation
/// groups, matching the settings page structure of the Outline web app.
struct SettingsSidebarList: View { struct SettingsSidebarList: View {
@Binding var selection: SettingsSection? @Binding var selection: SettingsSection?
let onDone: () -> Void let onDone: () -> Void
@@ -22,10 +26,21 @@ struct SettingsSidebarList: View {
Divider() Divider()
List(SettingsSection.allCases, selection: $selection) { section in List(selection: $selection) {
ForEach(SettingsCategory.allCases) { category in
let sections = SettingsSection.allCases.filter { $0.category == category }
Section {
ForEach(sections) { section in
Label(section.title, systemImage: section.icon) Label(section.title, systemImage: section.icon)
.tag(section) .tag(section)
} }
} header: {
if let title = category.title {
Text(title)
}
}
}
}
.listStyle(.sidebar) .listStyle(.sidebar)
Divider() Divider()
+19 -3
View File
@@ -62,13 +62,29 @@ struct SettingsView: View {
private var sectionDetail: some View { private var sectionDetail: some View {
switch section { switch section {
case .appearance: appearanceDetail case .appearance: appearanceDetail
case .account: accountDetail case .profile: profileDetail
case .offlineSync: offlineSyncDetail case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail case .advanced: advancedDetail
case .about: aboutDetail case .about: aboutDetail
default: comingSoonDetail
} }
} }
/// Everything in Account/Workspace/Integrations & Installation that
/// isn't `.profile` real content lands section by section; this is
/// just the nav skeleton until then.
private var comingSoonDetail: some View {
VStack(spacing: 16) {
sectionHeader
ContentUnavailableView {
Label("Coming Soon", systemImage: section.icon)
} description: {
Text("\(section.title) settings aren't built yet.")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
private var sectionHeader: some View { private var sectionHeader: some View {
Text(section.title) Text(section.title)
.font(.title.bold()) .font(.title.bold())
@@ -90,9 +106,9 @@ struct SettingsView: View {
} }
} }
// MARK: - Account // MARK: - Profile
private var accountDetail: some View { private var profileDetail: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
sectionHeader sectionHeader
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
+107 -4
View File
@@ -1,27 +1,130 @@
import Observation import Observation
enum SettingsSection: String, CaseIterable, Identifiable, Hashable { /// Top-level groupings shown as section headers in `SettingsSidebarList`.
case appearance, account, offlineSync, advanced, about /// `general` holds everything that's ours, not Outline's own settings
/// categories no header, sits above the named groups.
enum SettingsCategory: String, CaseIterable, Identifiable {
case general
case account
case workspace
case integrationsInstallation
var id: String { rawValue } var id: String { rawValue }
var title: String? {
switch self {
case .general: return nil
case .account: return "Account"
case .workspace: return "Workspace"
case .integrationsInstallation: return "Integrations & Installation"
}
}
}
/// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace/Integrations & Installation) so this app's
/// settings read as a native counterpart to the web app's, plus a `general`
/// group for things that are ours and don't map onto Outline's structure
/// (offline/sync, advanced, about, appearance).
///
/// Most of the Account/Workspace/Integrations cases are navigation-only for
/// now `SettingsView` renders a "Coming Soon" placeholder for anything not
/// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours)
case appearance, offlineSync, advanced, about
// Account
case profile, preferences, notifications, passkeys, apiAccess
// Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
// Integrations & Installation
case installation
var id: String { rawValue }
var category: SettingsCategory {
switch self {
case .appearance, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace
case .installation:
return .integrationsInstallation
}
}
var title: String { var title: String {
switch self { switch self {
case .appearance: return "Appearance" case .appearance: return "Appearance"
case .account: return "Account"
case .offlineSync: return "Offline & Sync" case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced" case .advanced: return "Advanced"
case .about: return "About" case .about: return "About"
case .profile: return "Profile"
case .preferences: return "Preferences"
case .notifications: return "Notifications"
case .passkeys: return "Passkeys"
case .apiAccess: return "API & Access"
case .details: return "Details"
case .authentication: return "Authentication"
case .security: return "Security"
case .ai: return "AI"
case .members: return "Members"
case .groups: return "Groups"
case .templates: return "Templates"
case .emojis: return "Emojis"
case .applications: return "Applications"
case .shared: return "Shared"
case .links: return "Links"
case .webhooks: return "Webhooks"
case .importData: return "Import"
case .exportData: return "Export"
case .installation: return "Installation"
} }
} }
var icon: String { var icon: String {
switch self { switch self {
case .appearance: return "paintbrush" case .appearance: return "paintbrush"
case .account: return "person.crop.circle"
case .offlineSync: return "arrow.triangle.2.circlepath" case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver" case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle" case .about: return "info.circle"
case .profile: return "person.crop.circle"
case .preferences: return "gearshape"
case .notifications: return "bell"
case .passkeys: return "key"
case .apiAccess: return "chevron.left.forwardslash.chevron.right"
case .details: return "building.2"
case .authentication: return "lock"
case .security: return "shield"
case .ai: return "sparkles"
case .members: return "person.2"
case .groups: return "person.3"
case .templates: return "doc.on.doc"
case .emojis: return "face.smiling"
case .applications: return "app.badge"
case .shared: return "square.and.arrow.up.on.square"
case .links: return "link"
case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up"
case .installation: return "shippingbox"
}
}
/// Everything actually built so far everything else in Account/
/// Workspace/Integrations & Installation renders a "Coming Soon"
/// placeholder until its content is specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .offlineSync, .advanced, .about, .profile:
return true
default:
return false
} }
} }
} }