349 lines
12 KiB
Swift
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()
|
|
}
|
|
}
|