- API, Mac client
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user