10 files added
2 files modified
| | |
| | | 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 |
| | | { |
| New file |
| | |
| | | <?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; |
| | | } |
| | | } |
| | |
| | | <?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')) { |
| | |
| | | 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'); |
| New file |
| | |
| | | { |
| | | "$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" |
| | | } |
| New file |
| | |
| | | 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> |
| | | </> |
| | | ); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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 |
| | | }); |
| New file |
| | |
| | | /** |
| | | * 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; |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| New file |
| | |
| | | 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); |
| | | }); |