diff --git a/CHANGELOG.md b/CHANGELOG.md index 8feaae3..8a210f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 46982f5..414c97d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/macclient/AppKitClient/main.m b/macclient/AppKitClient/main.m index f946c37..cfbb705 100644 --- a/macclient/AppKitClient/main.m +++ b/macclient/AppKitClient/main.m @@ -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,23 +1286,36 @@ 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; } - [self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) { - if (error) { - [self setStatus:error.localizedDescription]; + 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 setStatus:@"Content deleted."]; - [self newContent]; - [self refreshCurrentSection]; + [self.client requestAction:@"content.delete" query:@{} method:@"POST" body:@{@"slug": slug} completion:^(NSDictionary *json, NSError *error) { + if (error) { + [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]; 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 *)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:@"", [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)]; diff --git a/macclient/README.md b/macclient/README.md index 6fe9211..ce0b708 100644 --- a/macclient/README.md +++ b/macclient/README.md @@ -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. diff --git a/macclient/TCMS.app/Contents/Info.plist b/macclient/TCMS.app/Contents/Info.plist new file mode 100644 index 0000000..404a816 --- /dev/null +++ b/macclient/TCMS.app/Contents/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + TCMS + CFBundleIdentifier + com.tyclifford.tcms.macclient + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + TCMS + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + NSHighResolutionCapable + + + diff --git a/macclient/TCMS.app/Contents/MacOS/TCMS b/macclient/TCMS.app/Contents/MacOS/TCMS new file mode 100755 index 0000000..4de4dd5 Binary files /dev/null and b/macclient/TCMS.app/Contents/MacOS/TCMS differ diff --git a/macclient/TCMS.app/Contents/PkgInfo b/macclient/TCMS.app/Contents/PkgInfo new file mode 100644 index 0000000..bd04210 --- /dev/null +++ b/macclient/TCMS.app/Contents/PkgInfo @@ -0,0 +1 @@ +APPL???? \ No newline at end of file diff --git a/macclient/build-app.sh b/macclient/build-app.sh index f2aefc9..ae35e07 100755 --- a/macclient/build-app.sh +++ b/macclient/build-app.sh @@ -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"