325 lines
11 KiB
Swift
325 lines
11 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|