- The Mac client’s Media tab now has pagination and a preview/play area.

What changed in 
[main.m](/Users/tyemeclifford/Documents/GH/blog/macclient/AppKitClient/main.m):
Media list now paginates with Previous/Next and 10/25/50/100 per page.
Selecting an image loads an inline preview.
Selecting audio/video loads an AVPlayerView with native playback 
controls.
Added an Open button and double-click support to open media in the 
default Mac app.
Media file sizes now display as readable values like 1.4 MB.
I also updated 
[build-app.sh](/Users/tyemeclifford/Documents/GH/blog/macclient/build-app.sh) 
to link AVKit and AVFoundation, and to honor the app’s 
CFBundleExecutable from Info.plist so the rebuilt bundle launches with 
the expected TCMS executable.
This commit is contained in:
Ty Clifford
2026-07-05 18:32:07 -04:00
parent c391feb450
commit d0496c93f3
5 changed files with 273 additions and 14 deletions
+1
View File
@@ -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=<id>]` and `[video poster=<poster> file=<file>]`.
+2 -2
View File
@@ -5,13 +5,13 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>TCMSMacClient</string>
<string>TCMS</string>
<key>CFBundleIdentifier</key>
<string>com.tyclifford.tcms.macclient</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>TCMS Mac Client</string>
<string>TCMS</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+267 -11
View File
@@ -1,4 +1,5 @@
#import <Cocoa/Cocoa.h>
#import <AVKit/AVKit.h>
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};
+1
View File
@@ -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.
+2 -1
View File
@@ -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"