From f1e7005b26fdbf5d445d7ddc55d431d9f11efd59 Mon Sep 17 00:00:00 2001 From: Jeremy Rangel Date: Sun, 30 Nov 2025 00:50:56 -0800 Subject: [PATCH] Initial --- .gitignore | 1 + BorderRadiusControl.js | 126 ++++++++++++++++++++++++++++++++ BoxShadowControl.js | 107 +++++++++++++++++++++++++++ CodeControl.js | 66 +++++++++++++++++ ColorControl.js | 83 +++++++++++++++++++++ DirectoryListingItem.js | 139 +++++++++++++++++++++++++++++++++++ JustificationControl.js | 32 +++++++++ QuickViewModal.js | 15 ++++ ShareModal.js | 48 +++++++++++++ SpacingControl.js | 156 ++++++++++++++++++++++++++++++++++++++++ TextSizeControl.js | 60 ++++++++++++++++ package.json | 11 +++ 12 files changed, 844 insertions(+) create mode 100644 .gitignore create mode 100644 BorderRadiusControl.js create mode 100644 BoxShadowControl.js create mode 100644 CodeControl.js create mode 100644 ColorControl.js create mode 100644 DirectoryListingItem.js create mode 100644 JustificationControl.js create mode 100644 QuickViewModal.js create mode 100644 ShareModal.js create mode 100644 SpacingControl.js create mode 100644 TextSizeControl.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/BorderRadiusControl.js b/BorderRadiusControl.js new file mode 100644 index 0000000..6315e09 --- /dev/null +++ b/BorderRadiusControl.js @@ -0,0 +1,126 @@ +import { useState } from '@wordpress/element'; +import { + BaseControl, + Button, + __experimentalUnitControl as UnitControl, + RangeControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { link, linkOff } from '@wordpress/icons'; + +export default function BorderRadiusControl({ label, value = { radius: '0px' }, onChange }) { + // Determine if value is advanced initially + const initialAdvanced = value.topLeft || value.topRight || value.bottomLeft || value.bottomRight ? true : false; + const [advanced, setAdvanced] = useState(initialAdvanced); + + const parseValue = (val) => { + if (!val) return 0; + if (typeof val === 'string') return parseInt(val); + if (val.radius) return parseInt(val.radius); + return 0; + }; + + // Flat handlers + const handleFlatChange = (val) => { + const newValue = { radius: val }; + console.log('Flat change →', newValue); + onChange(newValue); + }; + const handleFlatRangeChange = (val) => { + const currentUnit = value.radius?.replace(/[0-9]/g, '') || 'px'; + const newValue = { radius: `${val}${currentUnit}` }; + console.log('Flat range change →', newValue); + onChange(newValue); + }; + + // Advanced handlers + const handleCornerChange = (corner, val) => { + // Always return a full object with all four corners + const newValue = { + topLeft: corner === 'topLeft' ? val : value.topLeft || '0px', + topRight: corner === 'topRight' ? val : value.topRight || '0px', + bottomRight: corner === 'bottomRight' ? val : value.bottomRight || '0px', + bottomLeft: corner === 'bottomLeft' ? val : value.bottomLeft || '0px', + }; + console.log('Corner change →', newValue); + onChange(newValue); + }; + + return ( + + + {/* Left column: controls */} + + {!advanced && ( + + +
+ +
+
+ )} + + {advanced && ( +
+ handleCornerChange('topLeft', val)} + min={0} + max={500} + /> + handleCornerChange('topRight', val)} + min={0} + max={500} + /> + + handleCornerChange('bottomLeft', val)} + min={0} + max={500} + /> + handleCornerChange('bottomRight', val)} + min={0} + max={500} + /> +
+ )} +
+ + {/* Right column: toggle button */} + + + + )} +/> + + + + ); +} diff --git a/CodeControl.js b/CodeControl.js new file mode 100644 index 0000000..5424c45 --- /dev/null +++ b/CodeControl.js @@ -0,0 +1,66 @@ +import { useState, useEffect } from '@wordpress/element'; +import { TextareaControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Simple validator functions for languages. + * You can expand these for more robust validation. + */ +const validators = { + css: (code) => { + // Very basic CSS validation: checks for opening/closing braces + const openBraces = (code.match(/{/g) || []).length; + const closeBraces = (code.match(/}/g) || []).length; + return openBraces === closeBraces; + }, + js: (code) => { + try { + // Attempt to parse as JavaScript + new Function(code); + return true; + } catch (e) { + return false; + } + }, + html: (code) => { + // Very basic HTML check: matching opening/closing tags + const openTags = (code.match(/<[^/!][^>]*>/g) || []).length; + const closeTags = (code.match(/<\/[^>]+>/g) || []).length; + return openTags === closeTags; + }, +}; + +export default function CodeControl({ + value, + onChange, + language = 'css', + label = __('Code', 'your-text-domain'), + rows = 10, +}) { + const [error, setError] = useState(false); + + useEffect(() => { + // Validate code whenever value or language changes + const isValid = validators[language] + ? validators[language](value) + : true; // fallback: no validation + setError(!isValid); + }, [value, language]); + + return ( +
+ +
+ ); +} diff --git a/ColorControl.js b/ColorControl.js new file mode 100644 index 0000000..5d03756 --- /dev/null +++ b/ColorControl.js @@ -0,0 +1,83 @@ +import { useState, useRef } from '@wordpress/element'; +import { Popover, ToolbarButton, ColorPalette, __experimentalHStack as HStack } from '@wordpress/components'; + +const ColorControl = ({ name, colors = [], value, onChange, className }) => { + const [isOpen, setIsOpen] = useState(false); + const buttonRef = useRef(); + + // Resolve what to show as the swatch color + const resolveColorValue = (val) => { + if (!val) return ''; + if (val.startsWith('var(')) { + // Convert var(--wp--preset--color--primary) → #hex from the palette + const match = colors.find(c => val.includes(c.slug)); + return match?.color || val; + } + return val; + }; + + const selectedColor = resolveColorValue(value); + + const handleChange = (colorValue) => { + // Find if it matches a theme color + const match = colors.find(c => c.color === colorValue); + + let finalValue = colorValue; + if (match?.slug) { + // Store CSS variable instead of raw color code + finalValue = `var(--wp--preset--color--${match.slug})`; + } + + if (onChange) onChange(finalValue); + setIsOpen(false); + }; + + return ( + <> + setIsOpen(!isOpen)} + > + + {name} + + + {isOpen && ( + setIsOpen(false)} + > +
+ +
+
+ )} + + ); +}; + +export default ColorControl; diff --git a/DirectoryListingItem.js b/DirectoryListingItem.js new file mode 100644 index 0000000..ef7813d --- /dev/null +++ b/DirectoryListingItem.js @@ -0,0 +1,139 @@ +import { useState, useEffect } from '@wordpress/element'; + +function isOpenNow(operatingHours = []) { + if (!operatingHours.length) return null; + + const now = new Date(); + const dayNames = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + const today = dayNames[now.getDay()]; + const todaysHours = operatingHours.filter(h => h.day === today); + if (!todaysHours.length) return null; + + for (let h of todaysHours) { + if (h.closed) continue; + if (h.open_24_hours) return true; + if (!h.opening_time || !h.closing_time) continue; + + const [openH, openM] = h.opening_time.split(':').map(Number); + const [closeH, closeM] = h.closing_time.split(':').map(Number); + + const openDate = new Date(now); + openDate.setHours(openH, openM, 0, 0); + const closeDate = new Date(now); + closeDate.setHours(closeH, closeM, 0, 0); + + if (now >= openDate && now <= closeDate) return true; + } + + return false; +} + +export default function DirectoryListingItem({ + post, + imageSrc = '', + imageMediaID = null, + restRoot = '', // must pass multisite REST root from View.js + onQuickView, + style = 'hero', + displayCategoryTitle = '', + displayCategoryURL = '#', + operatingHours = [], + titleTag, + postDescription, +}) { + const [finalImageSrc, setFinalImageSrc] = useState(imageSrc); + const [isOpen, setIsOpen] = useState(null); + const TitleTag = titleTag || 'h3'; + + useEffect(() => { + setIsOpen(isOpenNow(operatingHours)); + }, [operatingHours]); + + // Fetch image URL if imageMediaID is provided + useEffect(() => { + if (!imageMediaID) return; + if (!restRoot) { + console.error('DirectoryListingItem: restRoot is required for multisite media fetch.'); + return; + } + + let isMounted = true; + const url = `${restRoot}wp/v2/media/${imageMediaID}`; + console.log('Fetching media URL:', url); + + fetch(url) + .then(res => { + if (!res.ok) throw new Error(`Media fetch failed: ${res.status}`); + return res.json(); + }) + .then(data => { + if (isMounted && data?.source_url) { + setFinalImageSrc(data.source_url); + } + }) + .catch(err => console.error('Failed to fetch media:', err)); + + return () => { isMounted = false; }; + }, [imageMediaID, restRoot]); + + const heroStyle = style === 'hero' && finalImageSrc ? { backgroundImage: `url(${finalImageSrc})` } : {}; + + const content = ( + <> + {style !== 'hero' && ( +
+ {finalImageSrc ? ( + {post.title?.rendered + ) : ( +
No Image
+ )} +
+ )} + +
+ {displayCategoryTitle && ( + + {displayCategoryTitle} + + )} + + + {post.title?.rendered ? ( + + ) : ( + No title + )} + + + {postDescription ? ( +

+) : null} + + {isOpen !== null && ( + + {isOpen ? 'Open' : 'Closed'} + + )} + + +

+ + ); + + return style === 'hero' ? ( + + {content} + + ) : ( +
+ {content} +
+ ); +} diff --git a/JustificationControl.js b/JustificationControl.js new file mode 100644 index 0000000..81df59d --- /dev/null +++ b/JustificationControl.js @@ -0,0 +1,32 @@ +import { ToolbarGroup, ToolbarButton } from '@wordpress/components'; +import { justifyLeft, justifyCenter, justifyRight } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; + +export default function JustificationControl({ value, onChange, label = __('Justification', 'directory-listings') }) { + const options = [ + { icon: justifyLeft, value: 'left', label: __('Left', 'directory-listings') }, + { icon: justifyCenter, value: 'center', label: __('Center', 'directory-listings') }, + { icon: justifyRight, value: 'right', label: __('Right', 'directory-listings') }, + ]; + + return ( +
+ {/* Label above toolbar */} +
+ {label} +
+ + + {options.map((option) => ( + onChange(option.value)} + /> + ))} + +
+ ); +} diff --git a/QuickViewModal.js b/QuickViewModal.js new file mode 100644 index 0000000..ee9e4c6 --- /dev/null +++ b/QuickViewModal.js @@ -0,0 +1,15 @@ +import { Fragment } from '@wordpress/element'; + +export default function QuickViewModal({ post, onClose }) { + if (!post) return null; + + return ( +
+
e.stopPropagation()}> + +

+
+
+

+ ); +} \ No newline at end of file diff --git a/ShareModal.js b/ShareModal.js new file mode 100644 index 0000000..3adef7f --- /dev/null +++ b/ShareModal.js @@ -0,0 +1,48 @@ +function ShareModal({ postUrl }) { + const [visible, setVisible] = useState(false); + + if (!postUrl) return null; + + return ( + <> + + {visible && ( +
+
+ +

Share this listing

+ + +
+
+ )} + + ); +} diff --git a/SpacingControl.js b/SpacingControl.js new file mode 100644 index 0000000..e974153 --- /dev/null +++ b/SpacingControl.js @@ -0,0 +1,156 @@ +import { useState } from '@wordpress/element'; + +import { + BaseControl, + Button, + RangeControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, + __experimentalUnitControl as UnitControl, + Icon +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { sidesHorizontal, sidesVertical, sidesTop, sidesBottom, sidesLeft, sidesRight, settings } from '@wordpress/icons'; + +export default function SpacingControl({ label, value = {}, onChange, themeSpacing = {} }) { + const [verticalAdvanced, setVerticalAdvanced] = useState(false); + const [horizontalAdvanced, setHorizontalAdvanced] = useState(false); + const [advancedMode, setAdvancedMode] = useState(false); // full advanced toggle + + const parseValue = (val) => (val ? parseInt(val) : 0); + + const handleChange = (sides, val) => { + const unit = val.toString().replace(/[0-9]/g, '') || 'px'; + const newValue = { ...value }; + sides.forEach((side) => { + newValue[side] = `${parseInt(val)}${unit}`; + }); + onChange(newValue); + }; + + // --- Generate marks for RangeControl from theme.json spacing --- + const spacingMarks = themeSpacing?.spacingSizes?.map((s) => ({ + value: parseInt(s.size), // you may need to strip "px" or clamp() + label: s.name, + })) || []; + + // --- Advanced 4-row mode --- + if (advancedMode) { + const sides = [ + { key: 'top', sideIcon: sidesTop }, + { key: 'bottom', sideIcon: sidesBottom }, + { key: 'left', sideIcon: sidesLeft }, + { key: 'right', sideIcon: sidesRight }, + ]; + + return ( + + + + + {sides.map(({ key, sideIcon }) => ( + + + +
+ handleChange([key], val)} + min={0} + max={Math.max(...spacingMarks.map((m) => m.value))} + step={1} + marks={spacingMarks} + withInputField={false} + /> +
+ handleChange([key], val)} + min={0} + max={Math.max(...spacingMarks.map((m) => m.value))} + /> +
+
+ ))} +
+
+ ); + } + + // --- Simple vertical + horizontal mode --- + return ( + + + + + {/* Vertical */} + + + + {verticalAdvanced && ( + handleChange(['top', 'bottom'], val)} + min={0} + max={Math.max(...spacingMarks.map((m) => m.value))} + /> + )} +
+ handleChange(['top', 'bottom'], val)} + min={0} + max={Math.max(...spacingMarks.map((m) => m.value))} + step={1} + marks={spacingMarks} + withInputField={false} + /> +
+