import AppKit import SwiftUI struct ContentView: View { @StateObject private var model = BlogClientModel() var body: some View { NavigationView { sidebar detail } .toolbar { ToolbarItemGroup { Button("Refresh") { Task { await model.loadSection() } } .disabled(model.isBusy) if model.section == .posts || model.section == .pages { Button("Save Draft") { Task { await model.saveContent(status: "draft") } } Button("Publish") { Task { await model.saveContent(status: "published") } } Button("Delete") { Task { await model.deleteSelectedContent() } } .disabled(model.editorItem.slug.isEmpty) } } } .task { await model.loadSection() } .onChange(of: model.section) { _ in Task { await model.loadSection() } } } private var sidebar: some View { VStack(alignment: .leading, spacing: 12) { Text("TCMS") .font(.largeTitle.bold()) .padding(.horizontal) .padding(.top) VStack(alignment: .leading, spacing: 4) { ForEach(BlogClientModel.Section.allCases) { section in Button { model.section = section } label: { HStack { Text(section.rawValue) Spacer() } .padding(.horizontal) .padding(.vertical, 8) .background(model.section == section ? Color.accentColor.opacity(0.16) : Color.clear) .cornerRadius(6) } .buttonStyle(.plain) } } .padding(.horizontal, 8) Spacer() Text(model.message) .font(.footnote) .foregroundColor(.secondary) .padding() .frame(maxWidth: .infinity, alignment: .leading) } .frame(minWidth: 210) } @ViewBuilder private var detail: some View { switch model.section { case .posts: ContentLibraryView(model: model, type: "post") case .pages: ContentLibraryView(model: model, type: "page") case .media: MediaView(model: model) case .comments: CommentsView(model: model) case .settings: SettingsView(model: model) } } } struct ContentLibraryView: View { @ObservedObject var model: BlogClientModel var type: String var body: some View { HStack(spacing: 0) { VStack(spacing: 0) { HStack { Text(type == "page" ? "Pages" : "Posts") .font(.title2.bold()) Spacer() Button("New") { model.newContent(type: type) } } .padding() List(model.visibleContents) { item in VStack(alignment: .leading, spacing: 5) { Text(item.title).font(.headline) HStack { Text(item.slug.isEmpty ? "unsaved" : item.slug) Text(item.status) } .font(.caption) .foregroundColor(.secondary) } .padding(.vertical, 4) .background(model.selectedSlug == item.slug ? Color.accentColor.opacity(0.10) : Color.clear) .onTapGesture { Task { await model.selectContent(item) } } } } .frame(minWidth: 260, idealWidth: 300, maxWidth: 360) Divider() EditorView(model: model) } } } struct EditorView: View { @ObservedObject var model: BlogClientModel var body: some View { VStack(spacing: 0) { ScrollView { VStack(alignment: .leading, spacing: 14) { HStack { TextField("Title", text: model.stringBinding(\.title)) .font(.title2) Picker("Status", selection: model.stringBinding(\.status)) { Text("Draft").tag("draft") Text("Published").tag("published") } .pickerStyle(.segmented) .frame(width: 220) } HStack { TextField("Slug", text: model.stringBinding(\.slug)) Picker("Type", selection: model.stringBinding(\.type)) { Text("Post").tag("post") Text("Page").tag("page") } .pickerStyle(.segmented) .frame(width: 180) } HStack { TextField("Category", text: model.stringBinding(\.category)) TextField("Tags", text: model.tagsBinding) } HStack { TextField("Author", text: model.stringBinding(\.author)) TextField("Cover URL", text: model.stringBinding(\.cover)) } TextField("Summary", text: model.stringBinding(\.summary)) Toggle("Allow comments", isOn: model.boolBinding(\.allowComments)) if let lastAutosave = model.lastAutosave { Text("Last local autosave: \(lastAutosave.formatted(date: .omitted, time: .standard))") .font(.caption) .foregroundColor(.secondary) } } .padding() } .frame(maxHeight: 260) Divider() TextEditor(text: model.markdownBinding) .font(.system(.body, design: .monospaced)) .padding(8) } } } struct MediaView: View { @ObservedObject var model: BlogClientModel var body: some View { VStack(alignment: .leading) { HStack { Text("Media").font(.title.bold()) Spacer() Button("Upload Media") { chooseMedia() } Button("Refresh") { Task { await model.refreshMedia() } } } .padding() List(model.mediaItems) { item in VStack(alignment: .leading, spacing: 4) { Text(item.name).font(.headline) Text(item.url).font(.caption).foregroundColor(.secondary) Text("\(item.mime) ยท \(ByteCountFormatter.string(fromByteCount: Int64(item.size), countStyle: .file))") .font(.caption) .foregroundColor(.secondary) } .padding(.vertical, 4) } } } private func chooseMedia() { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.canChooseFiles = true if panel.runModal() == .OK, let url = panel.url { Task { await model.uploadMedia(fileURL: url) } } } } struct CommentsView: View { @ObservedObject var model: BlogClientModel var body: some View { VStack(alignment: .leading) { HStack { Text("Comments").font(.title.bold()) Spacer() Picker("Status", selection: $model.commentStatus) { Text("Pending").tag("pending") Text("Approved").tag("approved") Text("Spam").tag("spam") Text("All").tag("all") } .frame(width: 190) Button("Refresh") { Task { await model.refreshComments() } } } .padding() List(model.comments) { comment in VStack(alignment: .leading, spacing: 8) { HStack { Text(comment.author).font(.headline) Text("#\(comment.id)") .font(.caption) .foregroundColor(.secondary) Text(comment.status) .font(.caption) .foregroundColor(.secondary) Spacer() Button("Approve") { Task { await model.setComment(comment, status: "approved") } } Button("Pending") { Task { await model.setComment(comment, status: "pending") } } Button("Spam") { Task { await model.setComment(comment, status: "spam") } } Button("Delete") { Task { await model.deleteComment(comment) } } } Text(comment.slug).font(.caption).foregroundColor(.secondary) Text(comment.body) } .padding(.vertical, 6) } } .onChange(of: model.commentStatus) { _ in Task { await model.refreshComments() } } } } struct SettingsView: View { @ObservedObject var model: BlogClientModel var body: some View { Form { Section("Connection") { TextField("TCMS URL", text: $model.baseURLString) SecureField("API Token", text: $model.apiToken) HStack { Button("Save Settings") { model.saveSettings() } Button("Test Connection") { Task { await model.ping() } } } } Section("Autosave") { Stepper(value: $model.autosaveInterval, in: 10...900, step: 5) { Text("Every \(Int(model.autosaveInterval)) seconds") } Toggle("Also save remotely as draft", isOn: $model.remoteAutosaveDraft) Button("Apply Autosave Settings") { model.saveSettings() } } Section("Local Autosaves") { if model.localDrafts.isEmpty { Text("No local autosaves for this server.") .foregroundColor(.secondary) } else { ForEach(model.localDrafts) { draft in HStack { VStack(alignment: .leading) { Text(draft.item.title).font(.headline) Text(draft.savedAt.formatted()) .font(.caption) .foregroundColor(.secondary) } Spacer() Button("Restore") { model.restoreDraft(draft) } } } } } } .padding() } }