- 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:
@@ -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 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 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 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.
|
||||
@@ -19,6 +22,7 @@ All notable changes to Ty Clifford's Content Management System are documented he
|
||||
### Changed
|
||||
|
||||
- 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 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.
|
||||
|
||||
@@ -49,7 +49,7 @@ The native AppKit macOS 12+ client lives in `macclient/`.
|
||||
```bash
|
||||
cd macclient
|
||||
./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.
|
||||
|
||||
+304
-22
@@ -72,6 +72,22 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
}
|
||||
@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
|
||||
@property (nonatomic, copy) NSString *baseURLString;
|
||||
@property (nonatomic, copy) NSString *username;
|
||||
@@ -270,6 +286,8 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
@property (nonatomic, strong) NSImageView *mediaImageView;
|
||||
@property (nonatomic, strong) AVPlayerView *mediaPlayerView;
|
||||
@property (nonatomic, strong) NSButton *openMediaButton;
|
||||
@property (nonatomic, strong) NSButton *mediaURLCopyButton;
|
||||
@property (nonatomic, strong) NSButton *mediaEmbedCopyButton;
|
||||
@property (nonatomic, copy) NSString *mediaPreviewURLString;
|
||||
@property (nonatomic, strong) NSPopUpButton *commentStatusPopup;
|
||||
@property (nonatomic, strong) NSButton *previousPageButton;
|
||||
@@ -413,8 +431,8 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
|
||||
NSMenuItem *appItem = [NSMenuItem new];
|
||||
[mainMenu addItem:appItem];
|
||||
NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"TCMS Mac Client"];
|
||||
[appMenu addItemWithTitle:@"About TCMS Mac Client" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""];
|
||||
NSMenu *appMenu = [[NSMenu alloc] initWithTitle:@"TCMS"];
|
||||
[appMenu addItemWithTitle:@"About TCMS" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""];
|
||||
[appMenu addItem:[NSMenuItem separatorItem]];
|
||||
NSMenuItem *settings = [appMenu addItemWithTitle:@"Settings..." action:@selector(showSettingsFromMenu:) keyEquivalent:@","];
|
||||
settings.target = self;
|
||||
@@ -425,12 +443,12 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
[appMenu addItem:servicesItem];
|
||||
NSApp.servicesMenu = servicesMenu;
|
||||
[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"];
|
||||
hideOthers.keyEquivalentModifierMask = NSEventModifierFlagCommand | NSEventModifierFlagOption;
|
||||
[appMenu addItemWithTitle:@"Show All" action:@selector(unhideAllApplications:) keyEquivalent:@""];
|
||||
[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;
|
||||
|
||||
NSMenuItem *fileItem = [NSMenuItem new];
|
||||
@@ -523,6 +541,8 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
self.mediaImageView = nil;
|
||||
self.mediaPlayerView = nil;
|
||||
self.openMediaButton = nil;
|
||||
self.mediaURLCopyButton = nil;
|
||||
self.mediaEmbedCopyButton = 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]];
|
||||
[self.workspace addSubview:heading];
|
||||
[self addButton:@"Refresh" frame:NSMakeRect(210, 690, 90, 30) action:@selector(refreshCurrentSection)];
|
||||
[self addButton:@"New" frame:NSMakeRect(310, 690, 70, 30) action:@selector(newContent)];
|
||||
[self addButton:@"Save Draft" frame:NSMakeRect(390, 690, 100, 30) action:@selector(saveDraft)];
|
||||
[self addButton:@"Publish" frame:NSMakeRect(500, 690, 90, 30) action:@selector(publishContent)];
|
||||
[self addButton:@"Update" frame:NSMakeRect(600, 690, 80, 30) action:@selector(updateContent)];
|
||||
[self addButton:@"Delete" frame:NSMakeRect(690, 690, 80, 30) action:@selector(deleteContent)];
|
||||
[self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)];
|
||||
[self addButton:@"New" frame:NSMakeRect(296, 690, 58, 30) action:@selector(newContent)];
|
||||
[self addButton:@"Draft" frame:NSMakeRect(362, 690, 70, 30) action:@selector(saveDraft)];
|
||||
[self addButton:@"Update" frame:NSMakeRect(440, 690, 76, 30) action:@selector(updateContent)];
|
||||
[self addButton:@"Publish" frame:NSMakeRect(524, 690, 78, 30) action:@selector(publishContent)];
|
||||
[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"]];
|
||||
listScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin;
|
||||
@@ -649,15 +670,20 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
[self clearWorkspace];
|
||||
self.contentListPage = 1;
|
||||
[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:@"Upload" frame:NSMakeRect(310, 690, 90, 30) action:@selector(uploadMedia)];
|
||||
self.openMediaButton = [self addButton:@"Open" frame:NSMakeRect(410, 690, 80, 30) action:@selector(openSelectedMedia:)];
|
||||
[self addButton:@"Refresh" frame:NSMakeRect(210, 690, 78, 30) action:@selector(refreshCurrentSection)];
|
||||
[self addButton:@"Upload" frame:NSMakeRect(296, 690, 78, 30) action:@selector(uploadMedia)];
|
||||
self.openMediaButton = [self addButton:@"Open" frame:NSMakeRect(382, 690, 66, 30) action:@selector(openSelectedMedia:)];
|
||||
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"]];
|
||||
mediaScroll.autoresizingMask = NSViewHeightSizable | NSViewMaxXMargin;
|
||||
self.tableView.target = self;
|
||||
self.tableView.doubleAction = @selector(openSelectedMedia:);
|
||||
[self configureMediaContextMenu];
|
||||
|
||||
self.previousPageButton = [NSButton buttonWithTitle:@"Previous" target:self action:@selector(previousContentPage)];
|
||||
self.previousPageButton.frame = NSMakeRect(20, 20, 78, 28);
|
||||
@@ -791,7 +817,7 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
NSScrollView *scroll = [[NSScrollView alloc] initWithFrame:frame];
|
||||
scroll.borderType = NSBezelBorder;
|
||||
scroll.hasVerticalScroller = YES;
|
||||
self.tableView = [[NSTableView alloc] initWithFrame:scroll.bounds];
|
||||
self.tableView = [[TCMSTableView alloc] initWithFrame:scroll.bounds];
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.columnAutoresizingStyle = NSTableViewUniformColumnAutoresizingStyle;
|
||||
@@ -996,6 +1022,9 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
if (action == @selector(updateContent) || action == @selector(saveDraft) || action == @selector(publishContent)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1145,20 +1174,73 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
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 {
|
||||
[self saveContentWithStatus:@"draft" message:@"Draft saved."];
|
||||
[self saveContentWithStatus:@"draft" message:@"Draft saved." confirmationTitle:@"Draft Saved" confirmationAction:@"saved as a draft"];
|
||||
}
|
||||
|
||||
- (void)publishContent {
|
||||
[self saveContentWithStatus:@"published" message:@"Content published."];
|
||||
[self saveContentWithStatus:@"published" message:@"Content published." confirmationTitle:@"Published" confirmationAction:@"published"];
|
||||
}
|
||||
|
||||
- (void)saveContentWithStatus:(NSString *)status {
|
||||
[self saveContentWithStatus:status message:@"Content saved."];
|
||||
[self saveContentWithStatus:status message:@"Content saved." confirmationTitle:nil confirmationAction:nil];
|
||||
}
|
||||
|
||||
- (void)updateContent {
|
||||
[self saveContentWithStatus:nil message:@"Content updated."];
|
||||
[self saveContentWithStatus:nil message:@"Content updated." confirmationTitle:@"Updated" confirmationAction:@"updated"];
|
||||
}
|
||||
|
||||
- (NSString *)currentEditorMarkdown {
|
||||
@@ -1172,7 +1254,7 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
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) {
|
||||
[self setStatus:@"Open Posts or Pages to save content."];
|
||||
return;
|
||||
@@ -1204,15 +1286,26 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
[self loadEditorFields];
|
||||
[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."];
|
||||
if (serverEchoedSubmittedMarkdown && confirmationTitle.length > 0) {
|
||||
[self showConfirmationWithTitle:confirmationTitle message:[self confirmationMessageForItem:self.editorItem action:confirmationAction ?: @"saved"]];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)deleteContent {
|
||||
NSString *slug = TCMSString(self.editorItem[@"slug"]);
|
||||
NSDictionary *item = [self.editorItem copy] ?: @{};
|
||||
NSString *slug = TCMSString(item[@"slug"]);
|
||||
if (slug.length == 0) {
|
||||
[self setStatus:@"Nothing selected to delete."];
|
||||
return;
|
||||
}
|
||||
NSString *kind = [self contentKindForItem:item capitalized:YES];
|
||||
NSString *title = [self contentTitleForItem:item];
|
||||
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;
|
||||
}
|
||||
[self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) {
|
||||
if (error) {
|
||||
[self setStatus:error.localizedDescription];
|
||||
@@ -1221,6 +1314,8 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
[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];
|
||||
panel.canChooseDirectories = NO;
|
||||
panel.canChooseFiles = YES;
|
||||
@@ -1250,11 +1345,35 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
[self setStatus:error.localizedDescription];
|
||||
return;
|
||||
}
|
||||
NSDictionary *item = [json[@"item"] isKindOfClass:[NSDictionary class]] ? json[@"item"] : @{};
|
||||
completion(item);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)uploadMedia {
|
||||
[self chooseAndUploadMediaWithCompletion:^(NSDictionary *item) {
|
||||
[self setStatus:@"Media uploaded."];
|
||||
[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 {
|
||||
NSString *url = TCMSString(item[@"url"]);
|
||||
if (url.length == 0) {
|
||||
@@ -1263,6 +1382,165 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
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 {
|
||||
NSMutableArray *parts = [NSMutableArray array];
|
||||
NSString *mime = TCMSString(item[@"mime"]);
|
||||
@@ -1288,6 +1566,8 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
self.mediaImageView.hidden = YES;
|
||||
self.mediaPreviewURLString = nil;
|
||||
self.openMediaButton.enabled = NO;
|
||||
self.mediaURLCopyButton.enabled = NO;
|
||||
self.mediaEmbedCopyButton.enabled = NO;
|
||||
self.mediaPreviewLabel.stringValue = @"No media selected";
|
||||
self.mediaDetailLabel.stringValue = @"";
|
||||
}
|
||||
@@ -1304,6 +1584,8 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
NSString *urlString = url.absoluteString ?: @"";
|
||||
self.mediaPreviewURLString = urlString;
|
||||
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.mediaDetailLabel.stringValue = [self mediaDetailForItem:item];
|
||||
self.mediaImageView.image = nil;
|
||||
@@ -1455,7 +1737,7 @@ static NSString *TCMSFormatBytes(id value) {
|
||||
return;
|
||||
}
|
||||
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];
|
||||
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)];
|
||||
|
||||
+6
-3
@@ -1,4 +1,4 @@
|
||||
# TCMS Mac Client
|
||||
# TCMS
|
||||
|
||||
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
|
||||
cd macclient
|
||||
./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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- Confirm successful draft, publish, update, and delete actions with native sheets.
|
||||
- Upload media to `bl-content/uploads`.
|
||||
- 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.
|
||||
- Local autosave to Application Support.
|
||||
- Optional remote autosave as draft.
|
||||
|
||||
@@ -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>
|
||||
Executable
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
||||
APPL????
|
||||
+18
-6
@@ -2,13 +2,25 @@
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
APP="$ROOT/TCMSMacClient.app"
|
||||
PRIMARY_APP="$ROOT/TCMS.app"
|
||||
LEGACY_APP="$ROOT/TCMSMacClient.app"
|
||||
SRC="$ROOT/AppKitClient/main.m"
|
||||
EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$ROOT/AppKitClient/Info.plist")"
|
||||
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
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"
|
||||
build_app() {
|
||||
app="$1"
|
||||
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user