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) -> Binding { Binding( get: { self.editorItem[keyPath: keyPath] }, set: { self.editorItem[keyPath: keyPath] = $0 self.markDirty() } ) } func boolBinding(_ keyPath: WritableKeyPath) -> Binding { Binding( get: { self.editorItem[keyPath: keyPath] }, set: { self.editorItem[keyPath: keyPath] = $0 self.markDirty() } ) } var tagsBinding: Binding { 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 { 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 } } }