add three-column layout with sidebar, thread list, detail view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 12:21:49 +01:00
parent e53a207583
commit 5a878012b7
4 changed files with 204 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import Foundation
@Observable
final class MailViewModel {
let apiClient: APIClient
var threads: [ThreadSummary] = []
var selectedThread: ThreadSummary?
var selectedMessages: [EmailMessage] = []
var selectedPerspective: Perspective = .inbox
var isLoading = false
var errorMessage: String?
enum Perspective: String, CaseIterable, Identifiable {
case inbox = "Inbox"
case today = "Today"
case archive = "Archive"
var id: String { rawValue }
var systemImage: String {
switch self {
case .inbox: "tray"
case .today: "sun.max"
case .archive: "archivebox"
}
}
}
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func loadThreads(accountId: String) async {
isLoading = true
errorMessage = nil
do {
threads = try await apiClient.fetchThreads(accountId: accountId)
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func loadMessages(for thread: ThreadSummary) async {
selectedThread = thread
do {
selectedMessages = try await apiClient.fetchMessages(threadId: thread.threadId)
} catch {
errorMessage = error.localizedDescription
}
}
func connectToEvents(baseURL: URL, accountId: String) {
let sseURL = baseURL.appendingPathComponent("api/events")
let sseClient = SSEClient(url: sseURL)
sseClient.connect { [weak self] event in
guard let self else { return }
if event.event == "threads_updated" {
Task { @MainActor in
await self.loadThreads(accountId: accountId)
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
import SwiftUI
struct SidebarView: View {
@Bindable var viewModel: MailViewModel
var body: some View {
List(selection: $viewModel.selectedPerspective) {
Section("Views") {
ForEach(MailViewModel.Perspective.allCases) { perspective in
Label(perspective.rawValue, systemImage: perspective.systemImage)
.tag(perspective)
}
}
}
.navigationTitle("Magnum Opus")
.listStyle(.sidebar)
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
struct ThreadDetailView: View {
let thread: ThreadSummary?
let messages: [EmailMessage]
var body: some View {
Group {
if let thread {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(thread.subject)
.font(.title2)
.fontWeight(.semibold)
.padding()
Divider()
ForEach(messages) { message in
MessageView(message: message)
Divider()
}
}
}
} else {
ContentUnavailableView(
"No Thread Selected",
systemImage: "envelope",
description: Text("Select a thread to read")
)
}
}
}
}
struct MessageView: View {
let message: EmailMessage
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(message.senderName)
.fontWeight(.semibold)
Spacer()
Text(message.date)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("To: \(message.toHeader)")
.font(.caption)
.foregroundStyle(.secondary)
Text(message.body)
.font(.body)
.textSelection(.enabled)
}
.padding()
}
}

View File

@@ -0,0 +1,59 @@
import SwiftUI
struct ThreadListView: View {
@Bindable var viewModel: MailViewModel
var body: some View {
List(viewModel.threads, selection: Binding(
get: { viewModel.selectedThread?.threadId },
set: { newId in
if let thread = viewModel.threads.first(where: { $0.threadId == newId }) {
Task { await viewModel.loadMessages(for: thread) }
}
}
)) { thread in
ThreadRow(thread: thread)
.tag(thread.threadId)
}
.listStyle(.inset)
.navigationTitle(viewModel.selectedPerspective.rawValue)
.overlay {
if viewModel.isLoading {
ProgressView()
} else if viewModel.threads.isEmpty {
ContentUnavailableView(
"No Messages",
systemImage: "tray",
description: Text("Inbox is empty")
)
}
}
}
}
struct ThreadRow: View {
let thread: ThreadSummary
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(thread.authors)
.fontWeight(thread.isUnread ? .bold : .regular)
.lineLimit(1)
Spacer()
Text(thread.date, style: .relative)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(thread.subject)
.font(.subheadline)
.lineLimit(1)
if thread.totalMessages > 1 {
Text("\(thread.totalMessages) messages")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 2)
}
}