diff --git a/assets/beep.css b/assets/beep.css index c1d94ba..ac30875 100755 --- a/assets/beep.css +++ b/assets/beep.css @@ -947,3 +947,113 @@ justify-content: flex-end; gap: 8px; } + +/* ---------- Voice note button in composer ---------- */ +.beep-voice-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + color: var(--beep-accent); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.beep-voice-btn:hover { + background: rgba(29, 155, 240, 0.10); + color: var(--beep-accent); +} + +.beep-voice-btn svg { + display: block; + flex-shrink: 0; +} + +/* ---------- Voice note preview in composer ---------- */ +.beep-compose-voice-preview { + margin-top: 8px; + margin-bottom: 4px; +} + +.beep-compose-voice-preview[hidden] { + display: none; +} + +.beep-voice-preview-card { + display: inline-flex; + align-items: center; + gap: 8px; + background: var(--beep-hover); + border: 1px solid var(--beep-border); + border-radius: 9999px; + padding: 6px 12px 6px 10px; + font-size: 13px; + color: var(--beep-text); +} + +.beep-voice-preview-card svg { + color: var(--beep-accent); + flex-shrink: 0; +} + +.beep-voice-remove { + background: transparent; + border: 0; + color: var(--beep-text-secondary); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 0 0 4px; + display: flex; + align-items: center; + transition: color 0.15s ease; +} + +.beep-voice-remove:hover { + color: #F4212E; +} + +/* ---------- Voice note player in posts ---------- */ +.beep-voice-note { + margin: 6px 0 2px; + background: var(--beep-hover); + border: 1px solid var(--beep-border); + border-radius: 12px; + padding: 10px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.beep-voice-note audio { + width: 100%; + height: 36px; + display: block; +} + +/* Style the audio element across browsers */ +.beep-voice-note audio::-webkit-media-controls-panel { + background: var(--beep-bg); +} + +.beep-voice-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--beep-text-secondary); + font-weight: 500; +} + +.beep-voice-label svg { + color: var(--beep-accent); + flex-shrink: 0; +} + +.beep-voice-duration { + font-size: 12px; + color: var(--beep-text-secondary); + font-variant-numeric: tabular-nums; +} diff --git a/assets/beep.js b/assets/beep.js index 68e8b2b..0e94811 100755 --- a/assets/beep.js +++ b/assets/beep.js @@ -65,13 +65,23 @@ input.value = ''; refresh(); - // Upload pending images to this new beep + // Upload pending images and voice note to this new beep var pending = compose._beepPendingImages; + var pendingVoice = compose._beepPendingVoice; + var uploadDone = function () { + reloadFeed(feed, 'recent'); + }; + + var tasks = []; if (pending && pending.length && data.id) { - uploadPendingImages(data.id, pending, compose).then(function () { - // Refresh the beep in the DOM to show uploaded images - reloadFeed(feed, 'recent'); - }); + tasks.push(uploadPendingImages(data.id, pending, compose)); + } + if (pendingVoice && data.id) { + tasks.push(uploadPendingVoice(data.id, pendingVoice, compose)); + } + + if (tasks.length) { + Promise.all(tasks).then(uploadDone); } }) .catch(function () { @@ -341,9 +351,57 @@ var input = compose.querySelector('[data-beep-media-input]'); var preview = compose.querySelector('[data-beep-media-preview]'); var counter = compose.querySelector('[data-beep-media-count]'); - if (!input) return; + var voiceInput = compose.querySelector('[data-beep-voice-input]'); + var voicePreview = compose.querySelector('[data-beep-voice-preview]'); + if (!input && !voiceInput) return; var pendingImages = []; + var pendingVoice = null; // { file, id } + + // Voice note input + if (voiceInput) { + voiceInput.addEventListener('change', function (e) { + var files = e.target.files; + if (!files || !files.length) return; + var file = files[0]; + if (file.size > 20 * 1024 * 1024) { + window.alert('Voice note must be under 20 MB.'); + voiceInput.value = ''; + return; + } + var accepted = ['audio/mpeg','audio/ogg','audio/wav','audio/webm','audio/mp4','audio/x-m4a']; + if (accepted.indexOf(file.type) === -1) { + window.alert('Only MP3, OGG, WAV, WebM, and M4A audio are allowed.'); + voiceInput.value = ''; + return; + } + if (pendingVoice) { + window.alert('Only one voice note per beep.'); + voiceInput.value = ''; + return; + } + var reader = new FileReader(); + reader.onload = function (ev) { + var id = 'voice-' + Date.now(); + pendingVoice = { file: file, id: id }; + voicePreview.innerHTML = '
Voice note
'; + voicePreview.hidden = false; + }; + reader.readAsDataURL(file); + voiceInput.value = ''; + }); + } + + // Voice remove button + if (voicePreview) { + voicePreview.addEventListener('click', function (e) { + var btn = e.target.closest('[data-beep-voice-remove]'); + if (!btn) return; + pendingVoice = null; + voicePreview.innerHTML = ''; + voicePreview.hidden = true; + }); + } input.addEventListener('change', function (e) { var files = Array.prototype.slice.call(e.target.files); @@ -397,6 +455,7 @@ }); compose._beepPendingImages = pendingImages; + compose._beepPendingVoice = pendingVoice; } function uploadPendingImages(postId, pendingImages, compose) { @@ -426,6 +485,29 @@ }); } + function uploadPendingVoice(postId, pendingVoice, compose) { + if (!pendingVoice) return Promise.resolve(); + var formData = new FormData(); + formData.append('file', pendingVoice.file); + return fetch(config.restUrl + 'voice/' + postId, { + method: 'POST', + headers: { 'X-WP-Nonce': config.nonce }, + credentials: 'same-origin', + body: formData + }) + .then(handleResponse) + .then(function () { + pendingVoice = null; + if (compose) { + var vp = compose.querySelector('[data-beep-voice-preview]'); + if (vp) { vp.innerHTML = ''; vp.hidden = true; } + } + }) + .catch(function (err) { + console.warn('Voice upload failed:', err); + }); + } + function handleResponse(res) { if (!res.ok) return res.json().then(function (j) { throw j; }, function () { throw res; }); return res.json(); diff --git a/includes/class-media.php b/includes/class-media.php index 31e4174..33e2d32 100644 --- a/includes/class-media.php +++ b/includes/class-media.php @@ -217,4 +217,98 @@ class Beep_Media { delete_post_meta($post_id, self::META_KEY_IMAGES); delete_post_meta($post_id, self::META_KEY_VIDEO); } -} + // Voice note support + private static $audio_types = ['audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/mp4', 'audio/x-m4a']; + private static $max_audio_size = 20 * 1024 * 1024; // 20 MB + + public static function handle_voice_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_audio_size) { + return new WP_Error('file_too_large', 'Voice note exceeds 20 MB limit.'); + } + $mime = mime_content_type($file['tmp_name']); + if (!in_array($mime, self::$audio_types, true)) { + return new WP_Error('invalid_type', 'Only MP3, OGG, WAV, WebM, and M4A audio are allowed.'); + } + + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/media.php'; + + $file_return = wp_handle_upload($file, [ + 'test_form' => false, + 'mimes' => [ + 'mp3' => 'audio/mpeg', + 'ogg' => 'audio/ogg', + 'wav' => 'audio/wav', + 'webm' => 'audio/webm', + 'm4a' => 'audio/mp4', + ], + ]); + + if (isset($file_return['error'])) { + return new WP_Error('upload_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); + + update_post_meta($post_id, 'beep_voice_url', wp_get_attachment_url($attachment_id)); + update_post_meta($post_id, 'beep_voice_id', $attachment_id); + + return [ + 'id' => $attachment_id, + 'url' => wp_get_attachment_url($attachment_id), + ]; + } + + public static function get_voice_url($post_id) { + return esc_url_raw(get_post_meta($post_id, 'beep_voice_url', true)); + } + + public static function remove_voice($post_id) { + $id = (int) get_post_meta($post_id, 'beep_voice_id', true); + if ($id) { + wp_delete_attachment($id, true); + } + delete_post_meta($post_id, 'beep_voice_url'); + delete_post_meta($post_id, 'beep_voice_id'); + } + + public static function render_voice_note($post_id) { + $url = self::get_voice_url($post_id); + if (!$url) return ''; + $duration = ''; + $meta = get_post_meta($post_id, 'beep_voice_id', true); + if ($meta) { + $metadata = wp_get_attachment_metadata($meta); + if ($metadata && isset($metadata['length_formatted'])) { + $duration = $metadata['length_formatted']; + } + } + $dur_html = $duration ? '' . esc_html($duration) . '' : ''; + return '
' . + '' . + '
Voice note
' . + $dur_html . + '
'; + } + +} \ No newline at end of file diff --git a/includes/class-shortcode.php b/includes/class-shortcode.php index 6e1353a..25ab616 100755 --- a/includes/class-shortcode.php +++ b/includes/class-shortcode.php @@ -150,6 +150,13 @@ class Beep_Shortcode { return is_user_logged_in() && !beep_user_is_banned(get_current_user_id()); }, ]); + register_rest_route('beep/v1', '/voice/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [__CLASS__, 'upload_voice'], + 'permission_callback' => function () { + return is_user_logged_in() && !beep_user_is_banned(get_current_user_id()); + }, + ]); } public static function upload_media($request) { @@ -188,4 +195,25 @@ class Beep_Shortcode { Beep_Media::remove_image($post_id, $attachment_id); return rest_ensure_response(['ok' => true]); } -} + public static function upload_voice($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_voice_upload($post_id, $_FILES['file']); + if (is_wp_error($result)) { + return $result; + } + return rest_ensure_response($result); + } + +} \ No newline at end of file diff --git a/includes/helpers.php b/includes/helpers.php index 896ee75..c068f7b 100755 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -46,12 +46,23 @@ function beep_render_composer() { + - + +
+ ID); ?> +
+