Files
beep/includes/helpers.php
T
Krystie 6c9b06ccb2 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)
2026-05-18 04:01:41 -07:00

358 lines
16 KiB
PHP
Executable File

<?php
if (!defined('ABSPATH')) {
exit;
}
/**
* Wordmark (bird + "Beep!") shown at the top of the feed.
*/
function beep_logo_svg() {
$src = esc_url(BEEP_URL . 'assets/beep-logo.png');
return '<img class="beep-logo" src="' . $src . '" alt="Beep">';
}
/**
* Is this user banned from posting/liking/replying?
*/
function beep_user_is_banned($user_id) {
if (!$user_id) {
return false;
}
return get_user_meta($user_id, 'beep_banned', true) === '1';
}
/**
* Render the composer textarea (for logged-in, non-banned users).
*/
function beep_render_composer() {
$user = wp_get_current_user();
$avatar = get_avatar($user->ID, 48, '', $user->display_name, ['class' => 'beep-avatar']);
ob_start();
?>
<div class="beep-compose" data-beep-compose>
<?php echo $avatar; ?>
<div class="beep-compose-body">
<textarea
class="beep-input beep-compose-input"
data-beep-input
placeholder="What's happening?"
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();
}
/**
* Render a single beep post + its replies tree.
*/
function beep_render_post($post) {
if (!$post) {
return '';
}
$author = get_userdata($post->post_author);
$avatar = get_avatar($author ? $author->ID : 0, 48, '', '', ['class' => 'beep-avatar']);
$name = $author ? esc_html($author->display_name) : 'Unknown';
$handle = $author ? '@' . esc_html($author->user_login) : '';
$ts = strtotime($post->post_date_gmt . ' UTC');
$time = beep_relative_time($ts);
$time_iso = gmdate('c', $ts);
$time_full = mysql2date('F j, Y g:i a', $post->post_date);
$content = beep_format_content($post->post_content);
$like_count = Beep_Likes::get_count($post->ID, 'post');
$reply_count = Beep_Replies::reply_count($post->ID);
$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();
?>
<article class="beep-post" data-beep-post="<?php echo (int) $post->ID; ?>">
<?php echo $avatar; ?>
<div class="beep-post-body">
<div class="beep-meta">
<span class="beep-name"><?php echo $name; ?></span>
<?php if ($handle) : ?>
<span class="beep-handle"><?php echo $handle; ?></span>
<?php endif; ?>
<span class="beep-dot">·</span>
<time class="beep-time"
datetime="<?php echo esc_attr($time_iso); ?>"
title="<?php echo esc_attr($time_full); ?>"><?php echo esc_html($time); ?></time>
<span class="beep-spacer"></span>
<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
type="button"
title="Reply">
<?php echo beep_icon('reply'); ?>
<span class="beep-count" data-beep-reply-count><?php echo $reply_count > 0 ? esc_html($reply_count) : ''; ?></span>
</button>
<button class="beep-action beep-action-like<?php echo $user_liked ? ' is-liked' : ''; ?>"
data-beep-like-post="<?php echo (int) $post->ID; ?>"
type="button"
title="Like">
<span class="beep-icon-outline"><?php echo beep_icon('heart'); ?></span>
<span class="beep-icon-filled"><?php echo beep_icon('heart_filled'); ?></span>
<span class="beep-count" data-beep-like-count><?php echo $like_count > 0 ? esc_html($like_count) : ''; ?></span>
</button>
<button class="beep-action beep-action-share"
data-beep-share="<?php echo esc_url(get_permalink($post->ID)); ?>"
type="button"
title="Copy link">
<?php echo beep_icon('share'); ?>
</button>
</div>
<?php if ($can_delete) : ?>
<div class="beep-delete-row">
<button class="beep-delete-link"
data-beep-delete="<?php echo (int) $post->ID; ?>"
type="button"
aria-label="Delete this beep"
title="Delete">&times;</button>
</div>
<?php endif; ?>
<?php if ($can_interact) : ?>
<?php echo beep_render_reply_form($post->ID, 0); ?>
<?php endif; ?>
<div class="beep-replies" data-beep-replies>
<?php
$all = Beep_Replies::get_replies($post->ID);
echo beep_render_replies_tree($all, 0, 0, $post->ID, $can_interact);
?>
</div>
</div>
</article>
<?php
return ob_get_clean();
}
/**
* Render a reply form (used both on top-level posts and individual replies).
* @param int $post_id The beep post ID this reply belongs to.
* @param int $parent The parent comment ID (0 for top-level reply).
*/
function beep_render_reply_form($post_id, $parent) {
ob_start();
?>
<div class="beep-reply-form" data-beep-reply-form data-beep-parent="<?php echo (int) $parent; ?>" hidden>
<textarea
class="beep-input beep-reply-input"
placeholder="Post your reply"
maxlength="<?php echo (int) BEEP_CHAR_LIMIT; ?>"
rows="1"></textarea>
<div class="beep-reply-footer">
<span class="beep-char-counter"><?php echo (int) BEEP_CHAR_LIMIT; ?></span>
<button class="beep-button beep-reply-submit"
data-beep-reply-submit="<?php echo (int) $post_id; ?>"
data-beep-reply-parent="<?php echo (int) $parent; ?>"
type="button"
disabled>Reply</button>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Recursively render replies. Visual indent caps at depth 2.
*/
function beep_render_replies_tree($comments, $parent_id, $depth, $post_id, $can_interact) {
$out = '';
foreach ($comments as $c) {
if ((int) $c->comment_parent !== (int) $parent_id) {
continue;
}
$out .= beep_render_reply($c, $comments, $depth, $post_id, $can_interact);
}
return $out;
}
/**
* Render a single reply, plus its nested children.
*/
function beep_render_reply($comment, $all_comments = [], $depth = 0, $post_id = 0, $can_interact = false) {
if (!$comment) {
return '';
}
$author_id = (int) $comment->user_id;
$avatar = $author_id
? get_avatar($author_id, 36, '', '', ['class' => 'beep-avatar beep-avatar-sm'])
: get_avatar($comment->comment_author_email, 36, '', '', ['class' => 'beep-avatar beep-avatar-sm']);
$author = $author_id ? get_userdata($author_id) : null;
$name = $author ? esc_html($author->display_name) : esc_html($comment->comment_author);
$handle = $author ? '@' . esc_html($author->user_login) : '';
$ts = strtotime($comment->comment_date_gmt . ' UTC');
$time = beep_relative_time($ts);
$time_iso = gmdate('c', $ts);
$content = beep_format_content($comment->comment_content);
$can_delete = current_user_can('moderate_comments');
$like_count = Beep_Likes::get_count($comment->comment_ID, 'reply');
$user_liked = Beep_Likes::user_has_liked($comment->comment_ID, 'reply');
$depth_class = 'beep-depth-' . min((int) $depth, 2);
ob_start();
?>
<div class="beep-reply <?php echo esc_attr($depth_class); ?>" data-beep-reply="<?php echo (int) $comment->comment_ID; ?>">
<?php echo $avatar; ?>
<div class="beep-reply-body">
<div class="beep-meta">
<span class="beep-name"><?php echo $name; ?></span>
<?php if ($handle) : ?>
<span class="beep-handle"><?php echo $handle; ?></span>
<?php endif; ?>
<span class="beep-dot">·</span>
<time class="beep-time" datetime="<?php echo esc_attr($time_iso); ?>"><?php echo esc_html($time); ?></time>
<span class="beep-spacer"></span>
<img class="beep-post-badge beep-post-badge-sm" src="<?php echo esc_url(BEEP_URL . 'assets/beep-icon.png'); ?>" alt="" width="24" height="24">
</div>
<div class="beep-text"><?php echo $content; ?></div>
<?php if ($can_interact) : ?>
<div class="beep-actions beep-actions-reply">
<button class="beep-action beep-action-reply"
data-beep-toggle-reply
type="button"
title="Reply">
<?php echo beep_icon('reply'); ?>
</button>
<button class="beep-action beep-action-like<?php echo $user_liked ? ' is-liked' : ''; ?>"
data-beep-like-reply="<?php echo (int) $comment->comment_ID; ?>"
type="button"
title="Like">
<span class="beep-icon-outline"><?php echo beep_icon('heart'); ?></span>
<span class="beep-icon-filled"><?php echo beep_icon('heart_filled'); ?></span>
<span class="beep-count" data-beep-like-count><?php echo $like_count > 0 ? esc_html($like_count) : ''; ?></span>
</button>
</div>
<?php echo beep_render_reply_form($post_id, $comment->comment_ID); ?>
<?php endif; ?>
<?php if ($can_delete) : ?>
<div class="beep-delete-row">
<button class="beep-delete-link"
data-beep-delete-reply="<?php echo (int) $comment->comment_ID; ?>"
type="button"
aria-label="Delete this reply"
title="Delete">&times;</button>
</div>
<?php endif; ?>
<?php
// Recursively render children
$children = beep_render_replies_tree($all_comments, $comment->comment_ID, $depth + 1, $post_id, $can_interact);
if ($children) {
echo '<div class="beep-replies beep-replies-nested" data-beep-replies>' . $children . '</div>';
}
?>
</div>
</div>
<?php
return ob_get_clean();
}
/**
* Format beep text: linkify URLs, @mentions, #hashtags.
*/
function beep_format_content($text) {
$text = wp_kses($text, []);
// Hashtags
$text = preg_replace_callback('/(^|\s)#([\p{L}0-9_]+)/u', function ($m) {
return $m[1] . '<a href="#" class="beep-tag">#' . esc_html($m[2]) . '</a>';
}, $text);
// @mentions
$text = preg_replace_callback('/(^|\s)@([a-zA-Z0-9_]+)/', function ($m) {
$user = get_user_by('login', $m[2]);
if ($user) {
return $m[1] . '<a href="' . esc_url(get_author_posts_url($user->ID)) . '" class="beep-mention">@' . esc_html($m[2]) . '</a>';
}
return $m[1] . '<span class="beep-mention beep-mention-stale">@' . esc_html($m[2]) . '</span>';
}, $text);
// URLs
$text = preg_replace_callback('#(https?://[^\s<]+)#i', function ($m) {
$url = esc_url($m[1]);
$display = parse_url($url, PHP_URL_HOST);
if (!$display) {
$display = $url;
}
return '<a href="' . $url . '" class="beep-link" target="_blank" rel="noopener nofollow">' . esc_html($display) . '</a>';
}, $text);
// Line breaks
$text = nl2br($text);
return $text;
}
function beep_relative_time($timestamp) {
$now = time();
$diff = $now - $timestamp;
if ($diff < 0) {
$diff = 0;
}
if ($diff < 60) {
return $diff . 's';
}
if ($diff < 3600) {
return floor($diff / 60) . 'm';
}
if ($diff < 86400) {
return floor($diff / 3600) . 'h';
}
if ($diff < 604800) {
return floor($diff / 86400) . 'd';
}
return gmdate('M j', $timestamp);
}
function beep_icon($name) {
$icons = [
'reply' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01z"/></svg>',
'heart' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>',
'heart_filled' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M20.884 13.19c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"/></svg>',
'share' => '<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z"/></svg>',
];
return isset($icons[$name]) ? $icons[$name] : '';
}