From d0496c93f3e8b15df03d7a80976b03fdacfd7b77 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Sun, 5 Jul 2026 18:32:07 -0400 Subject: [PATCH] =?UTF-8?q?-=20The=20Mac=20client=E2=80=99s=20Media=20tab?= =?UTF-8?q?=20now=20has=20pagination=20and=20a=20preview/play=20area.=20Wh?= =?UTF-8?q?at=20changed=20in=20[main.m](/Users/tyemeclifford/Documents/GH/?= =?UTF-8?q?blog/macclient/AppKitClient/main.m):=20Media=20list=20now=20pag?= =?UTF-8?q?inates=20with=20Previous/Next=20and=2010/25/50/100=20per=20page?= =?UTF-8?q?.=20Selecting=20an=20image=20loads=20an=20inline=20preview.=20S?= =?UTF-8?q?electing=20audio/video=20loads=20an=20AVPlayerView=20with=20nat?= =?UTF-8?q?ive=20playback=20controls.=20Added=20an=20Open=20button=20and?= =?UTF-8?q?=20double-click=20support=20to=20open=20media=20in=20the=20defa?= =?UTF-8?q?ult=20Mac=20app.=20Media=20file=20sizes=20now=20display=20as=20?= =?UTF-8?q?readable=20values=20like=201.4=20MB.=20I=20also=20updated=20[bu?= =?UTF-8?q?ild-app.sh](/Users/tyemeclifford/Documents/GH/blog/macclient/bu?= =?UTF-8?q?ild-app.sh)=20to=20link=20AVKit=20and=20AVFoundation,=20and=20t?= =?UTF-8?q?o=20honor=20the=20app=E2=80=99s=20CFBundleExecutable=20from=20I?= =?UTF-8?q?nfo.plist=20so=20the=20rebuilt=20bundle=20launches=20with=20the?= =?UTF-8?q?=20expected=20TCMS=20executable.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + macclient/AppKitClient/Info.plist | 4 +- macclient/AppKitClient/main.m | 278 ++++++++++++++++++++++++++++-- macclient/README.md | 1 + macclient/build-app.sh | 3 +- 5 files changed, 273 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5fb96d..a4d8c60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to Ty Clifford's Content Management System are documented he - Added `api.php` for `.env` username/password authenticated remote management of content, media uploads, and comments. - 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 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 declared shortcode parser with PHP-array/JSON configuration, including `[youtube=]` and `[video poster= file=]`. diff --git a/macclient/AppKitClient/Info.plist b/macclient/AppKitClient/Info.plist index 4f78c37..404a816 100644 --- a/macclient/AppKitClient/Info.plist +++ b/macclient/AppKitClient/Info.plist @@ -5,13 +5,13 @@ CFBundleDevelopmentRegion en CFBundleExecutable - TCMSMacClient + TCMS CFBundleIdentifier com.tyclifford.tcms.macclient CFBundleInfoDictionaryVersion 6.0 CFBundleName - TCMS Mac Client + TCMS CFBundlePackageType APPL CFBundleShortVersionString diff --git a/macclient/AppKitClient/main.m b/macclient/AppKitClient/main.m index 646cc87..f946c37 100644 --- a/macclient/AppKitClient/main.m +++ b/macclient/AppKitClient/main.m @@ -1,4 +1,5 @@ #import +#import static NSString *TCMSString(id value) { if ([value isKindOfClass:[NSString class]]) { @@ -37,6 +38,28 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { return normalized; } +static NSString *TCMSFormatBytes(id value) { + long long bytes = 0; + if ([value respondsToSelector:@selector(longLongValue)]) { + bytes = [value longLongValue]; + } + if (bytes <= 0) { + return @""; + } + + NSArray *units = @[@"B", @"KB", @"MB", @"GB", @"TB"]; + double amount = (double)bytes; + NSUInteger unitIndex = 0; + while (amount >= 1024.0 && unitIndex + 1 < units.count) { + amount /= 1024.0; + unitIndex++; + } + + return unitIndex == 0 + ? [NSString stringWithFormat:@"%lld %@", bytes, units[unitIndex]] + : [NSString stringWithFormat:@"%.1f %@", amount, units[unitIndex]]; +} + @interface TCMSMarkdownTextView : NSTextView @end @@ -53,6 +76,7 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { @property (nonatomic, copy) NSString *baseURLString; @property (nonatomic, copy) NSString *username; @property (nonatomic, copy) NSString *password; +- (NSURL *)absoluteURLForPath:(NSString *)path; - (void)requestAction:(NSString *)action query:(NSDictionary *)query method:(NSString *)method body:(NSDictionary *)body completion:(void (^)(NSDictionary *, NSError *))completion; - (void)uploadFile:(NSURL *)fileURL completion:(void (^)(NSDictionary *, NSError *))completion; @end @@ -70,6 +94,34 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { return [base URLByAppendingPathComponent:@"api.php"]; } +- (NSURL *)absoluteURLForPath:(NSString *)path { + NSString *rawPath = TCMSString(path); + if (rawPath.length == 0) { + return nil; + } + + NSURL *directURL = [NSURL URLWithString:rawPath]; + if (directURL.scheme.length > 0) { + return directURL; + } + + NSURL *base = [NSURL URLWithString:self.baseURLString ?: @""]; + if (!base) { + return nil; + } + + if ([rawPath hasPrefix:@"/"]) { + NSURLComponents *components = [NSURLComponents componentsWithURL:base resolvingAgainstBaseURL:NO]; + components.path = rawPath; + components.query = nil; + components.fragment = nil; + return components.URL; + } + + NSURL *siteBase = [[base.lastPathComponent lowercaseString] isEqualToString:@"api.php"] ? [base URLByDeletingLastPathComponent] : base; + return [siteBase URLByAppendingPathComponent:rawPath]; +} + - (NSURL *)URLForAction:(NSString *)action query:(NSDictionary *)query { NSURL *endpoint = [self endpointURL]; if (!endpoint) { @@ -212,6 +264,13 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { @property (nonatomic, strong) NSButton *autoCorrectButton; @property (nonatomic, strong) NSTextView *markdownView; @property (nonatomic, strong) NSScrollView *editorScroll; +@property (nonatomic, strong) NSView *mediaPreviewContainer; +@property (nonatomic, strong) NSTextField *mediaPreviewLabel; +@property (nonatomic, strong) NSTextField *mediaDetailLabel; +@property (nonatomic, strong) NSImageView *mediaImageView; +@property (nonatomic, strong) AVPlayerView *mediaPlayerView; +@property (nonatomic, strong) NSButton *openMediaButton; +@property (nonatomic, copy) NSString *mediaPreviewURLString; @property (nonatomic, strong) NSPopUpButton *commentStatusPopup; @property (nonatomic, strong) NSButton *previousPageButton; @property (nonatomic, strong) NSButton *nextPageButton; @@ -302,7 +361,7 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable backing:NSBackingStoreBuffered defer:NO]; - self.window.title = @"TCMS Mac Client"; + self.window.title = @"TCMS"; self.window.minSize = NSMakeSize(1040, 680); self.window.delegate = self; self.window.releasedWhenClosed = NO; @@ -457,6 +516,14 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { } self.editorScroll = nil; self.markdownView = nil; + [self.mediaPlayerView.player pause]; + self.mediaPreviewContainer = nil; + self.mediaPreviewLabel = nil; + self.mediaDetailLabel = nil; + self.mediaImageView = nil; + self.mediaPlayerView = nil; + self.openMediaButton = nil; + self.mediaPreviewURLString = nil; } - (void)showContentSection:(NSString *)section { @@ -580,11 +647,82 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { - (void)showMediaSection { self.section = @"media"; [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)]; - NSScrollView *mediaScroll = [self buildTableWithFrame:NSMakeRect(20, 20, self.workspace.bounds.size.width - 40, 650) columns:@[@"name", @"mime", @"url"]]; - mediaScroll.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.openMediaButton = [self addButton:@"Open" frame:NSMakeRect(410, 690, 80, 30) action:@selector(openSelectedMedia:)]; + self.openMediaButton.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.previousPageButton = [NSButton buttonWithTitle:@"Previous" target:self action:@selector(previousContentPage)]; + self.previousPageButton.frame = NSMakeRect(20, 20, 78, 28); + self.previousPageButton.bezelStyle = NSBezelStyleRounded; + self.previousPageButton.autoresizingMask = NSViewMaxYMargin; + [self.workspace addSubview:self.previousPageButton]; + + self.pageInfoLabel = [self label:@"1 / 1" frame:NSMakeRect(104, 24, 66, 20) font:[NSFont systemFontOfSize:12]]; + self.pageInfoLabel.alignment = NSTextAlignmentCenter; + self.pageInfoLabel.autoresizingMask = NSViewMaxYMargin; + [self.workspace addSubview:self.pageInfoLabel]; + + self.nextPageButton = [NSButton buttonWithTitle:@"Next" target:self action:@selector(nextContentPage)]; + self.nextPageButton.frame = NSMakeRect(176, 20, 54, 28); + self.nextPageButton.bezelStyle = NSBezelStyleRounded; + self.nextPageButton.autoresizingMask = NSViewMaxYMargin; + [self.workspace addSubview:self.nextPageButton]; + + self.perPagePopup = [[NSPopUpButton alloc] initWithFrame:NSMakeRect(236, 20, 64, 28)]; + self.perPagePopup.autoresizingMask = NSViewMaxYMargin; + self.perPagePopup.toolTip = @"Items per page"; + self.perPagePopup.target = self; + self.perPagePopup.action = @selector(contentPerPageChanged:); + [self.perPagePopup addItemsWithTitles:@[@"10", @"25", @"50", @"100"]]; + NSString *perPageTitle = [NSString stringWithFormat:@"%ld", (long)self.contentListPerPage]; + if ([self.perPagePopup itemWithTitle:perPageTitle]) { + [self.perPagePopup selectItemWithTitle:perPageTitle]; + } else { + self.contentListPerPage = 25; + [self.perPagePopup selectItemWithTitle:@"25"]; + } + [self.workspace addSubview:self.perPagePopup]; + + CGFloat previewLeft = 460; + CGFloat previewWidth = self.workspace.bounds.size.width - previewLeft - 20; + self.mediaPreviewContainer = [[NSView alloc] initWithFrame:NSMakeRect(previewLeft, 20, previewWidth, 650)]; + self.mediaPreviewContainer.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.mediaPreviewContainer.wantsLayer = YES; + self.mediaPreviewContainer.layer.backgroundColor = [NSColor colorWithCalibratedWhite:0.11 alpha:1].CGColor; + [self.workspace addSubview:self.mediaPreviewContainer]; + + self.mediaPreviewLabel = [self label:@"No media selected" frame:NSMakeRect(16, 612, previewWidth - 32, 24) font:[NSFont boldSystemFontOfSize:15]]; + self.mediaPreviewLabel.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; + self.mediaPreviewLabel.textColor = [NSColor colorWithCalibratedWhite:0.92 alpha:1]; + [self.mediaPreviewContainer addSubview:self.mediaPreviewLabel]; + + self.mediaDetailLabel = [self label:@"" frame:NSMakeRect(16, 584, previewWidth - 32, 20) font:[NSFont systemFontOfSize:12]]; + self.mediaDetailLabel.autoresizingMask = NSViewWidthSizable | NSViewMinYMargin; + self.mediaDetailLabel.textColor = [NSColor colorWithCalibratedWhite:0.72 alpha:1]; + [self.mediaPreviewContainer addSubview:self.mediaDetailLabel]; + + NSRect previewFrame = NSMakeRect(16, 16, previewWidth - 32, 552); + self.mediaImageView = [[NSImageView alloc] initWithFrame:previewFrame]; + self.mediaImageView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.mediaImageView.imageScaling = NSImageScaleProportionallyUpOrDown; + self.mediaImageView.hidden = YES; + [self.mediaPreviewContainer addSubview:self.mediaImageView]; + + self.mediaPlayerView = [[AVPlayerView alloc] initWithFrame:previewFrame]; + self.mediaPlayerView.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + self.mediaPlayerView.controlsStyle = AVPlayerViewControlsStyleDefault; + self.mediaPlayerView.hidden = YES; + [self.mediaPreviewContainer addSubview:self.mediaPlayerView]; + + [self resetMediaPreview]; [self refreshMedia]; [self refreshWindowLayout]; } @@ -694,12 +832,15 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { self.previousPageButton.enabled = total > 0 && self.contentListPage > 1; self.nextPageButton.enabled = total > 0 && self.contentListPage < totalPages; self.perPagePopup.enabled = total > 0; + if ([self.section isEqualToString:@"media"]) { + [self resetMediaPreview]; + } [self refreshWindowLayout]; } - (NSString *)contentListStatusMessageForType:(NSString *)type { NSInteger total = self.allTableItems.count; - NSString *noun = [type isEqualToString:@"page"] ? @"pages" : @"posts"; + NSString *noun = [type isEqualToString:@"media"] ? @"media items" : ([type isEqualToString:@"page"] ? @"pages" : @"posts"); if (total == 0) { return [NSString stringWithFormat:@"No %@ found.", noun]; } @@ -709,13 +850,20 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { return [NSString stringWithFormat:@"Showing %ld-%ld of %ld %@.", (long)start, (long)end, (long)total, noun]; } +- (NSString *)currentListStatusMessage { + if ([self.section isEqualToString:@"media"]) { + return [self contentListStatusMessageForType:@"media"]; + } + return [self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]; +} + - (void)previousContentPage { if (self.contentListPage <= 1) { return; } self.contentListPage -= 1; [self applyContentListPagination]; - [self setStatus:[self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]]; + [self setStatus:[self currentListStatusMessage]]; } - (void)nextContentPage { @@ -725,7 +873,7 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { } self.contentListPage += 1; [self applyContentListPagination]; - [self setStatus:[self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]]; + [self setStatus:[self currentListStatusMessage]]; } - (void)contentPerPageChanged:(id)sender { @@ -734,7 +882,7 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { self.contentListPage = 1; [NSUserDefaults.standardUserDefaults setInteger:selected forKey:@"contentListPerPage"]; [self applyContentListPagination]; - [self setStatus:[self contentListStatusMessageForType:[self.section isEqualToString:@"pages"] ? @"page" : @"post"]]; + [self setStatus:[self currentListStatusMessage]]; } - (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { @@ -753,7 +901,7 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { field.lineBreakMode = NSLineBreakByTruncatingTail; } NSDictionary *item = self.tableItems[row]; - field.stringValue = TCMSString(item[tableColumn.identifier]); + field.stringValue = [tableColumn.identifier isEqualToString:@"size"] ? TCMSFormatBytes(item[@"size"]) : TCMSString(item[tableColumn.identifier]); return field; } @@ -765,6 +913,8 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { NSDictionary *item = self.tableItems[row]; if ([self.section isEqualToString:@"posts"] || [self.section isEqualToString:@"pages"]) { [self loadContentSlug:TCMSString(item[@"slug"])]; + } else if ([self.section isEqualToString:@"media"]) { + [self previewMediaItem:item]; } } @@ -1080,9 +1230,10 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { [self setStatus:error.localizedDescription]; return; } - self.tableItems = [NSMutableArray arrayWithArray:json[@"items"] ?: @[]]; - [self.tableView reloadData]; - [self setStatus:@"Media refreshed."]; + NSArray *items = [json[@"items"] isKindOfClass:[NSArray class]] ? json[@"items"] : @[]; + self.allTableItems = [NSMutableArray arrayWithArray:items]; + [self applyContentListPagination]; + [self setStatus:[self currentListStatusMessage]]; }]; } @@ -1104,6 +1255,111 @@ static NSString *TCMSMarkdownCompareString(NSString *value) { }]; } +- (NSURL *)absoluteURLForMediaItem:(NSDictionary *)item { + NSString *url = TCMSString(item[@"url"]); + if (url.length == 0) { + url = TCMSString(item[@"path"]); + } + return [self.client absoluteURLForPath:url]; +} + +- (NSString *)mediaDetailForItem:(NSDictionary *)item { + NSMutableArray *parts = [NSMutableArray array]; + NSString *mime = TCMSString(item[@"mime"]); + NSString *size = TCMSFormatBytes(item[@"size"]); + NSString *path = TCMSString(item[@"path"]); + if (mime.length > 0) { + [parts addObject:mime]; + } + if (size.length > 0) { + [parts addObject:size]; + } + if (path.length > 0) { + [parts addObject:path]; + } + return [parts componentsJoinedByString:@" "]; +} + +- (void)resetMediaPreview { + [self.mediaPlayerView.player pause]; + self.mediaPlayerView.player = nil; + self.mediaPlayerView.hidden = YES; + self.mediaImageView.image = nil; + self.mediaImageView.hidden = YES; + self.mediaPreviewURLString = nil; + self.openMediaButton.enabled = NO; + self.mediaPreviewLabel.stringValue = @"No media selected"; + self.mediaDetailLabel.stringValue = @""; +} + +- (void)previewMediaItem:(NSDictionary *)item { + NSURL *url = [self absoluteURLForMediaItem:item]; + if (!url) { + [self resetMediaPreview]; + [self setStatus:@"That media item does not have a usable URL."]; + return; + } + + NSString *mime = [TCMSString(item[@"mime"]) lowercaseString]; + NSString *urlString = url.absoluteString ?: @""; + self.mediaPreviewURLString = urlString; + self.openMediaButton.enabled = YES; + self.mediaPreviewLabel.stringValue = TCMSString(item[@"name"]).length ? TCMSString(item[@"name"]) : url.lastPathComponent; + self.mediaDetailLabel.stringValue = [self mediaDetailForItem:item]; + self.mediaImageView.image = nil; + self.mediaImageView.hidden = YES; + [self.mediaPlayerView.player pause]; + self.mediaPlayerView.player = nil; + self.mediaPlayerView.hidden = YES; + + if ([mime hasPrefix:@"image/"]) { + self.mediaImageView.hidden = NO; + [self setStatus:@"Loading image preview."]; + [[[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + NSImage *image = data.length > 0 ? [[NSImage alloc] initWithData:data] : nil; + dispatch_async(dispatch_get_main_queue(), ^{ + if (![self.mediaPreviewURLString isEqualToString:urlString]) { + return; + } + if (image) { + self.mediaImageView.image = image; + [self setStatus:@"Image preview ready."]; + } else { + self.mediaImageView.hidden = YES; + [self setStatus:error.localizedDescription ?: @"Unable to load image preview."]; + } + }); + }] resume]; + return; + } + + if ([mime hasPrefix:@"video/"] || [mime hasPrefix:@"audio/"]) { + AVPlayer *player = [AVPlayer playerWithURL:url]; + self.mediaPlayerView.player = player; + self.mediaPlayerView.hidden = NO; + [self setStatus:[mime hasPrefix:@"audio/"] ? @"Audio ready to play." : @"Video ready to play."]; + return; + } + + [self setStatus:@"Preview unavailable for this file type. Use Open to view it."]; +} + +- (void)openSelectedMedia:(id)sender { + NSDictionary *item = [self selectedTableItem]; + if (!item) { + [self setStatus:@"Select a media item first."]; + return; + } + + NSURL *url = [self absoluteURLForMediaItem:item]; + if (!url) { + [self setStatus:@"That media item does not have a usable URL."]; + return; + } + + [NSWorkspace.sharedWorkspace openURL:url]; +} + - (void)refreshComments { NSString *status = self.commentStatusPopup.titleOfSelectedItem ?: @"pending"; NSDictionary *query = [status isEqualToString:@"all"] ? @{} : @{@"status": status}; diff --git a/macclient/README.md b/macclient/README.md index befe167..6fe9211 100644 --- a/macclient/README.md +++ b/macclient/README.md @@ -30,6 +30,7 @@ The client automatically calls `api.php` under that URL. Enter the username/pass - 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. - Upload media to `bl-content/uploads`. +- Page through media, preview images, play audio/video, and open files in the default Mac app. - List and moderate comments. - Local autosave to Application Support. - Optional remote autosave as draft. diff --git a/macclient/build-app.sh b/macclient/build-app.sh index 3e4f7f3..f2aefc9 100755 --- a/macclient/build-app.sh +++ b/macclient/build-app.sh @@ -4,10 +4,11 @@ set -eu ROOT="$(cd "$(dirname "$0")" && pwd)" 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 "$SRC" -o "$APP/Contents/MacOS/TCMSMacClient" +/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"