- API, Mac client
This commit is contained in:
@@ -3,7 +3,7 @@ Options -Indexes
|
|||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
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]
|
RewriteRule ^bl-content/(databases|pages|tmp)(/|$) - [F,L]
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ All notable changes to Ty Clifford's Content Management System are documented he
|
|||||||
|
|
||||||
### Added
|
### 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=<id>]` and `[video poster=<poster> file=<file>]`.
|
- Added a declared shortcode parser with PHP-array/JSON configuration, including `[youtube=<id>]` and `[video poster=<poster> file=<file>]`.
|
||||||
- Added `lone-embed.php` as the generated HTML5 video player target for video shortcodes.
|
- 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
|
## 2026-07-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ php cli/blog.php delete old-slug
|
|||||||
php cli/blog.php rebuild
|
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
|
||||||
|
|
||||||
Comments are moderated by default and protected by captcha plus a honeypot field.
|
Comments are moderated by default and protected by captcha plus a honeypot field.
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use NeonBlog\ContentRepository;
|
||||||
|
use NeonBlog\View;
|
||||||
|
|
||||||
|
$app = require __DIR__ . '/app/bootstrap.php';
|
||||||
|
$repo = $app->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<string, mixed> */
|
||||||
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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';
|
||||||
|
}
|
||||||
@@ -156,6 +156,11 @@ final class Config
|
|||||||
'enabled' => true,
|
'enabled' => true,
|
||||||
'hash_salt' => 'change-me',
|
'hash_salt' => 'change-me',
|
||||||
],
|
],
|
||||||
|
'api' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'token' => '',
|
||||||
|
'allow_local_without_token' => true,
|
||||||
|
],
|
||||||
'social' => [],
|
'social' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserdata/
|
||||||
|
*.xcuserstate
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
@@ -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<Response: Decodable>(
|
||||||
|
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<Body: Encodable, Response: Decodable>(
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TCMSMacClientApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.frame(minWidth: 1040, minHeight: 680)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user