From 37178128edb71f07c346d9e9670eb761d98947ae Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Wed, 01 Oct 2025 04:10:29 +0000
Subject: [PATCH] Video Block
---
src/video/save.js | 22 ++
src/video/editor.scss | 62 ++++++
src/video/render.php | 1
src/video/index.js | 9
src/video/index.php | 0
inc/blocks/_setup.php | 18 +
src/video/style.scss | 35 +++
inc/blocks/VideoCoverBlock.php | 124 ++++++++++++
inc/blocks/RegisterBlocks.php | 1
src/video/block.json | 72 +++++++
src/video/edit.js | 218 +++++++++++++++++++++
src/video/view.js | 12 +
12 files changed, 573 insertions(+), 1 deletions(-)
diff --git a/inc/blocks/RegisterBlocks.php b/inc/blocks/RegisterBlocks.php
index e5f15f5..596de1b 100644
--- a/inc/blocks/RegisterBlocks.php
+++ b/inc/blocks/RegisterBlocks.php
@@ -12,6 +12,7 @@
require(JVB_DIR . '/build/list/render.php');
require(JVB_DIR . '/build/summary/render.php');
require(JVB_DIR . '/build/forms/render.php');
+require(JVB_DIR . '/build/menu/render.php');
function jvbRegisterBlocks():void
{
diff --git a/inc/blocks/VideoCoverBlock.php b/inc/blocks/VideoCoverBlock.php
new file mode 100644
index 0000000..e6292cd
--- /dev/null
+++ b/inc/blocks/VideoCoverBlock.php
@@ -0,0 +1,124 @@
+<?php
+namespace JVBase\blocks;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Video Cover Block Class
+ *
+ * Handles registration and rendering of self-hosted video cover blocks
+ * with poster images and multiple video format support
+ */
+class VideoCoverBlock
+{
+ protected static ?VideoCoverBlock $instance = null;
+ protected string $path = JVB_DIR . '/build/video-cover';
+
+ public static function getInstance(): VideoCoverBlock
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ public function __construct()
+ {
+ add_action('init', [$this, 'registerBlock']);
+ }
+
+ public function registerBlock(): void
+ {
+ register_block_type($this->path, [
+ 'render_callback' => [$this, 'render']
+ ]);
+ }
+
+ /**
+ * Render the video cover block
+ */
+ public function render($attributes, $content): string
+ {
+ // Extract attributes with defaults
+ $poster_id = $attributes['posterId'] ?? 0;
+ $video_sources = $attributes['videoSources'] ?? [];
+ $mobile_sources = $attributes['mobileSources'] ?? [];
+ $css_class = $attributes['className'] ?? '';
+ $fade_class = $attributes['fadeEffect'] ?? false ? 'fade' : '';
+ //Get date of current post
+ global $post;
+ $date = date('c',strtotime($post->post_date));
+ $title = $attributes['title'] ?? $post->post_title;
+ $description = $attributes['description'] ?? $post->post_excerpt;
+
+ // If no video sources, return empty
+ if (empty($video_sources)) {
+ return '';
+ }
+
+
+
+ // Get poster URL
+ $poster_url = $poster_id ? wp_get_attachment_url($poster_id) : '';
+
+ // Build video tag
+ $classes = trim("video-cover {$fade_class} {$css_class}");
+
+ $html = '<section class="'.esc_attr($classes).'">
+ <script type="application/ld+json">
+ {
+ "@context": "https://schema.org/",
+ "@type": "VideoObject",
+ "name": "'.$title.'",
+ "thumbnailUrl": "'.$poster_url.'",
+ "contentUrl": "'.$video_sources[0]['url'].'",
+ "description": "'.$description.'",
+ "uploadDate": "'.$date.'"
+ }
+ </script>
+ <div class="wrap">
+ <div class="video-container">';
+ $html .= '<video';
+ $html .= ' muted loop playsinline autoplay';
+
+ if ($poster_url) {
+ $html .= ' poster="' . esc_url($poster_url) . '"';
+ }
+
+ $html .= '>';
+
+ // Add mobile sources first (lower resolution)
+ foreach ($mobile_sources as $source) {
+ if (!empty($source['url']) && !empty($source['mime'])) {
+ $html .= '<source';
+ $html .= ' src="' . esc_url($source['url']) . '"';
+ $html .= ' type="' . esc_attr($source['mime']) . '"';
+ $html .= ' media="(max-width: 767px)"';
+ $html .= '>';
+ }
+ }
+
+ // Add desktop sources
+ foreach ($video_sources as $source) {
+ if (!empty($source['url']) && !empty($source['mime'])) {
+ $html .= '<source';
+ $html .= ' src="' . esc_url($source['url']) . '"';
+ $html .= ' type="' . esc_attr($source['mime']) . '"';
+
+ // Add media query for desktop if mobile sources exist
+ if (!empty($mobile_sources)) {
+ $html .= ' media="(min-width: 768px)"';
+ }
+
+ $html .= '>';
+ }
+ }
+
+ $html .= '</video>';
+ $html .= '</div></div><div class="inner-wrap"></div></section>';
+
+ return $html;
+ }
+}
diff --git a/inc/blocks/_setup.php b/inc/blocks/_setup.php
index 59ee716..88cde26 100644
--- a/inc/blocks/_setup.php
+++ b/inc/blocks/_setup.php
@@ -1,7 +1,7 @@
<?php
use JVBase\utility\Features;
-require(JVB_DIR . '/inc/blocks/RegisterBlocks.php');
+//require(JVB_DIR . '/inc/blocks/RegisterBlocks.php');
require(JVB_DIR . '/inc/blocks/CustomBlocks.php');
if (Features::forSite()->has('feed_block')) {
@@ -17,5 +17,21 @@
require(JVB_DIR . '/inc/blocks/SummaryBlock.php');
new JVBase\blocks\SummaryBlock();
+require(JVB_DIR . '/inc/blocks/VideoCoverBlock.php');
+new JVBase\blocks\VideoCoverBlock();
+
require(JVB_DIR . '/inc/blocks/FormBlock.php');
new JVBase\blocks\FormBlock();
+
+
+function jvbRegisterBlockCategory(array $categories):array
+{
+ return array_merge($categories, [
+ [
+ 'slug' => 'jvb',
+ 'title' => get_bloginfo('name'),
+ 'icon' => 'art'
+ ]
+ ]);
+}
+add_filter('block_categories_all', 'jvbRegisterBlockCategory');
diff --git a/src/video/block.json b/src/video/block.json
new file mode 100644
index 0000000..5e92360
--- /dev/null
+++ b/src/video/block.json
@@ -0,0 +1,72 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "jvb/video-cover",
+ "version": "1.0.0",
+ "title": "Video Cover",
+ "category": "jvb",
+ "icon": "video-alt3",
+ "description": "Self-hosted video cover with poster and multiple format support",
+ "supports": {
+ "html": false,
+ "align": ["wide", "full"],
+ "spacing": {
+ "margin": true,
+ "padding": true
+ }
+ },
+ "attributes": {
+ "posterId": {
+ "type": "number",
+ "default": 0
+ },
+ "posterUrl": {
+ "type": "string",
+ "default": ""
+ },
+ "videoSources": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number"
+ },
+ "url": {
+ "type": "string"
+ },
+ "mime": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "mobileSources": {
+ "type": "array",
+ "default": [],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "number"
+ },
+ "url": {
+ "type": "string"
+ },
+ "mime": {
+ "type": "string"
+ }
+ }
+ }
+ },
+ "fadeEffect": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "textdomain": "jvb",
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css",
+ "style": "file:./style-index.css"
+}
diff --git a/src/video/edit.js b/src/video/edit.js
new file mode 100644
index 0000000..e871a44
--- /dev/null
+++ b/src/video/edit.js
@@ -0,0 +1,218 @@
+import { __ } from '@wordpress/i18n';
+import {
+ useBlockProps,
+ InspectorControls,
+ MediaUpload,
+ MediaUploadCheck,
+ InnerBlocks
+} from '@wordpress/block-editor';
+import {
+ PanelBody,
+ Button,
+ ToggleControl,
+ BaseControl
+} from '@wordpress/components';
+import './editor.scss';
+
+const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/ogg', 'video/ogv'];
+
+export default function Edit({ attributes, setAttributes }) {
+ const {
+ posterId,
+ posterUrl,
+ videoSources,
+ mobileSources,
+ fadeEffect
+ } = attributes;
+
+ const blockProps = useBlockProps({
+ className: 'video-cover-editor'
+ });
+
+ const onSelectPoster = (media) => {
+ setAttributes({
+ posterId: media.id,
+ posterUrl: media.url
+ });
+ };
+
+ const onSelectVideo = (media, isMobile = false) => {
+ const newSource = {
+ id: media.id,
+ url: media.url,
+ mime: media.mime
+ };
+
+ if (isMobile) {
+ // Check if this format already exists in mobile sources
+ const exists = mobileSources.some(s => s.mime === media.mime);
+ if (!exists) {
+ setAttributes({
+ mobileSources: [...mobileSources, newSource]
+ });
+ }
+ } else {
+ // Check if this format already exists in desktop sources
+ const exists = videoSources.some(s => s.mime === media.mime);
+ if (!exists) {
+ setAttributes({
+ videoSources: [...videoSources, newSource]
+ });
+ }
+ }
+ };
+
+ const removeVideoSource = (index, isMobile = false) => {
+ if (isMobile) {
+ const updated = [...mobileSources];
+ updated.splice(index, 1);
+ setAttributes({ mobileSources: updated });
+ } else {
+ const updated = [...videoSources];
+ updated.splice(index, 1);
+ setAttributes({ videoSources: updated });
+ }
+ };
+
+ const renderVideoSourceList = (sources, isMobile = false) => {
+ if (sources.length === 0) return null;
+
+ return (
+ <ul className="video-source-list">
+ {sources.map((source, index) => (
+ <li key={index} className="video-source-item">
+ <span className="video-source-mime">{source.mime}</span>
+ <Button
+ isDestructive
+ isSmall
+ onClick={() => removeVideoSource(index, isMobile)}
+ >
+ {__('Remove', 'jvb')}
+ </Button>
+ </li>
+ ))}
+ </ul>
+ );
+ };
+
+ return (
+ <>
+ <InspectorControls>
+ <PanelBody title={__('Video Settings', 'jvb')} initialOpen={true}>
+ <BaseControl
+ label={__('Poster Image', 'jvb')}
+ help={__('Image shown while video loads', 'jvb')}
+ >
+ <MediaUploadCheck>
+ <MediaUpload
+ onSelect={onSelectPoster}
+ allowedTypes={['image']}
+ value={posterId}
+ render={({ open }) => (
+ <>
+ {posterUrl && (
+ <img
+ src={posterUrl}
+ alt={__('Poster preview', 'jvb')}
+ style={{ maxWidth: '100%', marginBottom: '10px' }}
+ />
+ )}
+ <Button
+ onClick={open}
+ variant={posterUrl ? 'secondary' : 'primary'}
+ >
+ {posterUrl
+ ? __('Change Poster', 'jvb')
+ : __('Select Poster', 'jvb')}
+ </Button>
+ {posterUrl && (
+ <Button
+ isDestructive
+ onClick={() => setAttributes({ posterId: 0, posterUrl: '' })}
+ style={{ marginLeft: '10px' }}
+ >
+ {__('Remove', 'jvb')}
+ </Button>
+ )}
+ </>
+ )}
+ />
+ </MediaUploadCheck>
+ </BaseControl>
+
+ <BaseControl
+ label={__('Desktop Video Sources', 'jvb')}
+ help={__('Add multiple formats for better browser support', 'jvb')}
+ >
+ {renderVideoSourceList(videoSources, false)}
+ <MediaUploadCheck>
+ <MediaUpload
+ onSelect={(media) => onSelectVideo(media, false)}
+ allowedTypes={ALLOWED_VIDEO_TYPES}
+ render={({ open }) => (
+ <Button
+ onClick={open}
+ variant="secondary"
+ >
+ {__('Add Desktop Video', 'jvb')}
+ </Button>
+ )}
+ />
+ </MediaUploadCheck>
+ </BaseControl>
+
+ <BaseControl
+ label={__('Mobile Video Sources (Optional)', 'jvb')}
+ help={__('Smaller videos for mobile devices', 'jvb')}
+ >
+ {renderVideoSourceList(mobileSources, true)}
+ <MediaUploadCheck>
+ <MediaUpload
+ onSelect={(media) => onSelectVideo(media, true)}
+ allowedTypes={ALLOWED_VIDEO_TYPES}
+ render={({ open }) => (
+ <Button
+ onClick={open}
+ variant="secondary"
+ >
+ {__('Add Mobile Video', 'jvb')}
+ </Button>
+ )}
+ />
+ </MediaUploadCheck>
+ </BaseControl>
+
+ <ToggleControl
+ label={__('Fade Effect', 'jvb')}
+ help={__('Add fade class to video element', 'jvb')}
+ checked={fadeEffect}
+ onChange={(value) => setAttributes({ fadeEffect: value })}
+ />
+ </PanelBody>
+ </InspectorControls>
+
+ <div {...blockProps}>
+ {posterUrl ? (
+ <div className="video-cover-preview">
+ <img src={posterUrl} alt={__('Video poster', 'jvb')} />
+ <div className="video-overlay">
+ <p>
+ {videoSources.length} {__('desktop source(s)', 'jvb')}
+ {mobileSources.length > 0 &&
+ `, ${mobileSources.length} ${__('mobile source(s)', 'jvb')}`
+ }
+ </p>
+ </div>
+ </div>
+ ) : (
+ <div className="video-cover-placeholder">
+ <p>{__('Configure video sources in the sidebar →', 'jvb')}</p>
+ </div>
+ )}
+ </div>
+ <div className="inner-blocks">
+ <InnerBlocks />
+ </div>
+ </>
+ );
+}
diff --git a/src/video/editor.scss b/src/video/editor.scss
new file mode 100644
index 0000000..1600218
--- /dev/null
+++ b/src/video/editor.scss
@@ -0,0 +1,62 @@
+.video-cover-editor {
+ min-height: 200px;
+ background: #f0f0f0;
+ border: 2px dashed #ccc;
+ border-radius: 4px;
+
+ .video-cover-preview {
+ position: relative;
+ width: 100%;
+
+ img {
+ width: 100%;
+ height: auto;
+ display: block;
+ }
+
+ .video-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+ color: white;
+ padding: 10px;
+ font-size: 14px;
+
+ p {
+ margin: 0;
+ }
+ }
+ }
+
+ .video-cover-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 200px;
+ color: #666;
+ font-size: 16px;
+ }
+}
+
+.video-source-list {
+ list-style: none;
+ margin: 10px 0;
+ padding: 0;
+
+ .video-source-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: #f5f5f5;
+ border-radius: 4px;
+ margin-bottom: 5px;
+
+ .video-source-mime {
+ font-family: monospace;
+ font-size: 13px;
+ }
+ }
+}
diff --git a/src/video/index.js b/src/video/index.js
new file mode 100644
index 0000000..c0cc9f0
--- /dev/null
+++ b/src/video/index.js
@@ -0,0 +1,9 @@
+import { registerBlockType } from '@wordpress/blocks';
+import './style.scss';
+import Edit from './edit';
+import metadata from './block.json';
+
+registerBlockType(metadata.name, {
+ edit: Edit,
+ save: () => null // Server-side rendering
+});
diff --git a/src/video/index.php b/src/video/index.php
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/video/index.php
diff --git a/src/video/render.php b/src/video/render.php
new file mode 100644
index 0000000..b3d9bbc
--- /dev/null
+++ b/src/video/render.php
@@ -0,0 +1 @@
+<?php
diff --git a/src/video/save.js b/src/video/save.js
new file mode 100644
index 0000000..83b144f
--- /dev/null
+++ b/src/video/save.js
@@ -0,0 +1,22 @@
+/**
+ * React hook that is used to mark the block wrapper element.
+ * It provides all the necessary props like the class name.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/#useblockprops
+ */
+import { useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * The save function defines the way in which the different attributes should
+ * be combined into the final markup, which is then serialized by the block
+ * editor into `post_content`.
+ *
+ * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#save
+ *
+ * @return {WPElement} Element to render.
+ */
+export default function save() {
+ // This is a dynamic block that is rendered on the server side
+ // Return null to let WordPress handle the saving and rendering
+ return null;
+}
diff --git a/src/video/style.scss b/src/video/style.scss
new file mode 100644
index 0000000..c31a76f
--- /dev/null
+++ b/src/video/style.scss
@@ -0,0 +1,35 @@
+.video-cover {
+ width: 100%;
+ height: auto;
+ display: block;
+ object-fit: cover;
+
+ &.fade {
+ animation: fadeIn 1s ease-in;
+ }
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+
+.video-cover video {
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ left: 50%;
+ opacity: 0.85;
+ mix-blend-mode: multiply;
+ transform: translate(-40%,-10%);
+ height: 105vh;
+ width: 300vw!important;
+ filter: grayscale(100%) contrast(1);
+ flex: 1 0 100%;
+ max-width: none;
+}
diff --git a/src/video/view.js b/src/video/view.js
new file mode 100644
index 0000000..8974786
--- /dev/null
+++ b/src/video/view.js
@@ -0,0 +1,12 @@
+const observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ loadVideo(entry.target);
+ observer.unobserve(entry.target);
+ }
+ });
+});
+
+document.querySelectorAll('.video-container .placeholder').forEach(el => {
+ observer.observe(el);
+});
--
Gitblit v1.10.0