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