- API, Mac client

This commit is contained in:
Ty Clifford
2026-07-05 15:58:51 -04:00
parent 9dbd29d8aa
commit 729b29276f
14 changed files with 1682 additions and 1 deletions
@@ -0,0 +1,324 @@
import Foundation
import SwiftUI
@MainActor
final class BlogClientModel: ObservableObject {
enum Section: String, CaseIterable, Identifiable {
case posts = "Posts"
case pages = "Pages"
case media = "Media"
case comments = "Comments"
case settings = "Settings"
var id: String { rawValue }
}
@Published var section: Section = .posts
@Published var baseURLString: String
@Published var apiToken: String
@Published var autosaveInterval: Double
@Published var remoteAutosaveDraft: Bool
@Published var contents: [ContentItem] = []
@Published var editorItem = ContentItem()
@Published var editorMarkdown = "# Untitled\n\nStart writing here."
@Published var selectedSlug: String?
@Published var mediaItems: [MediaItem] = []
@Published var comments: [CommentItem] = []
@Published var commentStatus = "pending"
@Published var message = "Ready"
@Published var isBusy = false
@Published var lastAutosave: Date?
@Published var localDrafts: [DraftRecord] = []
private var originalSlug = ""
private var isDirty = false
private var timer: Timer?
private let draftStore = DraftStore()
private let defaults = UserDefaults.standard
init() {
baseURLString = defaults.string(forKey: "baseURLString") ?? "http://127.0.0.1:8097"
apiToken = defaults.string(forKey: "apiToken") ?? ""
autosaveInterval = defaults.double(forKey: "autosaveInterval")
if autosaveInterval < 10 {
autosaveInterval = 30
}
remoteAutosaveDraft = defaults.bool(forKey: "remoteAutosaveDraft")
restartAutosaveTimer()
localDrafts = draftStore.loadAll(serverURL: baseURLString)
}
deinit {
timer?.invalidate()
}
var client: TCMSAPIClient? {
guard let url = URL(string: baseURLString.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return nil
}
return TCMSAPIClient(baseURL: url, token: apiToken)
}
var visibleContents: [ContentItem] {
let type = section == .pages ? "page" : "post"
return contents.filter { $0.type == type }
}
func saveSettings() {
defaults.set(baseURLString, forKey: "baseURLString")
defaults.set(apiToken, forKey: "apiToken")
defaults.set(autosaveInterval, forKey: "autosaveInterval")
defaults.set(remoteAutosaveDraft, forKey: "remoteAutosaveDraft")
restartAutosaveTimer()
localDrafts = draftStore.loadAll(serverURL: baseURLString)
message = "Settings saved."
}
func loadSection() async {
switch section {
case .posts:
await refreshContent(type: "post")
case .pages:
await refreshContent(type: "page")
case .media:
await refreshMedia()
case .comments:
await refreshComments()
case .settings:
localDrafts = draftStore.loadAll(serverURL: baseURLString)
}
}
func ping() async {
await run("Connected.") {
let response = try await requireClient().ping()
message = response.authenticated
? "Connected to \(response.name)."
: "Connected, but management requests need an API token."
}
}
func refreshContent(type: String? = nil) async {
let requestedType = type ?? (section == .pages ? "page" : "post")
await run("Content refreshed.") {
contents = try await requireClient().listContent(type: requestedType)
if selectedSlug == nil, let first = contents.first {
await selectContent(first)
}
}
}
func selectContent(_ item: ContentItem) async {
await run("Loaded \(item.title).") {
let full = try await requireClient().getContent(slug: item.slug)
editorItem = full
editorMarkdown = full.markdown
originalSlug = full.slug
selectedSlug = full.slug
isDirty = false
}
}
func newContent(type: String) {
let title = type == "page" ? "New Page" : "New Post"
editorItem = ContentItem(title: title, type: type, markdown: "# \(title)\n\nStart writing here.")
editorMarkdown = editorItem.markdown
originalSlug = ""
selectedSlug = nil
markDirty()
}
func saveContent(status: String? = nil) async {
var item = editorItem
if let status {
item.status = status
}
if item.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
item.title = "Untitled"
}
await run(status == "published" ? "Published." : "Saved.") {
let saved = try await requireClient().saveContent(item, originalSlug: originalSlug, markdown: editorMarkdown)
editorItem = saved
editorMarkdown = saved.markdown
originalSlug = saved.slug
selectedSlug = saved.slug
isDirty = false
draftStore.remove(id: saved.slug)
await refreshContent(type: saved.type)
}
}
func deleteSelectedContent() async {
let slug = editorItem.slug
guard !slug.isEmpty else {
message = "Nothing selected to delete."
return
}
await run("Deleted \(slug).") {
_ = try await requireClient().deleteContent(slug: slug)
editorItem = ContentItem(type: section == .pages ? "page" : "post")
editorMarkdown = editorItem.markdown
originalSlug = ""
selectedSlug = nil
isDirty = false
await refreshContent(type: section == .pages ? "page" : "post")
}
}
func refreshMedia() async {
await run("Media refreshed.") {
mediaItems = try await requireClient().listMedia()
}
}
func uploadMedia(fileURL: URL) async {
await run("Uploaded \(fileURL.lastPathComponent).") {
let accessing = fileURL.startAccessingSecurityScopedResource()
defer {
if accessing {
fileURL.stopAccessingSecurityScopedResource()
}
}
_ = try await requireClient().uploadMedia(fileURL: fileURL)
mediaItems = try await requireClient().listMedia()
}
}
func refreshComments() async {
await run("Comments refreshed.") {
comments = try await requireClient().listComments(status: commentStatus)
}
}
func setComment(_ comment: CommentItem, status: String) async {
await run("Comment updated.") {
_ = try await requireClient().setCommentStatus(id: comment.id, status: status)
comments = try await requireClient().listComments(status: commentStatus)
}
}
func deleteComment(_ comment: CommentItem) async {
await run("Comment deleted.") {
_ = try await requireClient().deleteComment(id: comment.id)
comments = try await requireClient().listComments(status: commentStatus)
}
}
func markDirty() {
isDirty = true
}
func stringBinding(_ keyPath: WritableKeyPath<ContentItem, String>) -> Binding<String> {
Binding(
get: { self.editorItem[keyPath: keyPath] },
set: {
self.editorItem[keyPath: keyPath] = $0
self.markDirty()
}
)
}
func boolBinding(_ keyPath: WritableKeyPath<ContentItem, Bool>) -> Binding<Bool> {
Binding(
get: { self.editorItem[keyPath: keyPath] },
set: {
self.editorItem[keyPath: keyPath] = $0
self.markDirty()
}
)
}
var tagsBinding: Binding<String> {
Binding(
get: { self.editorItem.tags.joined(separator: ", ") },
set: {
self.editorItem.tags = $0
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
self.markDirty()
}
)
}
var markdownBinding: Binding<String> {
Binding(
get: { self.editorMarkdown },
set: {
self.editorMarkdown = $0
self.markDirty()
}
)
}
func restoreDraft(_ draft: DraftRecord) {
editorItem = draft.item
editorMarkdown = draft.markdown
originalSlug = draft.item.slug
selectedSlug = draft.item.slug
section = draft.item.type == "page" ? .pages : .posts
markDirty()
message = "Restored local autosave from \(draft.savedAt.formatted())."
}
private func autosave() {
guard isDirty else {
return
}
do {
try draftStore.save(item: editorItem, markdown: editorMarkdown, serverURL: baseURLString)
lastAutosave = Date()
localDrafts = draftStore.loadAll(serverURL: baseURLString)
message = "Local autosave complete."
} catch {
message = "Autosave failed: \(error.localizedDescription)"
}
if remoteAutosaveDraft {
Task {
var draft = editorItem
draft.status = "draft"
do {
let saved = try await requireClient().saveContent(draft, originalSlug: originalSlug, markdown: editorMarkdown)
editorItem = saved
editorMarkdown = saved.markdown
originalSlug = saved.slug
selectedSlug = saved.slug
message = "Remote draft autosaved."
} catch {
message = "Remote autosave failed: \(error.localizedDescription)"
}
}
}
}
private func restartAutosaveTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: max(10, autosaveInterval), repeats: true) { [weak self] _ in
Task { @MainActor in
self?.autosave()
}
}
}
private func requireClient() throws -> TCMSAPIClient {
guard let client else {
throw TCMSClientError.missingServer
}
return client
}
private func run(_ successMessage: String, operation: @MainActor @escaping () async throws -> Void) async {
isBusy = true
defer { isBusy = false }
do {
try await operation()
if !successMessage.isEmpty {
message = successMessage
}
} catch {
message = error.localizedDescription
}
}
}