diff --git a/Outpost/Features/About/AboutView.swift b/Outpost/Features/About/AboutView.swift new file mode 100644 index 0000000..59ea90b --- /dev/null +++ b/Outpost/Features/About/AboutView.swift @@ -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 diff --git a/Outpost/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift new file mode 100644 index 0000000..fdc932f --- /dev/null +++ b/Outpost/Features/Account/AccountFooter.swift @@ -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 diff --git a/Outpost/Features/Account/KeyboardShortcutsView.swift b/Outpost/Features/Account/KeyboardShortcutsView.swift new file mode 100644 index 0000000..2e98c6c --- /dev/null +++ b/Outpost/Features/Account/KeyboardShortcutsView.swift @@ -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 diff --git a/Outpost/Features/Account/PreferencesView.swift b/Outpost/Features/Account/PreferencesView.swift new file mode 100644 index 0000000..cfe27e9 --- /dev/null +++ b/Outpost/Features/Account/PreferencesView.swift @@ -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 diff --git a/Outpost/Features/Account/ProfileView.swift b/Outpost/Features/Account/ProfileView.swift new file mode 100644 index 0000000..d4907be --- /dev/null +++ b/Outpost/Features/Account/ProfileView.swift @@ -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 diff --git a/Outpost/Support/View+LogoutConfirmation.swift b/Outpost/Support/View+LogoutConfirmation.swift new file mode 100644 index 0000000..6675154 --- /dev/null +++ b/Outpost/Support/View+LogoutConfirmation.swift @@ -0,0 +1,18 @@ +import SwiftUI + +extension View { + func logoutConfirmationDialog(isPresented: Binding, 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.") + } + } +}