Add media support: image uploads, gallery rendering, video oEmbed

- New class-media.php: handles image upload via REST, stores in post meta, renders Twitter-style gallery
- Added POST /beep/v1/media/{id} and DELETE routes for media management
- Updated composer: media upload button, preview area
- Updated beep_render_post: displays image gallery + video embed
- Supports YouTube, Vimeo, Twitter/X video URLs via oEmbed
- Max 4 images per beep, 10MB each (JPEG/PNG/GIF/WebP)
This commit is contained in:
Krystie
2026-05-18 04:01:41 -07:00
parent 0807eb74d9
commit 6c9b06ccb2
4 changed files with 305 additions and 0 deletions
+1
View File
@@ -26,6 +26,7 @@ require_once BEEP_DIR . 'includes/class-cpt.php';
require_once BEEP_DIR . 'includes/class-likes.php';
require_once BEEP_DIR . 'includes/class-replies.php';
require_once BEEP_DIR . 'includes/class-moderation.php';
require_once BEEP_DIR . 'includes/class-media.php';
require_once BEEP_DIR . 'includes/class-shortcode.php';
require_once BEEP_DIR . 'includes/class-beep.php';
+220
View File
@@ -0,0 +1,220 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
/**
* Beep_Media — handles image uploads, video URLs, and media rendering.
* Uses WordPress built-in media library (no extra server requirements).
*/
class Beep_Media {
const META_KEY_IMAGES = 'beep_images';
const META_KEY_VIDEO = 'beep_video_url';
private static $image_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private static $max_images = 4;
private static $max_file_size = 10 * 1024 * 1024; // 10 MB
public static function init() {}
/**
* Handle a media upload from the REST API.
*
* @param int $post_id The beep post ID to attach media to.
* @param array $file $_FILES entry.
* @return array|WP_Error
*/
public static function handle_upload($post_id, $file) {
if (!is_array($file) || $file['error'] !== UPLOAD_ERR_OK) {
return new WP_Error('upload_error', 'Upload failed.');
}
if ($file['size'] > self::$max_file_size) {
return new WP_Error('file_too_large', 'File exceeds 10 MB limit.');
}
$mime = mime_content_type($file['tmp_name']);
if (!in_array($mime, self::$image_types, true)) {
return new WP_Error('invalid_type', 'Only JPEG, PNG, GIF, and WebP are allowed.');
}
$existing = self::get_images($post_id);
if (count($existing) >= self::$max_images) {
return new WP_Error('too_many', 'Maximum ' . self::$max_images . ' images per beep.');
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
$file_return = wp_handle_sideload($file, [
'test_form' => false,
'mimes' => [
'jpg|jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
],
]);
if (isset($file_return['error'])) {
return new WP_Error('sideload_error', $file_return['error']);
}
$attachment = [
'post_mime_type' => $file_return['type'],
'post_title' => preg_replace('/\.[^.]+$/', '', basename($file_return['file'])),
'post_content' => '',
'post_status' => 'inherit',
'guid' => $file_return['url'],
];
$attachment_id = wp_insert_attachment($attachment, $file_return['file'], $post_id);
if (is_wp_error($attachment_id)) {
return $attachment_id;
}
$attach_data = wp_generate_attachment_metadata($attachment_id, $file_return['file']);
wp_update_attachment_metadata($attachment_id, $attach_data);
add_post_meta($post_id, self::META_KEY_IMAGES, $attachment_id);
return [
'id' => $attachment_id,
'url' => wp_get_attachment_url($attachment_id),
'thumb' => self::get_image_url($attachment_id, 'thumbnail'),
'sizes' => self::get_image_sizes($attachment_id),
];
}
/**
* Remove a single image from a beep post.
*/
public static function remove_image($post_id, $attachment_id) {
$ids = self::get_images($post_id);
$ids = array_values(array_filter($ids, fn($id) => (int) $id !== (int) $attachment_id));
delete_post_meta($post_id, self::META_KEY_IMAGES);
foreach ($ids as $id) {
add_post_meta($post_id, self::META_KEY_IMAGES, $id);
}
wp_delete_attachment($attachment_id, true);
}
/**
* Get image attachment IDs for a beep post.
*/
public static function get_images($post_id) {
$ids = get_post_meta($post_id, self::META_KEY_IMAGES, false);
return array_filter(array_map('intval', (array) $ids));
}
/**
* Get image srcset-ready sizes for an attachment.
*/
public static function get_image_sizes($attachment_id) {
$sizes = [];
foreach (['thumbnail', 'medium', 'large', 'full'] as $size) {
$img = wp_get_attachment_image_src($attachment_id, $size);
if ($img) {
$sizes[$size] = ['url' => $img[0], 'width' => $img[1], 'height' => $img[2]];
}
}
return $sizes;
}
/**
* Get image URL at a given size.
*/
public static function get_image_url($attachment_id, $size = 'medium') {
$img = wp_get_attachment_image_src($attachment_id, $size);
return $img ? $img[0] : '';
}
/**
* Set a video URL for a beep post.
*/
public static function set_video_url($post_id, $url) {
$url = esc_url_raw($url);
if (!$url) return new WP_Error('invalid_url', 'Invalid video URL.');
update_post_meta($post_id, self::META_KEY_VIDEO, $url);
return true;
}
/**
* Get the video URL for a beep post.
*/
public static function get_video_url($post_id) {
return esc_url_raw(get_post_meta($post_id, self::META_KEY_VIDEO, true));
}
/**
* Detect if a URL is a supported video oEmbed.
*/
public static function detect_video_url($url) {
$patterns = [
'youtube' => '#https?://(?:www\.)?youtube\.com/watch\?v=[^\s&#]+#',
'yt_s' => '#https?://youtu\.be/[^\s&#]+#',
'vimeo' => '#https?://vimeo\.com/\d+#',
'twitter' => '#https?://(?:twitter\.com|x\.com)/[^\s#/]+/status/\d+#',
];
foreach ($patterns as $name => $pattern) {
if (preg_match($pattern, $url)) return $name;
}
return false;
}
/**
* Render the Twitter-style image gallery.
*
* @param int[] $image_ids
* @return string HTML
*/
public static function render_image_gallery($image_ids) {
if (empty($image_ids)) return '';
$count = count($image_ids);
$grid_class = 'beep-gallery';
if ($count === 1) $grid_class .= ' beep-gallery-1';
elseif ($count === 2) $grid_class .= ' beep-gallery-2';
elseif ($count === 3) $grid_class .= ' beep-gallery-3';
else $grid_class .= ' beep-gallery-4';
$html = '<div class="' . esc_attr($grid_class) . '">';
foreach ($image_ids as $i => $id) {
$full = self::get_image_url($id, 'large');
$thumb = self::get_image_url($id, 'medium');
if (!$thumb) $thumb = $full;
if (!$full) continue;
$alt = get_post_meta($id, '_wp_attachment_image_alt', true);
$html .= '<div class="beep-gallery-item">';
$html .= '<a href="' . esc_url($full) . '" target="_blank" rel="noopener" data-beep-lightbox>';
$html .= '<img src="' . esc_url($thumb) . '" alt="' . esc_attr($alt ?: "Beep image") . '" loading="lazy">';
$html .= '</a>';
$html .= '</div>';
}
$html .= '</div>';
return $html;
}
/**
* Render video embed via oEmbed.
*/
public static function render_video_embed($url) {
if (!$url) return '';
$embed = wp_oembed_get($url, ['width' => 560, 'height' => 315]);
if ($embed) {
return '<div class="beep-video-embed">' . $embed . '</div>';
}
return '';
}
/**
* Delete all media for a beep post (when beep is deleted).
*/
public static function delete_media($post_id) {
foreach (self::get_images($post_id) as $id) {
wp_delete_attachment($id, true);
}
delete_post_meta($post_id, self::META_KEY_IMAGES);
delete_post_meta($post_id, self::META_KEY_VIDEO);
}
}
+58
View File
@@ -8,6 +8,7 @@ class Beep_Shortcode {
public static function init() {
add_shortcode('beep_feed', [__CLASS__, 'render_feed']);
add_action('rest_api_init', [__CLASS__, 'register_routes']);
add_action('rest_api_init', [__CLASS__, 'register_media_routes']);
}
public static function register_routes() {
@@ -130,4 +131,61 @@ class Beep_Shortcode {
<?php
return ob_get_clean();
}
/**
* Register additional REST routes for media upload.
*/
public static function register_media_routes() {
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)', [
'methods' => 'POST',
'callback' => [__CLASS__, 'upload_media'],
'permission_callback' => function () {
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
},
]);
register_rest_route('beep/v1', '/media/(?P<post_id>\d+)/(?P<attachment_id>\d+)', [
'methods' => 'DELETE',
'callback' => [__CLASS__, 'delete_media'],
'permission_callback' => function () {
return is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
},
]);
}
public static function upload_media($request) {
$post_id = (int) $request->get_param('post_id');
$post = get_post($post_id);
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
return new WP_Error('not_found', 'Beep not found.', 404);
}
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
}
if (empty($_FILES['file'])) {
return new WP_Error('no_file', 'No file uploaded.');
}
$result = Beep_Media::handle_upload($post_id, $_FILES['file']);
if (is_wp_error($result)) {
return $result;
}
return rest_ensure_response($result);
}
public static function delete_media($request) {
$post_id = (int) $request->get_param('post_id');
$attachment_id = (int) $request->get_param('attachment_id');
$post = get_post($post_id);
if (!$post || $post->post_type !== Beep_CPT::POST_TYPE) {
return new WP_Error('not_found', 'Beep not found.', 404);
}
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('forbidden', 'Cannot edit this beep.', 403);
}
Beep_Media::remove_image($post_id, $attachment_id);
return rest_ensure_response(['ok' => true]);
}
}
+26
View File
@@ -40,10 +40,24 @@ function beep_render_composer() {
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<div class="beep-compose-footer">
<div class="beep-compose-tools">
<label class="beep-media-btn" title="Add up to 4 images">
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple data-beep-media-input hidden>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</label>
<span class="beep-media-count" data-beep-media-count hidden>0/4</span>
</div>
<span class="beep-char-counter" data-beep-counter><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-post-button" data-beep-submit disabled>Beep</button>
</div>
</div>
<div class="beep-compose-media-preview" data-beep-media-preview hidden></div> <div class="beep-compose-footer">
<span class="beep-char-counter" data-beep-counter><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-post-button" data-beep-submit disabled>Beep</button>
</div>
</div>
</div>
</div>
</div>
<?php
return ob_get_clean();
@@ -72,6 +86,8 @@ function beep_render_post($post) {
$user_liked = Beep_Likes::user_has_liked($post->ID, 'post');
$can_delete = current_user_can('delete_post', $post->ID);
$can_interact = is_user_logged_in() && !beep_user_is_banned(get_current_user_id());
$beep_images = Beep_Media::get_images($post->ID);
$beep_video = Beep_Media::get_video_url($post->ID);
ob_start();
?>
@@ -91,6 +107,16 @@ function beep_render_post($post) {
<img class="beep-post-badge" src="<?php echo esc_url(BEEP_URL . 'assets/beep-icon.png'); ?>" alt="" width="32" height="32">
</div>
<div class="beep-text"><?php echo $content; ?></div>
<?php if (!empty($beep_images)) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_image_gallery($beep_images); ?>
</div>
<?php endif; ?>
<?php if ($beep_video) : ?>
<div class="beep-media-attachments">
<?php echo Beep_Media::render_video_embed($beep_video); ?>
</div>
<?php endif; ?>
<div class="beep-actions">
<button class="beep-action beep-action-reply"
data-beep-toggle-reply