feat(account): add account menu, about panel, and preferences (macOS)
Sidebar footer opens a popover (not Menu — its AppKit-bridged label rendering broke on click with an avatar image in it) with profile, keyboard shortcuts, docs/feedback links, and log out. About panel and a minimal Preferences window round out the app-menu affordances.
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AboutView: View {
|
||||||
|
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||||
|
|
||||||
|
private var appName: String {
|
||||||
|
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var versionString: String {
|
||||||
|
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
|
||||||
|
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
|
||||||
|
return "Version \(shortVersion) (\(buildNumber))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var copyrightYear: String {
|
||||||
|
String(Calendar.current.component(.year, from: Date()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if let appIcon = NSImage(named: "AppIcon") {
|
||||||
|
Image(nsImage: appIcon)
|
||||||
|
.resizable()
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "text.book.closed.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Color.accentColor)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(appName)
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(versionString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("A native Apple client for self-hosted Outline, built for full editing parity with the web app.")
|
||||||
|
.font(.callout)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Link(destination: repositoryURL) {
|
||||||
|
Label("View Source on Git", systemImage: "link")
|
||||||
|
}
|
||||||
|
.font(.callout)
|
||||||
|
|
||||||
|
Button("Check for Updates…") {
|
||||||
|
checkForUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.frame(width: 320)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update checking isn't wired to a distribution channel yet — button is a placeholder.
|
||||||
|
private func checkForUpdates() {}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
|
||||||
|
/// contains the avatar image reliably broke its own sizing on click (even after
|
||||||
|
/// several hardening attempts) — that's specifically `Menu`'s AppKit-bridged
|
||||||
|
/// label rendering, which a `Button` doesn't go through.
|
||||||
|
struct AccountFooter: View {
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
@Environment(\.openWindow) private var openWindow
|
||||||
|
@Environment(\.openSettings) private var openSettings
|
||||||
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
@State private var isMenuPresented = false
|
||||||
|
@State private var isShowingLogoutConfirmation = false
|
||||||
|
@State private var isShowingProfile = false
|
||||||
|
|
||||||
|
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
|
||||||
|
private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")!
|
||||||
|
|
||||||
|
private var apiDocumentationURL: URL? {
|
||||||
|
session.serverURL?.appendingPathComponent("developers")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
isMenuPresented = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
AvatarBadge(avatarURL: session.userAvatarURL, size: 28, placeholderSystemImage: "person.crop.circle.fill")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(session.userName ?? "Signed In")
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.lineLimit(1)
|
||||||
|
if let email = session.userEmail {
|
||||||
|
Text(email)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.popover(isPresented: $isMenuPresented, arrowEdge: .trailing) {
|
||||||
|
menuContent
|
||||||
|
}
|
||||||
|
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||||
|
.sheet(isPresented: $isShowingProfile) {
|
||||||
|
ProfileView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var menuContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
menuItem("Documentation") { openURL(repositoryURL) }
|
||||||
|
if let apiDocumentationURL {
|
||||||
|
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
||||||
|
}
|
||||||
|
menuItem("Changelog") { openURL(repositoryURL) }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
menuItem("Send Us Feedback") { openURL(issuesURL) }
|
||||||
|
menuItem("Report a Bug") { openURL(issuesURL) }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Appearance")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Appearance", selection: $appearance) {
|
||||||
|
ForEach(AppAppearance.allCases) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
menuItem("Profile…") { isShowingProfile = true }
|
||||||
|
menuItem("Preferences…") { openSettings() }
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
menuItem("Log Out", role: .destructive) { isShowingLogoutConfirmation = true }
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.frame(width: 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func menuItem(_ title: String, role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(role: role) {
|
||||||
|
isMenuPresented = false
|
||||||
|
action()
|
||||||
|
} label: {
|
||||||
|
Text(title)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundStyle(role == .destructive ? Color.red : Color.primary)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct KeyboardShortcutsView: View {
|
||||||
|
private struct Shortcut: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let action: String
|
||||||
|
let keys: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private let shortcuts: [Shortcut] = [
|
||||||
|
Shortcut(action: "Sign In", keys: "⏎"),
|
||||||
|
Shortcut(action: "Preferences", keys: "⌘ ,"),
|
||||||
|
Shortcut(action: "Close Window", keys: "⌘ W"),
|
||||||
|
Shortcut(action: "Quit Outpost", keys: "⌘ Q")
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Keyboard Shortcuts")
|
||||||
|
.font(.title3.bold())
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(shortcuts) { shortcut in
|
||||||
|
HStack {
|
||||||
|
Text(shortcut.action)
|
||||||
|
Spacer()
|
||||||
|
Text(shortcut.keys)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospaced()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 280)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PreferencesView: View {
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
|
@State private var isShowingLogoutConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section("Appearance") {
|
||||||
|
Picker("Appearance", selection: $appearance) {
|
||||||
|
ForEach(AppAppearance.allCases) { option in
|
||||||
|
Text(option.label).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Account") {
|
||||||
|
LabeledContent("Signed in as", value: session.userName ?? "—")
|
||||||
|
if let email = session.userEmail {
|
||||||
|
LabeledContent("Email", value: email)
|
||||||
|
}
|
||||||
|
if let teamName = session.teamName {
|
||||||
|
LabeledContent("Workspace", value: teamName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Log Out…", role: .destructive) {
|
||||||
|
isShowingLogoutConfirmation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.frame(width: 380, height: 300)
|
||||||
|
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#if os(macOS)
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProfileView: View {
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
AvatarBadge(avatarURL: session.userAvatarURL, size: 64, placeholderSystemImage: "person.crop.circle.fill")
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(session.userName ?? "Unknown")
|
||||||
|
.font(.title3.bold())
|
||||||
|
if let email = session.userEmail {
|
||||||
|
Text(email)
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let teamName = session.teamName {
|
||||||
|
Label(teamName, systemImage: "building.2")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.padding(32)
|
||||||
|
.frame(width: 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func logoutConfirmationDialog(isPresented: Binding<Bool>, session: SessionStore) -> some View {
|
||||||
|
confirmationDialog(
|
||||||
|
"Log Out of Outpost?",
|
||||||
|
isPresented: isPresented,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("Log Out", role: .destructive) {
|
||||||
|
session.signOut()
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("You'll need your server address and API token to sign in again.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user