diff --git a/.htaccess b/.htaccess index e1c37fb..fb82cc7 100644 --- a/.htaccess +++ b/.htaccess @@ -3,7 +3,7 @@ Options -Indexes RewriteEngine On - RewriteRule ^(app|cli|config|storage)(/|$) - [F,L] + RewriteRule ^(app|cli|config|macclient|storage)(/|$) - [F,L] RewriteRule ^bl-content/(databases|pages|tmp)(/|$) - [F,L] RewriteCond %{REQUEST_FILENAME} -f [OR] diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3b8db..66e5ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,15 @@ All notable changes to Ty Clifford's Content Management System are documented he ### Added +- Added `api.php` for authenticated remote management of content, media uploads, and comments. +- Added a native SwiftUI macOS 12+ client under `macclient/` for editing posts/pages, uploading media, moderating comments, local autosave, and optional remote draft autosave. - Added a declared shortcode parser with PHP-array/JSON configuration, including `[youtube=]` and `[video poster= file=]`. - Added `lone-embed.php` as the generated HTML5 video player target for video shortcodes. +### Changed + +- Blocked direct web access to the `macclient/` source folder through `.htaccess`. + ## 2026-07-04 ### Added diff --git a/README.md b/README.md index 635ad35..0d0f2d3 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,31 @@ php cli/blog.php delete old-slug php cli/blog.php rebuild ``` +## Mac Client and API + +The native macOS 12+ client lives in `macclient/`. + +```bash +cd macclient +swift run TCMSMacClient +``` + +Point the client at any TCMS site URL. It will call `api.php` at that site for posts, pages, media, comments, local autosave restore, and optional remote draft autosave. + +For remote servers, set an API token in `config/site.json` or through the `TCMS_API_TOKEN` environment variable: + +```json +{ + "api": { + "enabled": true, + "token": "replace-with-a-long-random-token", + "allow_local_without_token": true + } +} +``` + +Send the token as a Bearer token or `X-TCMS-Token`. Localhost requests may use the tokenless development fallback while `allow_local_without_token` is enabled. + ## Comments Comments are moderated by default and protected by captcha plus a honeypot field. diff --git a/api.php b/api.php new file mode 100644 index 0000000..d8651fc --- /dev/null +++ b/api.php @@ -0,0 +1,394 @@ +repository(); +$comments = $app->comments(); +$config = $app->config(); + +header('Content-Type: application/json; charset=utf-8'); + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { + tcms_json(['ok' => true]); +} + +$payload = tcms_payload(); +$action = (string) ($_GET['action'] ?? $payload['action'] ?? ''); + +try { + if ($action === '' || $action === 'ping') { + tcms_json([ + 'ok' => true, + 'name' => 'Ty Clifford Content Management System API', + 'site' => $config->get('site', []), + 'authenticated' => tcms_is_authenticated($config), + 'token_configured' => tcms_configured_token($config) !== '', + 'capabilities' => [ + 'content' => ['list', 'get', 'save', 'delete'], + 'media' => ['list', 'upload'], + 'comments' => ['list', 'status', 'delete'], + ], + ]); + } + + if (!$config->get('api.enabled', true)) { + tcms_error('The API is disabled for this TCMS site.', 403); + } + tcms_require_auth($config); + + match ($action) { + 'content.list' => tcms_content_list($repo), + 'content.get' => tcms_content_get($repo), + 'content.save' => tcms_content_save($repo, $payload), + 'content.delete' => tcms_content_delete($repo, $payload), + 'media.list' => tcms_media_list(), + 'media.upload' => tcms_media_upload($payload), + 'comments.list' => tcms_comments_list($comments), + 'comments.status' => tcms_comments_status($comments, $payload), + 'comments.delete' => tcms_comments_delete($comments, $payload), + default => tcms_error('Unknown API action: ' . $action, 404), + }; +} catch (Throwable $error) { + tcms_error($error->getMessage(), 500); +} + +/** @return array */ +function tcms_payload(): array +{ + $contentType = (string) ($_SERVER['CONTENT_TYPE'] ?? ''); + if (str_contains($contentType, 'application/json')) { + $decoded = json_decode((string) file_get_contents('php://input'), true); + return is_array($decoded) ? $decoded : []; + } + + return $_POST; +} + +/** @param array $data */ +function tcms_json(array $data, int $status = 200): never +{ + http_response_code($status); + echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL; + exit; +} + +/** @param array $extra */ +function tcms_error(string $message, int $status = 400, array $extra = []): never +{ + tcms_json(['ok' => false, 'error' => $message] + $extra, $status); +} + +function tcms_require_auth($config): void +{ + if (!tcms_is_authenticated($config)) { + tcms_error('API authentication failed. Send the configured token as a Bearer token or X-TCMS-Token header.', 401); + } +} + +function tcms_is_authenticated($config): bool +{ + $configured = tcms_configured_token($config); + if ($configured === '') { + return (bool) $config->get('api.allow_local_without_token', true) && tcms_is_local_request(); + } + + $provided = tcms_request_token(); + return $provided !== '' && hash_equals($configured, $provided); +} + +function tcms_configured_token($config): string +{ + $envToken = trim((string) getenv('TCMS_API_TOKEN')); + return $envToken !== '' ? $envToken : trim((string) $config->get('api.token', '')); +} + +function tcms_request_token(): string +{ + $headers = function_exists('getallheaders') ? getallheaders() : []; + $auth = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $headers['Authorization'] ?? $headers['authorization'] ?? ''); + if (preg_match('/^Bearer\s+(.+)$/i', $auth, $matches)) { + return trim($matches[1]); + } + + return trim((string) ($_SERVER['HTTP_X_TCMS_TOKEN'] ?? $headers['X-TCMS-Token'] ?? $headers['x-tcms-token'] ?? '')); +} + +function tcms_is_local_request(): bool +{ + $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + return in_array($remote, ['127.0.0.1', '::1', 'localhost', ''], true); +} + +function tcms_content_list(ContentRepository $repo): never +{ + $includeDrafts = filter_var($_GET['include_drafts'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + $includeDrafts = $includeDrafts ?? true; + $type = (string) ($_GET['type'] ?? 'all'); + $items = match ($type) { + 'post', 'posts' => $repo->posts($includeDrafts), + 'page', 'pages' => $repo->pages($includeDrafts), + default => $repo->all($includeDrafts), + }; + + tcms_json([ + 'ok' => true, + 'items' => array_map(static fn (array $item): array => tcms_item($item, false), $items), + ]); +} + +function tcms_content_get(ContentRepository $repo): never +{ + $slug = (string) ($_GET['slug'] ?? ''); + if ($slug === '') { + tcms_error('A slug is required.'); + } + $item = $repo->find($slug, true); + if ($item === null) { + tcms_error('Content not found: ' . $slug, 404); + } + + tcms_json(['ok' => true, 'item' => tcms_item($item, true)]); +} + +/** @param array $payload */ +function tcms_content_save(ContentRepository $repo, array $payload): never +{ + $metadata = is_array($payload['metadata'] ?? null) ? $payload['metadata'] : []; + $slug = (string) ($payload['slug'] ?? $metadata['slug'] ?? ''); + $originalSlug = (string) ($payload['original_slug'] ?? $payload['originalSlug'] ?? ''); + $existing = $originalSlug !== '' ? $repo->find($originalSlug, true) : ($slug !== '' ? $repo->find($slug, true) : null); + $markdown = array_key_exists('markdown', $payload) ? (string) $payload['markdown'] : (string) ($existing['markdown'] ?? ''); + + $allowed = [ + 'title', 'slug', 'type', 'status', 'date', 'category', 'tags', 'summary', + 'author', 'cover', 'allow_comments', 'menu_order', + ]; + $clean = []; + foreach ($allowed as $key) { + if (array_key_exists($key, $metadata)) { + $clean[$key] = $metadata[$key]; + } + } + if (isset($payload['status'])) { + $clean['status'] = (string) $payload['status']; + } + if (isset($clean['tags'])) { + $clean['tags'] = ContentRepository::normalizeList($clean['tags']); + } + + $slug = (string) ($clean['slug'] ?? $slug); + $saved = $repo->save($slug, $clean, $markdown); + if ($originalSlug !== '' && $originalSlug !== $saved['slug']) { + $repo->delete($originalSlug); + } + + tcms_json(['ok' => true, 'item' => tcms_item($saved, true)]); +} + +/** @param array $payload */ +function tcms_content_delete(ContentRepository $repo, array $payload): never +{ + $slug = (string) ($_GET['slug'] ?? $payload['slug'] ?? ''); + if ($slug === '') { + tcms_error('A slug is required.'); + } + + tcms_json(['ok' => $repo->delete($slug)]); +} + +/** @param array $item */ +function tcms_item(array $item, bool $includeBody): array +{ + $record = [ + 'slug' => (string) ($item['slug'] ?? ''), + 'title' => (string) ($item['title'] ?? ''), + 'type' => (string) ($item['type'] ?? 'post'), + 'status' => (string) ($item['status'] ?? 'draft'), + 'date' => (string) ($item['date'] ?? ''), + 'modified' => (string) ($item['modified'] ?? ''), + 'category' => (string) ($item['category'] ?? ''), + 'tags' => array_values((array) ($item['tags'] ?? [])), + 'summary' => (string) ($item['summary'] ?? ''), + 'author' => (string) ($item['author'] ?? ''), + 'cover' => (string) ($item['cover'] ?? ''), + 'allow_comments' => (bool) ($item['allow_comments'] ?? true), + 'menu_order' => (int) ($item['menu_order'] ?? 0), + 'url' => View::itemUrl($item), + 'excerpt' => (string) ($item['excerpt'] ?? ''), + ]; + if ($includeBody) { + $record['markdown'] = (string) ($item['markdown'] ?? ''); + } + + return $record; +} + +function tcms_media_list(): never +{ + $root = BLOG_ROOT . '/bl-content/uploads'; + $items = []; + if (is_dir($root)) { + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + if (!$file->isFile() || str_starts_with($file->getFilename(), '.')) { + continue; + } + $relative = ltrim(str_replace('\\', '/', substr($file->getPathname(), strlen($root))), '/'); + $items[] = [ + 'name' => $file->getFilename(), + 'path' => 'bl-content/uploads/' . $relative, + 'url' => View::url('bl-content/uploads/' . $relative), + 'size' => $file->getSize(), + 'modified' => date('c', $file->getMTime()), + 'mime' => tcms_mime($file->getPathname()), + ]; + } + } + + usort($items, static fn (array $a, array $b): int => strcmp((string) $b['modified'], (string) $a['modified'])); + tcms_json(['ok' => true, 'items' => $items]); +} + +/** @param array $payload */ +function tcms_media_upload(array $payload): never +{ + $folder = tcms_safe_folder((string) ($_POST['folder'] ?? $payload['folder'] ?? '')); + $uploads = BLOG_ROOT . '/bl-content/uploads'; + $targetDir = $uploads . ($folder !== '' ? '/' . $folder : ''); + if (!is_dir($targetDir)) { + mkdir($targetDir, 0775, true); + } + + if (isset($_FILES['file']) && is_array($_FILES['file'])) { + $upload = $_FILES['file']; + if ((int) ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + tcms_error('Upload failed with code ' . (int) $upload['error']); + } + $name = tcms_safe_filename((string) ($upload['name'] ?? 'upload.bin')); + $target = tcms_unique_media_path($targetDir . '/' . $name); + if (!move_uploaded_file((string) $upload['tmp_name'], $target)) { + tcms_error('Unable to store uploaded file.', 500); + } + } else { + $name = tcms_safe_filename((string) ($payload['filename'] ?? 'upload.bin')); + $data = base64_decode((string) ($payload['data_base64'] ?? ''), true); + if ($data === false) { + tcms_error('A multipart file or base64 payload is required.'); + } + $target = tcms_unique_media_path($targetDir . '/' . $name); + file_put_contents($target, $data); + } + + $relative = ltrim(str_replace('\\', '/', substr($target, strlen($uploads))), '/'); + tcms_json([ + 'ok' => true, + 'item' => [ + 'name' => basename($target), + 'path' => 'bl-content/uploads/' . $relative, + 'url' => View::url('bl-content/uploads/' . $relative), + 'size' => filesize($target) ?: 0, + 'modified' => date('c', filemtime($target) ?: time()), + 'mime' => tcms_mime($target), + ], + ]); +} + +function tcms_comments_list($comments): never +{ + $status = (string) ($_GET['status'] ?? ''); + $slug = (string) ($_GET['slug'] ?? ''); + tcms_json([ + 'ok' => true, + 'items' => $comments->list($status !== '' ? $status : null, $slug !== '' ? $slug : null), + ]); +} + +/** @param array $payload */ +function tcms_comments_status($comments, array $payload): never +{ + $id = (int) ($_GET['id'] ?? $payload['id'] ?? 0); + $status = (string) ($_GET['status'] ?? $payload['status'] ?? ''); + if ($id < 1 || $status === '') { + tcms_error('A comment id and status are required.'); + } + + tcms_json(['ok' => $comments->setStatus($id, $status)]); +} + +/** @param array $payload */ +function tcms_comments_delete($comments, array $payload): never +{ + $id = (int) ($_GET['id'] ?? $payload['id'] ?? 0); + if ($id < 1) { + tcms_error('A comment id is required.'); + } + + tcms_json(['ok' => $comments->delete($id)]); +} + +function tcms_safe_folder(string $folder): string +{ + $folder = trim(str_replace('\\', '/', $folder), '/'); + if ($folder === '') { + return ''; + } + + $segments = []; + foreach (explode('/', $folder) as $segment) { + $segment = trim(preg_replace('/[^A-Za-z0-9._-]+/', '-', $segment) ?? '', '.-'); + if ($segment !== '') { + $segments[] = $segment; + } + } + + return implode('/', $segments); +} + +function tcms_safe_filename(string $name): string +{ + $name = basename(str_replace('\\', '/', $name)); + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + $blocked = ['php', 'phtml', 'phar', 'cgi', 'pl', 'asp', 'aspx', 'jsp', 'sh']; + if ($extension === '' || in_array($extension, $blocked, true)) { + tcms_error('That file type is not allowed for media uploads.'); + } + + $base = pathinfo($name, PATHINFO_FILENAME); + $base = trim(preg_replace('/[^A-Za-z0-9._-]+/', '-', $base) ?? 'upload', '.-'); + return ($base !== '' ? $base : 'upload') . '.' . $extension; +} + +function tcms_unique_media_path(string $path): string +{ + if (!file_exists($path)) { + return $path; + } + + $dir = dirname($path); + $name = pathinfo($path, PATHINFO_FILENAME); + $extension = pathinfo($path, PATHINFO_EXTENSION); + for ($i = 2; $i < 10000; $i++) { + $candidate = $dir . '/' . $name . '-' . $i . '.' . $extension; + if (!file_exists($candidate)) { + return $candidate; + } + } + + tcms_error('Unable to find an available filename.', 500); +} + +function tcms_mime(string $path): string +{ + if (class_exists('finfo')) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mime = $finfo->file($path); + if (is_string($mime) && $mime !== '') { + return $mime; + } + } + + return 'application/octet-stream'; +} diff --git a/app/Config.php b/app/Config.php index 72d4ebd..b3666f7 100644 --- a/app/Config.php +++ b/app/Config.php @@ -156,6 +156,11 @@ final class Config 'enabled' => true, 'hash_salt' => 'change-me', ], + 'api' => [ + 'enabled' => true, + 'token' => '', + 'allow_local_without_token' => true, + ], 'social' => [], ]; } diff --git a/macclient/.gitignore b/macclient/.gitignore new file mode 100644 index 0000000..8b1a46e --- /dev/null +++ b/macclient/.gitignore @@ -0,0 +1,4 @@ +.build/ +DerivedData/ +*.xcuserdata/ +*.xcuserstate diff --git a/macclient/Package.swift b/macclient/Package.swift new file mode 100644 index 0000000..a4ede13 --- /dev/null +++ b/macclient/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 5.5 + +import PackageDescription + +let package = Package( + name: "TCMSMacClient", + platforms: [ + .macOS(.v12) + ], + products: [ + .executable(name: "TCMSMacClient", targets: ["TCMSMacClient"]) + ], + targets: [ + .executableTarget( + name: "TCMSMacClient", + path: "Sources/TCMSMacClient" + ) + ] +) diff --git a/macclient/README.md b/macclient/README.md new file mode 100644 index 0000000..b60af5a --- /dev/null +++ b/macclient/README.md @@ -0,0 +1,32 @@ +# TCMS Mac Client + +Native SwiftUI macOS 12+ client for Ty Clifford's Content Management System. + +## Run + +```bash +cd macclient +swift run TCMSMacClient +``` + +You can also open this folder in Xcode and run the `TCMSMacClient` package target. + +## Connect + +Point the client at any TCMS install URL, such as: + +```text +https://example.com/blog +http://127.0.0.1:8097 +``` + +The client automatically calls `api.php` under that URL. If the server has an API token configured, paste it in Settings. Local development can use TCMS' localhost fallback when `api.token` is blank and `api.allow_local_without_token` is enabled. + +## Features + +- Create, edit, draft, publish, and delete posts and pages. +- Upload media to `bl-content/uploads`. +- List and moderate comments. +- Local autosave to Application Support. +- Optional remote autosave as draft. +- Configurable autosave interval. diff --git a/macclient/Sources/TCMSMacClient/APIClient.swift b/macclient/Sources/TCMSMacClient/APIClient.swift new file mode 100644 index 0000000..433cb50 --- /dev/null +++ b/macclient/Sources/TCMSMacClient/APIClient.swift @@ -0,0 +1,254 @@ +import Foundation + +struct TCMSAPIClient { + var baseURL: URL + var token: String + + private var endpointURL: URL { + if baseURL.lastPathComponent.lowercased() == "api.php" { + return baseURL + } + return baseURL.appendingPathComponent("api.php") + } + + private var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + private var decoder: JSONDecoder { + JSONDecoder() + } + + func ping() async throws -> PingResponse { + try await request(action: "ping") + } + + func listContent(type: String, includeDrafts: Bool = true) async throws -> [ContentItem] { + let response: ContentListResponse = try await request( + action: "content.list", + query: [ + URLQueryItem(name: "type", value: type), + URLQueryItem(name: "include_drafts", value: includeDrafts ? "1" : "0") + ] + ) + return response.items + } + + func getContent(slug: String) async throws -> ContentItem { + let response: ContentItemResponse = try await request( + action: "content.get", + query: [URLQueryItem(name: "slug", value: slug)] + ) + return response.item + } + + func saveContent(_ item: ContentItem, originalSlug: String, markdown: String) async throws -> ContentItem { + let body = ContentSaveRequest( + originalSlug: originalSlug, + slug: item.slug, + markdown: markdown, + metadata: ContentMetadata(item: item) + ) + let response: ContentItemResponse = try await request(action: "content.save", method: "POST", body: body) + return response.item + } + + func deleteContent(slug: String) async throws -> Bool { + let body = SlugRequest(slug: slug) + let response: BasicResponse = try await request(action: "content.delete", method: "POST", body: body) + return response.ok + } + + func listMedia() async throws -> [MediaItem] { + let response: MediaListResponse = try await request(action: "media.list") + return response.items + } + + func uploadMedia(fileURL: URL, folder: String = "") async throws -> MediaItem { + var request = try makeRequest(action: "media.upload", method: "POST") + let boundary = "TCMSBoundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + + var body = Data() + if !folder.isEmpty { + body.appendMultipartField(name: "folder", value: folder, boundary: boundary) + } + let data = try Data(contentsOf: fileURL) + body.appendMultipartFile( + name: "file", + filename: fileURL.lastPathComponent, + mimeType: Self.mimeType(for: fileURL), + data: data, + boundary: boundary + ) + body.appendString("--\(boundary)--\r\n") + + let (responseData, response) = try await URLSession.shared.upload(for: request, from: body) + try validate(responseData: responseData, response: response) + return try decoder.decode(MediaItemResponse.self, from: responseData).item + } + + func listComments(status: String, slug: String = "") async throws -> [CommentItem] { + var query = [URLQueryItem]() + if status != "all" { + query.append(URLQueryItem(name: "status", value: status)) + } + if !slug.isEmpty { + query.append(URLQueryItem(name: "slug", value: slug)) + } + let response: CommentListResponse = try await request(action: "comments.list", query: query) + return response.items + } + + func setCommentStatus(id: Int, status: String) async throws -> Bool { + let response: BasicResponse = try await request( + action: "comments.status", + method: "POST", + body: CommentStatusRequest(id: id, status: status) + ) + return response.ok + } + + func deleteComment(id: Int) async throws -> Bool { + let response: BasicResponse = try await request( + action: "comments.delete", + method: "POST", + body: CommentDeleteRequest(id: id) + ) + return response.ok + } + + private func request( + action: String, + query: [URLQueryItem] = [], + method: String = "GET" + ) async throws -> Response { + let request = try makeRequest(action: action, query: query, method: method) + let (data, response) = try await URLSession.shared.data(for: request) + try validate(responseData: data, response: response) + return try decoder.decode(Response.self, from: data) + } + + private func request( + action: String, + query: [URLQueryItem] = [], + method: String = "POST", + body: Body + ) async throws -> Response { + var request = try makeRequest(action: action, query: query, method: method) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try encoder.encode(body) + let (data, response) = try await URLSession.shared.data(for: request) + try validate(responseData: data, response: response) + return try decoder.decode(Response.self, from: data) + } + + private func makeRequest(action: String, query: [URLQueryItem] = [], method: String) throws -> URLRequest { + guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else { + throw TCMSClientError.invalidURL + } + components.queryItems = [URLQueryItem(name: "action", value: action)] + query + guard let url = components.url else { + throw TCMSClientError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 30 + request.setValue("application/json", forHTTPHeaderField: "Accept") + if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return request + } + + private func validate(responseData: Data, response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + return + } + guard (200..<300).contains(http.statusCode) else { + let decoded = try? decoder.decode(APIErrorResponse.self, from: responseData) + throw TCMSClientError.server(decoded?.error ?? "HTTP \(http.statusCode)") + } + } + + private static func mimeType(for url: URL) -> String { + switch url.pathExtension.lowercased() { + case "jpg", "jpeg": return "image/jpeg" + case "png": return "image/png" + case "gif": return "image/gif" + case "webp": return "image/webp" + case "svg": return "image/svg+xml" + case "mp4", "m4v": return "video/mp4" + case "webm": return "video/webm" + case "mp3": return "audio/mpeg" + case "wav": return "audio/wav" + case "pdf": return "application/pdf" + default: return "application/octet-stream" + } + } +} + +enum TCMSClientError: LocalizedError { + case invalidURL + case server(String) + case missingServer + + var errorDescription: String? { + switch self { + case .invalidURL: + return "The TCMS URL is not valid." + case .server(let message): + return message + case .missingServer: + return "Set a TCMS URL in Settings first." + } + } +} + +private struct ContentSaveRequest: Encodable { + var originalSlug: String + var slug: String + var markdown: String + var metadata: ContentMetadata + + enum CodingKeys: String, CodingKey { + case originalSlug = "original_slug" + case slug, markdown, metadata + } +} + +private struct SlugRequest: Encodable { + var slug: String +} + +private struct CommentStatusRequest: Encodable { + var id: Int + var status: String +} + +private struct CommentDeleteRequest: Encodable { + var id: Int +} + +private extension Data { + mutating func appendString(_ string: String) { + append(Data(string.utf8)) + } + + mutating func appendMultipartField(name: String, value: String, boundary: String) { + appendString("--\(boundary)\r\n") + appendString("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n") + appendString("\(value)\r\n") + } + + mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) { + appendString("--\(boundary)\r\n") + appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n") + appendString("Content-Type: \(mimeType)\r\n\r\n") + append(data) + appendString("\r\n") + } +} diff --git a/macclient/Sources/TCMSMacClient/BlogClientModel.swift b/macclient/Sources/TCMSMacClient/BlogClientModel.swift new file mode 100644 index 0000000..576e87b --- /dev/null +++ b/macclient/Sources/TCMSMacClient/BlogClientModel.swift @@ -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) -> 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 + } + } +} diff --git a/macclient/Sources/TCMSMacClient/ContentView.swift b/macclient/Sources/TCMSMacClient/ContentView.swift new file mode 100644 index 0000000..3cdcaaa --- /dev/null +++ b/macclient/Sources/TCMSMacClient/ContentView.swift @@ -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() + } +} diff --git a/macclient/Sources/TCMSMacClient/DraftStore.swift b/macclient/Sources/TCMSMacClient/DraftStore.swift new file mode 100644 index 0000000..1f92be7 --- /dev/null +++ b/macclient/Sources/TCMSMacClient/DraftStore.swift @@ -0,0 +1,65 @@ +import Foundation + +struct DraftRecord: Codable, Identifiable { + var id: String { item.slug.isEmpty ? item.title : item.slug } + var serverURL: String + var savedAt: Date + var item: ContentItem + var markdown: String +} + +final class DraftStore { + private let folder: URL + + init() { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + folder = base.appendingPathComponent("TCMS Mac Client", isDirectory: true) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } + + func save(item: ContentItem, markdown: String, serverURL: String) throws { + let record = DraftRecord(serverURL: serverURL, savedAt: Date(), item: item, markdown: markdown) + let data = try JSONEncoder.tcms.encode(record) + try data.write(to: fileURL(for: record.id), options: .atomic) + } + + func remove(id: String) { + try? FileManager.default.removeItem(at: fileURL(for: id)) + } + + func loadAll(serverURL: String) -> [DraftRecord] { + let urls = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [] + return urls.compactMap { url in + guard let data = try? Data(contentsOf: url), + let record = try? JSONDecoder.tcms.decode(DraftRecord.self, from: data), + record.serverURL == serverURL else { + return nil + } + return record + } + .sorted { $0.savedAt > $1.savedAt } + } + + private func fileURL(for id: String) -> URL { + let safe = id.replacingOccurrences(of: "[^A-Za-z0-9._-]+", with: "-", options: .regularExpression) + return folder.appendingPathComponent(safe.isEmpty ? "untitled" : safe).appendingPathExtension("json") + } +} + +private extension JSONEncoder { + static var tcms: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +private extension JSONDecoder { + static var tcms: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/macclient/Sources/TCMSMacClient/Models.swift b/macclient/Sources/TCMSMacClient/Models.swift new file mode 100644 index 0000000..191fba0 --- /dev/null +++ b/macclient/Sources/TCMSMacClient/Models.swift @@ -0,0 +1,194 @@ +import Foundation + +struct ContentItem: Codable, Identifiable, Hashable { + var id: String { slug.isEmpty ? title : slug } + var slug: String + var title: String + var type: String + var status: String + var date: String + var modified: String + var category: String + var tags: [String] + var summary: String + var author: String + var cover: String + var allowComments: Bool + var menuOrder: Int + var url: String + var excerpt: String + var markdown: String + + init( + slug: String = "", + title: String = "Untitled", + type: String = "post", + status: String = "draft", + date: String = ISO8601DateFormatter().string(from: Date()), + modified: String = "", + category: String = "Notes", + tags: [String] = [], + summary: String = "", + author: String = "Editor", + cover: String = "", + allowComments: Bool = true, + menuOrder: Int = 0, + url: String = "", + excerpt: String = "", + markdown: String = "# Untitled\n\nStart writing here." + ) { + self.slug = slug + self.title = title + self.type = type + self.status = status + self.date = date + self.modified = modified + self.category = category + self.tags = tags + self.summary = summary + self.author = author + self.cover = cover + self.allowComments = allowComments + self.menuOrder = menuOrder + self.url = url + self.excerpt = excerpt + self.markdown = markdown + } + + enum CodingKeys: String, CodingKey { + case slug, title, type, status, date, modified, category, tags, summary, author, cover, url, excerpt, markdown + case allowComments = "allow_comments" + case menuOrder = "menu_order" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + slug = try container.decodeIfPresent(String.self, forKey: .slug) ?? "" + title = try container.decodeIfPresent(String.self, forKey: .title) ?? "Untitled" + type = try container.decodeIfPresent(String.self, forKey: .type) ?? "post" + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "draft" + date = try container.decodeIfPresent(String.self, forKey: .date) ?? "" + modified = try container.decodeIfPresent(String.self, forKey: .modified) ?? "" + category = try container.decodeIfPresent(String.self, forKey: .category) ?? "Notes" + tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] + summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? "" + author = try container.decodeIfPresent(String.self, forKey: .author) ?? "Editor" + cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? "" + allowComments = try container.decodeIfPresent(Bool.self, forKey: .allowComments) ?? true + menuOrder = try container.decodeIfPresent(Int.self, forKey: .menuOrder) ?? 0 + url = try container.decodeIfPresent(String.self, forKey: .url) ?? "" + excerpt = try container.decodeIfPresent(String.self, forKey: .excerpt) ?? "" + markdown = try container.decodeIfPresent(String.self, forKey: .markdown) ?? "" + } +} + +struct ContentMetadata: Codable { + var title: String + var slug: String + var type: String + var status: String + var date: String + var category: String + var tags: [String] + var summary: String + var author: String + var cover: String + var allowComments: Bool + var menuOrder: Int + + init(item: ContentItem) { + title = item.title + slug = item.slug + type = item.type + status = item.status + date = item.date + category = item.category + tags = item.tags + summary = item.summary + author = item.author + cover = item.cover + allowComments = item.allowComments + menuOrder = item.menuOrder + } + + enum CodingKeys: String, CodingKey { + case title, slug, type, status, date, category, tags, summary, author, cover + case allowComments = "allow_comments" + case menuOrder = "menu_order" + } +} + +struct MediaItem: Codable, Identifiable, Hashable { + var id: String { path } + var name: String + var path: String + var url: String + var size: Int + var modified: String + var mime: String +} + +struct CommentItem: Codable, Identifiable, Hashable { + var id: Int + var slug: String + var author: String + var email: String + var website: String + var body: String + var status: String + var createdAt: String + var updatedAt: String + + enum CodingKeys: String, CodingKey { + case id, slug, author, email, website, body, status + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +struct PingResponse: Codable { + var ok: Bool + var name: String + var authenticated: Bool + var tokenConfigured: Bool + + enum CodingKeys: String, CodingKey { + case ok, name, authenticated + case tokenConfigured = "token_configured" + } +} + +struct ContentListResponse: Codable { + var ok: Bool + var items: [ContentItem] +} + +struct ContentItemResponse: Codable { + var ok: Bool + var item: ContentItem +} + +struct MediaListResponse: Codable { + var ok: Bool + var items: [MediaItem] +} + +struct MediaItemResponse: Codable { + var ok: Bool + var item: MediaItem +} + +struct CommentListResponse: Codable { + var ok: Bool + var items: [CommentItem] +} + +struct BasicResponse: Codable { + var ok: Bool + var error: String? +} + +struct APIErrorResponse: Codable { + var ok: Bool? + var error: String? +} diff --git a/macclient/Sources/TCMSMacClient/TCMSMacClientApp.swift b/macclient/Sources/TCMSMacClient/TCMSMacClientApp.swift new file mode 100644 index 0000000..ebb69ff --- /dev/null +++ b/macclient/Sources/TCMSMacClient/TCMSMacClientApp.swift @@ -0,0 +1,11 @@ +import SwiftUI + +@main +struct TCMSMacClientApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 1040, minHeight: 680) + } + } +}