- Added native confirmation sheets in the Mac client.

Manual content actions now confirm clearly:
Draft shows “Draft Saved”
Publish shows “Published”
Update shows “Updated”
Delete first asks “Delete Post/Page?” and then shows “Deleted” after the 
API succeeds
Autosave stays quiet so it does not spam popups.
Updated:
[macclient/AppKitClient/main.m](/Users/tyemeclifford/Documents/GH/blog/macclient/AppKitClient/main.m)
[macclient/README.md](/Users/tyemeclifford/Documents/GH/blog/macclient/README.md)
[CHANGELOG.md](/Users/tyemeclifford/Documents/GH/blog/CHANGELOG.md)
This commit is contained in:
Ty Clifford
2026-07-05 21:22:17 -04:00
parent 3e7cea4491
commit df56bfd57c
8 changed files with 366 additions and 38 deletions
+4
View File
@@ -10,6 +10,9 @@ All notable changes to Ty Clifford's Content Management System are documented he
- Added a native AppKit macOS 12+ client under `macclient/` for editing posts/pages, uploading media, moderating comments, local autosave, and optional remote draft autosave. - Added a native AppKit macOS 12+ client under `macclient/` for editing posts/pages, uploading media, moderating comments, local autosave, and optional remote draft autosave.
- Added Mac client post/page list pagination with configurable per-page counts. - Added Mac client post/page list pagination with configurable per-page counts.
- Added Mac client media pagination plus native image preview and audio/video playback. - Added Mac client media pagination plus native image preview and audio/video playback.
- Added Mac client editor-screen media upload with automatic Markdown/shortcode insertion.
- Added Mac client Media context-menu actions to copy a file URL or a ready-to-paste Markdown embed.
- Added Mac client confirmation sheets for saving drafts, publishing, updating, and deleting posts/pages.
- Added Mac client editor spell-check and auto-correct options. - Added Mac client editor spell-check and auto-correct options.
- Added a Mac client Update action for saving existing posts/pages without changing their current draft/published status. - Added a Mac client Update action for saving existing posts/pages without changing their current draft/published status.
- Added a public post lightbox slideshow for images, native videos, video embeds, and direct media links within the same post. - Added a public post lightbox slideshow for images, native videos, video embeds, and direct media links within the same post.
@@ -19,6 +22,7 @@ All notable changes to Ty Clifford's Content Management System are documented he
### Changed ### Changed
- Simplified API and Mac client authentication to `.env` username/password only, removing token-based auth. - Simplified API and Mac client authentication to `.env` username/password only, removing token-based auth.
- Rebranded the native Mac client bundle and menus to `TCMS`.
- Fixed Mac client resizing, maximized editor text reflow, startup layout refresh, guarded undo/redo behavior, keyboard focus traversal, and standard Command-key shortcuts including Settings and Update. - Fixed Mac client resizing, maximized editor text reflow, startup layout refresh, guarded undo/redo behavior, keyboard focus traversal, and standard Command-key shortcuts including Settings and Update.
- Fixed Mac client Update so edited Markdown is read from the live text storage and stale save responses no longer overwrite the editor. - Fixed Mac client Update so edited Markdown is read from the live text storage and stale save responses no longer overwrite the editor.
- Expanded the API content save endpoint to accept `markdown`, `content`, or `body` payload fields for editor content. - Expanded the API content save endpoint to accept `markdown`, `content`, or `body` payload fields for editor content.
+1 -1
View File
@@ -49,7 +49,7 @@ The native AppKit macOS 12+ client lives in `macclient/`.
```bash ```bash
cd macclient cd macclient
./build-app.sh ./build-app.sh
open TCMSMacClient.app open TCMS.app
``` ```
Point the client at any TCMS site URL. It will call `api.php` at that site for posts, pages, media, comments, local autosave, and optional remote draft autosave. Point the client at any TCMS site URL. It will call `api.php` at that site for posts, pages, media, comments, local autosave, and optional remote draft autosave.
+310 -28
View File
@@ -72,6 +72,22 @@ static NSString *TCMSFormatBytes(id value) {
} }
@end @end
@interface TCMSTableView : NSTableView
@end
@implementation TCMSTableView
- (NSMenu *)menuForEvent:(NSEvent *)event {
NSPoint point = [self convertPoint:event.locationInWindow fromView:nil];
NSInteger row = [self rowAtPoint:point];
if (row >= 0) {
[self selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO];
} else {
return nil;
}
return [super menuForEvent:event];
}
@end
@interface TCMSAPIClient : NSObject @interface TCMSAPIClient : NSObject
@property (nonatomic, copy) NSString *baseURLString; @property (nonatomic, copy) NSString *baseURLString;
@property (nonatomic, copy) NSString *username; @property (nonatomic, copy) NSString *username;
@@ -270,6 +286,8 @@ static NSString *TCMSFormatBytes(id value) {
@property (nonatomic, strong) NSImageView *mediaImageView; @property (nonatomic, strong) NSImageView *mediaImageView;
@property (nonatomic, strong) AVPlayerView *mediaPlayerView; @property (nonatomic, strong) AVPlayerView *mediaPlayerView;
@property (nonatomic, strong) NSButton *openMediaButton; @property (nonatomic, strong) NSButton *openMediaButton;
@property (nonatomic, strong) NSButton *mediaURLCopyButton;
@property (nonatomic, strong) NSButton *mediaEmbedCopyButton;
@property (nonatomic, copy) NSString *mediaPreviewURLString; @property (nonatomic, copy) NSString *mediaPreviewURLString;
@property (nonatomic, strong) NSPopUpButton *commentStatusPopup; @property (nonatomic, strong) NSPopUpButton *commentStatusPopup;
@property (nonatomic, strong) NSButton *previousPageButton; @property (nonatomic, strong) NSButton *previousPageButton;
@@ -413,8 +431,8 @@ static NSString *TCMSFormatBytes(id value) {
NSMenuItem *appItem = [NSMenuItem new]; NSMenuItem *appItem = [NSMenuItem new];
[mainMenu addItem:appItem]; [mainMenu addItem:appItem];
NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"TCMS Mac Client"]; NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"TCMS"];
[appMenu addItemWithTitle:@"About TCMS Mac Client" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""]; [appMenu addItemWithTitle:@"About TCMS" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""];
[appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItem:[NSMenuItem separatorItem]];
NSMenuItem *settings = [appMenu addItemWithTitle:@"Settings..." action:@selector(showSettingsFromMenu:) keyEquivalent:@","]; NSMenuItem *settings = [appMenu addItemWithTitle:@"Settings..." action:@selector(showSettingsFromMenu:) keyEquivalent:@","];
settings.target = self; settings.target = self;
@@ -425,12 +443,12 @@ static NSString *TCMSFormatBytes(id value) {
[appMenu addItem:servicesItem]; [appMenu addItem:servicesItem];
NSApp.servicesMenu = servicesMenu; NSApp.servicesMenu = servicesMenu;
[appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItem:[NSMenuItem separatorItem]];
[appMenu addItemWithTitle:@"Hide TCMS Mac Client" action:@selector(hide:) keyEquivalent:@"h"]; [appMenu addItemWithTitle:@"Hide TCMS" action:@selector(hide:) keyEquivalent:@"h"];
NSMenuItem *hideOthers = [appMenu addItemWithTitle:@"Hide Others" action:@selector(hideOtherApplications:) keyEquivalent:@"h"]; NSMenuItem *hideOthers = [appMenu addItemWithTitle:@"Hide Others" action:@selector(hideOtherApplications:) keyEquivalent:@"h"];
hideOthers.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagOption; hideOthers.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagOption;
[appMenu addItemWithTitle:@"Show All" action:@selector(unhideAllApplications:) keyEquivalent:@""]; [appMenu addItemWithTitle:@"Show All" action:@selector(unhideAllApplications:) keyEquivalent:@""];
[appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItem:[NSMenuItem separatorItem]];
[appMenu addItemWithTitle:@"Quit TCMS Mac Client" action:@selector(terminate:) keyEquivalent:@"q"]; [appMenu addItemWithTitle:@"Quit TCMS" action:@selector(terminate:) keyEquivalent:@"q"];
appItem.submenu = appMenu; appItem.submenu = appMenu;
NSMenuItem *fileItem = [NSMenuItem new]; NSMenuItem *fileItem = [NSMenuItem new];
@@ -523,6 +541,8 @@ static NSString *TCMSFormatBytes(id value) {
self.mediaImageView = nil; self.mediaImageView = nil;
self.mediaPlayerView = nil; self.mediaPlayerView = nil;
self.openMediaButton = nil; self.openMediaButton = nil;
self.mediaURLCopyButton = nil;
self.mediaEmbedCopyButton = nil;
self.mediaPreviewURLString = nil; self.mediaPreviewURLString = nil;
} }
@@ -534,12 +554,13 @@ static NSString *TCMSFormatBytes(id value) {
NSTextField *heading = [self label:[section capitalizedString] frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]; NSTextField *heading = [self label:[section capitalizedString] frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]];
[self.workspace addSubview:heading]; [self.workspace addSubview:heading];
[self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)];
[self addButton:@"New" frame:NSMakeRect(310, 690, 70, 30) action:@selector(newContent)]; [self addButton:@"New" frame:NSMakeRect(296, 690, 58, 30) action:@selector(newContent)];
[self addButton:@"Save Draft" frame:NSMakeRect(390, 690, 100, 30) action:@selector(saveDraft)]; [self addButton:@"Draft" frame:NSMakeRect(362, 690, 70, 30) action:@selector(saveDraft)];
[self addButton:@"Publish" frame:NSMakeRect(500, 690, 90, 30) action:@selector(publishContent)]; [self addButton:@"Update" frame:NSMakeRect(440, 690, 76, 30) action:@selector(updateContent)];
[self addButton:@"Update" frame:NSMakeRect(600, 690, 80, 30) action:@selector(updateContent)]; [self addButton:@"Publish" frame:NSMakeRect(524, 690, 78, 30) action:@selector(publishContent)];
[self addButton:@"Delete" frame:NSMakeRect(690, 690, 80, 30) action:@selector(deleteContent)]; [self addButton:@"Upload Media" frame:NSMakeRect(610, 690, 118, 30) action:@selector(uploadEditorMedia)];
[self addButton:@"Delete" frame:NSMakeRect(736, 690, 70, 30) action:@selector(deleteContent)];
NSScrollView *listScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 280, 614) columns:@[@"title", @"status"]]; NSScrollView *listScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 280, 614) columns:@[@"title", @"status"]];
listScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin; listScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin;
@@ -649,15 +670,20 @@ static NSString *TCMSFormatBytes(id value) {
[self clearWorkspace]; [self clearWorkspace];
self.contentListPage = 1; self.contentListPage = 1;
[self.workspace addSubview:[self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]]; [self.workspace addSubview:[self label:@"Media" frame:NSMakeRect(20, 690, 180, 28) font:[NSFont boldSystemFontOfSize:24]]];
[self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)]; [self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)];
[self addButton:@"Upload" frame:NSMakeRect(310, 690, 90, 30) action:@selector(uploadMedia)]; [self addButton:@"Upload" frame:NSMakeRect(296, 690, 78, 30) action:@selector(uploadMedia)];
self.openMediaButton = [self addButton:@"Open" frame:NSMakeRect(410, 690, 80, 30) action:@selector(openSelectedMedia:)]; self.openMediaButton = [self addButton:@"Open" frame:NSMakeRect(382, 690, 66, 30) action:@selector(openSelectedMedia:)];
self.openMediaButton.enabled = NO; self.openMediaButton.enabled = NO;
self.mediaURLCopyButton = [self addButton:@"Copy URL" frame:NSMakeRect(456, 690, 88, 30) action:@selector(copySelectedMediaURL:)];
self.mediaURLCopyButton.enabled = NO;
self.mediaEmbedCopyButton = [self addButton:@"Copy Embed" frame:NSMakeRect(552, 690, 104, 30) action:@selector(copySelectedMediaEmbed:)];
self.mediaEmbedCopyButton.enabled = NO;
NSScrollView *mediaScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 420, 614) columns:@[@"name", @"mime", @"size"]]; NSScrollView *mediaScroll = [self buildTableWithFrame:NSMakeRect(20, 56, 420, 614) columns:@[@"name", @"mime", @"size"]];
mediaScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin; mediaScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin;
self.tableView.target = self; self.tableView.target = self;
self.tableView.doubleAction = @selector(openSelectedMedia:); self.tableView.doubleAction = @selector(openSelectedMedia:);
[self configureMediaContextMenu];
self.previousPageButton = [NSButton buttonWithTitle:@"Previous" target:self action:@selector(previousContentPage)]; self.previousPageButton = [NSButton buttonWithTitle:@"Previous" target:self action:@selector(previousContentPage)];
self.previousPageButton.frame = NSMakeRect(20, 20, 78, 28); self.previousPageButton.frame = NSMakeRect(20, 20, 78, 28);
@@ -791,7 +817,7 @@ static NSString *TCMSFormatBytes(id value) {
NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame]; NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame];
scroll.borderType = NSBezelBorder; scroll.borderType = NSBezelBorder;
scroll.hasVerticalScroller = YES; scroll.hasVerticalScroller = YES;
self.tableView = [[NSTableView alloc] initWithFrame:scroll.bounds]; self.tableView = [[TCMSTableView alloc] initWithFrame:scroll.bounds];
self.tableView.delegate = self; self.tableView.delegate = self;
self.tableView.dataSource = self; self.tableView.dataSource = self;
self.tableView.columnAutoresizingStyle = NSTableViewUniformColumnAutoresizingStyle; self.tableView.columnAutoresizingStyle = NSTableViewUniformColumnAutoresizingStyle;
@@ -996,6 +1022,9 @@ static NSString *TCMSFormatBytes(id value) {
if (action == @selector(updateContent) || action == @selector(saveDraft) || action == @selector(publishContent)) { if (action == @selector(updateContent) || action == @selector(saveDraft) || action == @selector(publishContent)) {
return [self isContentSection] && self.markdownView != nil; return [self isContentSection] && self.markdownView != nil;
} }
if (action == @selector(copySelectedMediaURL:) || action == @selector(copySelectedMediaEmbed:) || action == @selector(openSelectedMedia:)) {
return [self.section isEqualToString:@"media"] && [self selectedTableItem] != nil;
}
return YES; return YES;
} }
@@ -1145,20 +1174,73 @@ static NSString *TCMSFormatBytes(id value) {
return item; return item;
} }
- (NSString *)contentKindForItem:(NSDictionary *)item capitalized:(BOOL)capitalized {
NSString *type = [TCMSString(item[@"type"]) lowercaseString];
NSString *kind = [type isEqualToString:@"page"] ? @"page" : @"post";
return capitalized ? [kind capitalizedString] : kind;
}
- (NSString *)contentTitleForItem:(NSDictionary *)item {
NSString *title = TCMSString(item[@"title"]);
if (title.length == 0) {
title = TCMSString(item[@"slug"]);
}
return title.length > 0 ? title : @"Untitled";
}
- (NSString *)confirmationMessageForItem:(NSDictionary *)item action:(NSString *)action {
NSString *kind = [self contentKindForItem:item capitalized:YES];
NSString *title = [self contentTitleForItem:item];
return [NSString stringWithFormat:@"%@ \"%@\" was %@.", kind, title, action];
}
- (void)showConfirmationWithTitle:(NSString *)title message:(NSString *)message {
NSAlert *alert = [NSAlert new];
alert.alertStyle = NSAlertStyleInformational;
alert.messageText = title.length > 0 ? title : @"Done";
alert.informativeText = message.length > 0 ? message : @"The action completed successfully.";
[alert addButtonWithTitle:@"OK"];
if (self.window) {
[alert beginSheetModalForWindow:self.window completionHandler:nil];
} else {
[alert runModal];
}
}
- (void)confirmDestructiveActionWithTitle:(NSString *)title message:(NSString *)message confirmButton:(NSString *)confirmButton completion:(void (^)(BOOL confirmed))completion {
NSAlert *alert = [NSAlert new];
alert.alertStyle = NSAlertStyleWarning;
alert.messageText = title.length > 0 ? title : @"Confirm";
alert.informativeText = message.length > 0 ? message : @"This action cannot be undone.";
NSButton *primary = [alert addButtonWithTitle:confirmButton.length > 0 ? confirmButton : @"Delete"];
primary.keyEquivalent = @"\r";
[alert addButtonWithTitle:@"Cancel"];
void (^handler)(NSModalResponse) = ^(NSModalResponse response) {
if (completion) {
completion(response == NSAlertFirstButtonReturn);
}
};
if (self.window) {
[alert beginSheetModalForWindow:self.window completionHandler:handler];
} else {
handler([alert runModal]);
}
}
- (void)saveDraft { - (void)saveDraft {
[self saveContentWithStatus:@"draft" message:@"Draft saved."]; [self saveContentWithStatus:@"draft" message:@"Draft saved." confirmationTitle:@"Draft Saved" confirmationAction:@"saved as a draft"];
} }
- (void)publishContent { - (void)publishContent {
[self saveContentWithStatus:@"published" message:@"Content published."]; [self saveContentWithStatus:@"published" message:@"Content published." confirmationTitle:@"Published" confirmationAction:@"published"];
} }
- (void)saveContentWithStatus:(NSString *)status { - (void)saveContentWithStatus:(NSString *)status {
[self saveContentWithStatus:status message:@"Content saved."]; [self saveContentWithStatus:status message:@"Content saved." confirmationTitle:nil confirmationAction:nil];
} }
- (void)updateContent { - (void)updateContent {
[self saveContentWithStatus:nil message:@"Content updated."]; [self saveContentWithStatus:nil message:@"Content updated." confirmationTitle:@"Updated" confirmationAction:@"updated"];
} }
- (NSString *)currentEditorMarkdown { - (NSString *)currentEditorMarkdown {
@@ -1172,7 +1254,7 @@ static NSString *TCMSFormatBytes(id value) {
return self.markdownView.string ?: @""; return self.markdownView.string ?: @"";
} }
- (void)saveContentWithStatus:(NSString *)status message:(NSString *)message { - (void)saveContentWithStatus:(NSString *)status message:(NSString *)message confirmationTitle:(NSString *)confirmationTitle confirmationAction:(NSString *)confirmationAction {
if (![self isContentSection] || !self.markdownView) { if (![self isContentSection] || !self.markdownView) {
[self setStatus:@"Open Posts or Pages to save content."]; [self setStatus:@"Open Posts or Pages to save content."];
return; return;
@@ -1204,23 +1286,36 @@ static NSString *TCMSFormatBytes(id value) {
[self loadEditorFields]; [self loadEditorFields];
[self refreshContentType:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"]; [self refreshContentType:TCMSString(self.editorItem[@"type"]).length ? TCMSString(self.editorItem[@"type"]) : @"post"];
[self setStatus:serverEchoedSubmittedMarkdown ? (message ?: @"Content saved.") : @"Server response did not include the updated Markdown. Editor changes preserved."]; [self setStatus:serverEchoedSubmittedMarkdown ? (message ?: @"Content saved.") : @"Server response did not include the updated Markdown. Editor changes preserved."];
if (serverEchoedSubmittedMarkdown && confirmationTitle.length > 0) {
[self showConfirmationWithTitle:confirmationTitle message:[self confirmationMessageForItem:self.editorItem action:confirmationAction ?: @"saved"]];
}
}]; }];
} }
- (void)deleteContent { - (void)deleteContent {
NSString *slug = TCMSString(self.editorItem[@"slug"]); NSDictionary *item = [self.editorItem copy] ?: @{};
NSString *slug = TCMSString(item[@"slug"]);
if (slug.length == 0) { if (slug.length == 0) {
[self setStatus:@"Nothing selected to delete."]; [self setStatus:@"Nothing selected to delete."];
return; return;
} }
[self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) { NSString *kind = [self contentKindForItem:item capitalized:YES];
if (error) { NSString *title = [self contentTitleForItem:item];
[self setStatus:error.localizedDescription]; NSString *message = [NSString stringWithFormat:@"Delete \"%@\" from the server? This cannot be undone.", title];
[self confirmDestructiveActionWithTitle:[NSString stringWithFormat:@"Delete %@?", kind] message:message confirmButton:@"Delete" completion:^(BOOL confirmed) {
if (!confirmed) {
return; return;
} }
[self setStatus:@"Content deleted."]; [self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) {
[self newContent]; if (error) {
[self refreshCurrentSection]; [self setStatus:error.localizedDescription];
return;
}
[self setStatus:@"Content deleted."];
[self newContent];
[self refreshCurrentSection];
[self showConfirmationWithTitle:@"Deleted" message:[self confirmationMessageForItem:item action:@"deleted"]];
}];
}]; }];
} }
@@ -1237,7 +1332,7 @@ static NSString *TCMSFormatBytes(id value) {
}]; }];
} }
- (void)uploadMedia { - (void)chooseAndUploadMediaWithCompletion:(void (^)(NSDictionary *item))completion {
NSOpenPanel *panel = [NSOpenPanel openPanel]; NSOpenPanel *panel = [NSOpenPanel openPanel];
panel.canChooseDirectories = NO; panel.canChooseDirectories = NO;
panel.canChooseFiles = YES; panel.canChooseFiles = YES;
@@ -1250,11 +1345,35 @@ static NSString *TCMSFormatBytes(id value) {
[self setStatus:error.localizedDescription]; [self setStatus:error.localizedDescription];
return; return;
} }
NSDictionary *item = [json[@"item"] isKindOfClass:[NSDictionary class]] ? json[@"item"] : @{};
completion(item);
}];
}
- (void)uploadMedia {
[self chooseAndUploadMediaWithCompletion:^(NSDictionary *item) {
[self setStatus:@"Media uploaded."]; [self setStatus:@"Media uploaded."];
[self refreshMedia]; [self refreshMedia];
}]; }];
} }
- (void)uploadEditorMedia {
if (![self isContentSection] || !self.markdownView) {
[self setStatus:@"Open Posts or Pages to insert media."];
return;
}
[self chooseAndUploadMediaWithCompletion:^(NSDictionary *item) {
NSString *snippet = [self mediaMarkdownEmbedForItem:item];
if (snippet.length == 0) {
[self setStatus:@"Media uploaded, but no embed could be generated."];
return;
}
[self insertMarkdownSnippet:snippet];
[self setStatus:@"Media uploaded and inserted."];
}];
}
- (NSURL *)absoluteURLForMediaItem:(NSDictionary *)item { - (NSURL *)absoluteURLForMediaItem:(NSDictionary *)item {
NSString *url = TCMSString(item[@"url"]); NSString *url = TCMSString(item[@"url"]);
if (url.length == 0) { if (url.length == 0) {
@@ -1263,6 +1382,165 @@ static NSString *TCMSFormatBytes(id value) {
return [self.client absoluteURLForPath:url]; return [self.client absoluteURLForPath:url];
} }
- (NSString *)mediaEmbedURLForItem:(NSDictionary *)item {
NSString *url = TCMSString(item[@"url"]);
if (url.length == 0) {
url = TCMSString(item[@"path"]);
}
return url;
}
- (NSString *)mediaTitleForItem:(NSDictionary *)item {
NSString *name = TCMSString(item[@"name"]);
if (name.length == 0) {
name = TCMSString(item[@"path"]).lastPathComponent;
}
NSString *base = name.stringByDeletingPathExtension;
return base.length > 0 ? base : name;
}
- (BOOL)mediaItem:(NSDictionary *)item hasMimePrefix:(NSString *)prefix extensions:(NSArray<NSString *> *)extensions {
NSString *mime = [TCMSString(item[@"mime"]) lowercaseString];
if ([mime hasPrefix:prefix]) {
return YES;
}
NSString *extension = [[TCMSString(item[@"name"]).pathExtension lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
if (extension.length == 0) {
extension = [[TCMSString(item[@"path"]).pathExtension lowercaseString] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
}
return [extensions containsObject:extension];
}
- (BOOL)isImageMediaItem:(NSDictionary *)item {
return [self mediaItem:item hasMimePrefix:@"image/" extensions:@[@"avif", @"bmp", @"gif", @"jpeg", @"jpg", @"png", @"svg", @"webp"]];
}
- (BOOL)isVideoMediaItem:(NSDictionary *)item {
return [self mediaItem:item hasMimePrefix:@"video/" extensions:@[@"m4v", @"mov", @"mp4", @"ogv", @"ogg", @"webm"]];
}
- (BOOL)isAudioMediaItem:(NSDictionary *)item {
return [self mediaItem:item hasMimePrefix:@"audio/" extensions:@[@"aac", @"aif", @"aiff", @"flac", @"m4a", @"mp3", @"oga", @"ogg", @"wav", @"weba"]];
}
- (NSString *)markdownSafeLabel:(NSString *)value {
NSString *label = [TCMSString(value) stringByReplacingOccurrencesOfString:@"\n" withString:@" "];
label = [label stringByReplacingOccurrencesOfString:@"[" withString:@"("];
label = [label stringByReplacingOccurrencesOfString:@"]" withString:@")"];
return label.length > 0 ? label : @"media";
}
- (NSString *)shortcodeSafeValue:(NSString *)value {
NSString *clean = [TCMSString(value) stringByReplacingOccurrencesOfString:@"\"" withString:@"%22"];
clean = [clean stringByReplacingOccurrencesOfString:@"\n" withString:@""];
return clean;
}
- (NSString *)mediaMarkdownEmbedForItem:(NSDictionary *)item {
NSString *url = [self mediaEmbedURLForItem:item];
if (url.length == 0) {
return @"";
}
NSString *title = [self markdownSafeLabel:[self mediaTitleForItem:item]];
if ([self isImageMediaItem:item]) {
return [NSString stringWithFormat:@"![%@](%@)", title, url];
}
if ([self isVideoMediaItem:item]) {
return [NSString stringWithFormat:@"[video file=\"%@\"]", [self shortcodeSafeValue:url]];
}
if ([self isAudioMediaItem:item]) {
return [NSString stringWithFormat:@"<audio controls src=\"%@\"></audio>", [self shortcodeSafeValue:url]];
}
return [NSString stringWithFormat:@"[%@](%@)", title, url];
}
- (void)insertMarkdownSnippet:(NSString *)snippet {
if (!self.markdownView || snippet.length == 0) {
return;
}
NSString *current = self.markdownView.string ?: @"";
NSRange range = self.markdownView.selectedRange;
if (range.location == NSNotFound || range.location > current.length) {
range = NSMakeRange(current.length, 0);
} else if (NSMaxRange(range) > current.length) {
range.length = current.length - range.location;
}
NSString *prefix = @"";
NSString *suffix = @"";
if (range.location > 0) {
NSString *before = [current substringToIndex:range.location];
if (![before hasSuffix:@"\n\n"]) {
prefix = [before hasSuffix:@"\n"] ? @"\n" : @"\n\n";
}
}
NSUInteger afterLocation = MIN(NSMaxRange(range), current.length);
if (afterLocation < current.length) {
NSString *after = [current substringFromIndex:afterLocation];
if (![after hasPrefix:@"\n\n"]) {
suffix = [after hasPrefix:@"\n"] ? @"\n" : @"\n\n";
}
} else {
suffix = @"\n";
}
NSString *insert = [NSString stringWithFormat:@"%@%@%@", prefix, snippet, suffix];
if ([self.markdownView shouldChangeTextInRange:range replacementString:insert]) {
[self.markdownView.textStorage replaceCharactersInRange:range withString:insert];
[self.markdownView didChangeText];
[self.markdownView setSelectedRange:NSMakeRange(range.location + prefix.length + snippet.length, 0)];
}
[self.window makeFirstResponder:self.markdownView];
[self markDirty];
}
- (void)configureMediaContextMenu {
NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Media"];
NSMenuItem *copyURL = [[NSMenuItem alloc] initWithTitle:@"Copy URL" action:@selector(copySelectedMediaURL:) keyEquivalent:@""];
copyURL.target = self;
[menu addItem:copyURL];
NSMenuItem *copyEmbed = [[NSMenuItem alloc] initWithTitle:@"Copy Markdown Embed" action:@selector(copySelectedMediaEmbed:) keyEquivalent:@""];
copyEmbed.target = self;
[menu addItem:copyEmbed];
[menu addItem:[NSMenuItem separatorItem]];
NSMenuItem *open = [[NSMenuItem alloc] initWithTitle:@"Open" action:@selector(openSelectedMedia:) keyEquivalent:@""];
open.target = self;
[menu addItem:open];
self.tableView.menu = menu;
}
- (void)copyTextToPasteboard:(NSString *)text status:(NSString *)status {
if (text.length == 0) {
[self setStatus:@"Nothing to copy."];
return;
}
NSPasteboard *pasteboard = NSPasteboard.generalPasteboard;
[pasteboard clearContents];
[pasteboard setString:text forType:NSPasteboardTypeString];
[self setStatus:status];
}
- (void)copySelectedMediaURL:(id)sender {
NSDictionary *item = [self selectedTableItem];
if (!item) {
[self setStatus:@"Select a media item first."];
return;
}
NSURL *url = [self absoluteURLForMediaItem:item];
[self copyTextToPasteboard:url.absoluteString ?: [self mediaEmbedURLForItem:item] status:@"Media URL copied."];
}
- (void)copySelectedMediaEmbed:(id)sender {
NSDictionary *item = [self selectedTableItem];
if (!item) {
[self setStatus:@"Select a media item first."];
return;
}
[self copyTextToPasteboard:[self mediaMarkdownEmbedForItem:item] status:@"Markdown embed copied."];
}
- (NSString *)mediaDetailForItem:(NSDictionary *)item { - (NSString *)mediaDetailForItem:(NSDictionary *)item {
NSMutableArray *parts = [NSMutableArray array]; NSMutableArray *parts = [NSMutableArray array];
NSString *mime = TCMSString(item[@"mime"]); NSString *mime = TCMSString(item[@"mime"]);
@@ -1288,6 +1566,8 @@ static NSString *TCMSFormatBytes(id value) {
self.mediaImageView.hidden = YES; self.mediaImageView.hidden = YES;
self.mediaPreviewURLString = nil; self.mediaPreviewURLString = nil;
self.openMediaButton.enabled = NO; self.openMediaButton.enabled = NO;
self.mediaURLCopyButton.enabled = NO;
self.mediaEmbedCopyButton.enabled = NO;
self.mediaPreviewLabel.stringValue = @"No media selected"; self.mediaPreviewLabel.stringValue = @"No media selected";
self.mediaDetailLabel.stringValue = @""; self.mediaDetailLabel.stringValue = @"";
} }
@@ -1304,6 +1584,8 @@ static NSString *TCMSFormatBytes(id value) {
NSString *urlString = url.absoluteString ?: @""; NSString *urlString = url.absoluteString ?: @"";
self.mediaPreviewURLString = urlString; self.mediaPreviewURLString = urlString;
self.openMediaButton.enabled = YES; self.openMediaButton.enabled = YES;
self.mediaURLCopyButton.enabled = YES;
self.mediaEmbedCopyButton.enabled = YES;
self.mediaPreviewLabel.stringValue = TCMSString(item[@"name"]).length ? TCMSString(item[@"name"]) : url.lastPathComponent; self.mediaPreviewLabel.stringValue = TCMSString(item[@"name"]).length ? TCMSString(item[@"name"]) : url.lastPathComponent;
self.mediaDetailLabel.stringValue = [self mediaDetailForItem:item]; self.mediaDetailLabel.stringValue = [self mediaDetailForItem:item];
self.mediaImageView.image = nil; self.mediaImageView.image = nil;
@@ -1455,7 +1737,7 @@ static NSString *TCMSFormatBytes(id value) {
return; return;
} }
NSURL *base = [NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject; NSURL *base = [NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask].firstObject;
NSURL *folder = [base URLByAppendingPathComponent:@"TCMS Mac Client/Drafts" isDirectory:YES]; NSURL *folder = [base URLByAppendingPathComponent:@"TCMS/Drafts" isDirectory:YES];
[NSFileManager.defaultManager createDirectoryAtURL:folder withIntermediateDirectories:YES attributes:nil error:nil]; [NSFileManager.defaultManager createDirectoryAtURL:folder withIntermediateDirectories:YES attributes:nil error:nil];
NSString *name = TCMSString(item[@"slug"]).length ? TCMSString(item[@"slug"]) : TCMSString(item[@"title"]); NSString *name = TCMSString(item[@"slug"]).length ? TCMSString(item[@"slug"]) : TCMSString(item[@"title"]);
name = [name stringByReplacingOccurrencesOfString:@"[^A-Za-z0-9._-]+" withString:@"-" options:NSRegularExpressionSearch range:NSMakeRange(0, name.length)]; name = [name stringByReplacingOccurrencesOfString:@"[^A-Za-z0-9._-]+" withString:@"-" options:NSRegularExpressionSearch range:NSMakeRange(0, name.length)];
+6 -3
View File
@@ -1,4 +1,4 @@
# TCMS Mac Client # TCMS
Native AppKit macOS 12+ client for Ty Clifford's Content Management System. Native AppKit macOS 12+ client for Ty Clifford's Content Management System.
@@ -7,10 +7,10 @@ Native AppKit macOS 12+ client for Ty Clifford's Content Management System.
```bash ```bash
cd macclient cd macclient
./build-app.sh ./build-app.sh
open TCMSMacClient.app open TCMS.app
``` ```
The built app bundle is written to `macclient/TCMSMacClient.app`. The built app bundle is written to `macclient/TCMS.app`.
## Connect ## Connect
@@ -29,8 +29,11 @@ The client automatically calls `api.php` under that URL. Enter the username/pass
- Page through large post/page lists with configurable per-page counts. - Page through large post/page lists with configurable per-page counts.
- Toggle spell check and auto-correct in the Markdown editor. - Toggle spell check and auto-correct in the Markdown editor.
- Use standard Mac shortcuts, including Command-S for Update, Command-Comma for Settings, and Command-Q for Quit. - Use standard Mac shortcuts, including Command-S for Update, Command-Comma for Settings, and Command-Q for Quit.
- Confirm successful draft, publish, update, and delete actions with native sheets.
- Upload media to `bl-content/uploads`. - Upload media to `bl-content/uploads`.
- Page through media, preview images, play audio/video, and open files in the default Mac app. - Page through media, preview images, play audio/video, and open files in the default Mac app.
- Upload media from the editor and insert the correct Markdown image, video shortcode, audio tag, or link automatically.
- Right-click media to copy its URL or a ready-to-paste Markdown embed.
- List and moderate comments. - List and moderate comments.
- Local autosave to Application Support. - Local autosave to Application Support.
- Optional remote autosave as draft. - Optional remote autosave as draft.
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>TCMS</string>
<key>CFBundleIdentifier</key>
<string>com.tyclifford.tcms.macclient</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>TCMS</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
APPL????
+18 -6
View File
@@ -2,13 +2,25 @@
set -eu set -eu
ROOT="$(cd "$(dirname "$0")" && pwd)" ROOT="$(cd "$(dirname "$0")" && pwd)"
APP="$ROOT/TCMSMacClient.app" PRIMARY_APP="$ROOT/TCMS.app"
LEGACY_APP="$ROOT/TCMSMacClient.app"
SRC="$ROOT/AppKitClient/main.m" SRC="$ROOT/AppKitClient/main.m"
EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$ROOT/AppKitClient/Info.plist")" EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$ROOT/AppKitClient/Info.plist")"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" build_app() {
cp "$ROOT/AppKitClient/Info.plist" "$APP/Contents/Info.plist" app="$1"
/usr/bin/clang -fobjc-arc -mmacosx-version-min=12.0 -framework Cocoa -framework AVKit -framework AVFoundation "$SRC" -o "$APP/Contents/MacOS/$EXECUTABLE" mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
printf 'APPL????' > "$APP/Contents/PkgInfo" rm -f "$app/Contents/MacOS"/*
cp "$ROOT/AppKitClient/Info.plist" "$app/Contents/Info.plist"
/usr/bin/clang -fobjc-arc -mmacosx-version-min=12.0 -framework Cocoa -framework AVKit -framework AVFoundation "$SRC" -o "$app/Contents/MacOS/$EXECUTABLE"
printf 'APPL????' > "$app/Contents/PkgInfo"
}
echo "Built $APP" build_app "$PRIMARY_APP"
if [ -d "$LEGACY_APP" ]; then
build_app "$LEGACY_APP"
echo "Updated legacy bundle $LEGACY_APP"
fi
echo "Built $PRIMARY_APP"