Files
tcms/macclient/Sources/TCMSMacClient/ContentView.swift
T
2026-07-05 15:58:51 -04:00

349 lines
12 KiB
Swift

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()
}
}