| 1 |
import * as React from "react"; |
| 2 |
import GalleryContext from "../context/Gallery"; |
| 3 |
import { useQuery } from "react-query"; |
| 4 |
import produce from "immer"; |
| 5 |
import { Helmet } from "react-helmet"; |
| 6 |
import { useDebouncedCallback } from "use-debounce"; |
| 7 |
|
| 8 |
import { Theme, ThemeSetting, ThemeSettingType } from "~/providers/Themes"; |
| 9 |
import ThemesContext from "../context/Themes"; |
| 10 |
|
| 11 |
type GalleryProviderProps = React.PropsWithChildren<{ id?: string }>; |
| 12 |
|
| 13 |
export type GalleryState = { |
| 14 |
id?: number; |
| 15 |
cache_timeout?: number; |
| 16 |
date_created?: string; |
| 17 |
featured_video?: string; |
| 18 |
gallery_width?: string; |
| 19 |
resource_uri?: string; |
| 20 |
source_url?: string; |
| 21 |
theme_name?: string; |
| 22 |
title?: string; |
| 23 |
video_limit?: number; |
| 24 |
appearanceRules?: GalleryAppearanceRule[]; |
| 25 |
allow_downloads?: boolean; |
| 26 |
sort?: SortType; |
| 27 |
direction?: SortDirection; |
| 28 |
enable_search?: boolean; |
| 29 |
enable_tags?: boolean; |
| 30 |
enable_playlist?: boolean; |
| 31 |
per_page?: number; |
| 32 |
}; |
| 33 |
|
| 34 |
type SortDirection = "asc" | "desc"; |
| 35 |
type SortType = |
| 36 |
| "date" |
| 37 |
| "likes" |
| 38 |
| "comments" |
| 39 |
| "plays" |
| 40 |
| "alphabetical" |
| 41 |
| "duration" |
| 42 |
| "default"; |
| 43 |
interface GalleryResponse { |
| 44 |
id: number; |
| 45 |
cache_timeout: number; |
| 46 |
date_created: string; |
| 47 |
featured_video: string; |
| 48 |
gallery_width: string; |
| 49 |
resource_uri: string; |
| 50 |
source_url: string; |
| 51 |
theme_name: string; |
| 52 |
title: string; |
| 53 |
video_limit: number; |
| 54 |
allow_downloads?: boolean; |
| 55 |
sort?: SortType; |
| 56 |
direction?: SortDirection; |
| 57 |
enable_search?: boolean; |
| 58 |
enable_tags?: boolean; |
| 59 |
enable_playlist?: boolean; |
| 60 |
per_page?: number; |
| 61 |
} |
| 62 |
|
| 63 |
export type GalleryAppearanceRule = { |
| 64 |
id: string; |
| 65 |
css: string; |
| 66 |
}; |
| 67 |
|
| 68 |
type Action = |
| 69 |
| { type: `HYDRATE`; payload: GalleryResponse } |
| 70 |
| { type: `EDIT_GALLERY_STATE`; payload: GalleryState } |
| 71 |
| { type: `RESET_GALLERY_APPEARANCE` } |
| 72 |
| { type: `UPDATE_GALLERY_APPEARANCE`; payload: ThemeSetting }; |
| 73 |
|
| 74 |
const initialState = { |
| 75 |
appearanceRules: [], |
| 76 |
}; |
| 77 |
|
| 78 |
const convertToDashedAttribute = (attr: string) => { |
| 79 |
let shimmedAttribute; |
| 80 |
|
| 81 |
// backwards-compat for old gallery theme settings |
| 82 |
// removes grid prefix from deprecated theme attributes |
| 83 |
// for browser compatibility |
| 84 |
if (attr === `gridColumnGap`) { |
| 85 |
shimmedAttribute = `columnGap`; |
| 86 |
} else if (attr === `gridRowGap`) { |
| 87 |
shimmedAttribute = `rowGap`; |
| 88 |
} else { |
| 89 |
shimmedAttribute = attr; |
| 90 |
} |
| 91 |
|
| 92 |
return shimmedAttribute.replace(/[A-Z]/g, (a) => "-" + a.toLowerCase()); |
| 93 |
}; |
| 94 |
|
| 95 |
const computeValue = ( |
| 96 |
type: ThemeSettingType, |
| 97 |
transform: string = "", |
| 98 |
value: string |
| 99 |
) => { |
| 100 |
const suffix = type === "numeric" || type === "slider" ? "px" : ""; |
| 101 |
|
| 102 |
if (transform !== "") { |
| 103 |
return `${transform.replace(/{{value}}/, value + suffix)}`; |
| 104 |
} else { |
| 105 |
return `${value}${suffix}`; |
| 106 |
} |
| 107 |
}; |
| 108 |
|
| 109 |
const reducer = (state: GalleryState, action: Action) => { |
| 110 |
switch (action.type) { |
| 111 |
case "HYDRATE": |
| 112 |
case `EDIT_GALLERY_STATE`: { |
| 113 |
return { |
| 114 |
...state, |
| 115 |
...action.payload, |
| 116 |
}; |
| 117 |
} |
| 118 |
|
| 119 |
case `RESET_GALLERY_APPEARANCE`: { |
| 120 |
return produce(state, (next) => { |
| 121 |
next.appearanceRules = []; |
| 122 |
}); |
| 123 |
} |
| 124 |
|
| 125 |
case `UPDATE_GALLERY_APPEARANCE`: { |
| 126 |
let rules: GalleryAppearanceRule[] = []; |
| 127 |
|
| 128 |
const buildCSS = ( |
| 129 |
target: string, |
| 130 |
attribute: string, |
| 131 |
computedValue: string, |
| 132 |
label: string |
| 133 |
) => |
| 134 |
`/* ${label} */ |
| 135 |
${ |
| 136 |
namespace ? "#vimeography-gallery-" + state.id : "" |
| 137 |
}${target} { ${attribute}: ${computedValue}${ |
| 138 |
important ? " !important;" : ";" |
| 139 |
} } |
| 140 |
`; |
| 141 |
|
| 142 |
const { |
| 143 |
id, |
| 144 |
label, |
| 145 |
type, |
| 146 |
value, |
| 147 |
properties, |
| 148 |
namespace, |
| 149 |
important, |
| 150 |
expressions = [], |
| 151 |
} = action.payload; |
| 152 |
|
| 153 |
properties.map((property, index) => { |
| 154 |
const computedValue = computeValue(type, property.transform, value); |
| 155 |
const attribute = convertToDashedAttribute(property.attribute); |
| 156 |
|
| 157 |
rules.push({ |
| 158 |
id: `${id}-property-${index}`, |
| 159 |
css: buildCSS(property.target, attribute, computedValue, label), |
| 160 |
}); |
| 161 |
}); |
| 162 |
|
| 163 |
expressions.map((expression, index) => { |
| 164 |
const attribute = convertToDashedAttribute(expression.attribute); |
| 165 |
|
| 166 |
let calculatedValue; |
| 167 |
|
| 168 |
switch (expression.operator) { |
| 169 |
case "+": |
| 170 |
calculatedValue = Math.ceil( |
| 171 |
parseInt(value) + eval(expression.value) |
| 172 |
); |
| 173 |
break; |
| 174 |
case "-": |
| 175 |
calculatedValue = Math.ceil( |
| 176 |
parseInt(value) - eval(expression.value) |
| 177 |
); |
| 178 |
break; |
| 179 |
case "/": |
| 180 |
calculatedValue = Math.ceil( |
| 181 |
parseInt(value) / eval(expression.value) |
| 182 |
); |
| 183 |
break; |
| 184 |
case "*": |
| 185 |
calculatedValue = Math.ceil( |
| 186 |
parseInt(value) * eval(expression.value) |
| 187 |
); |
| 188 |
break; |
| 189 |
} |
| 190 |
|
| 191 |
const computedValue = computeValue( |
| 192 |
type, |
| 193 |
expression.transform, |
| 194 |
calculatedValue.toString() |
| 195 |
); |
| 196 |
|
| 197 |
rules.push({ |
| 198 |
id: `${id}-expression-${index}`, |
| 199 |
css: buildCSS(expression.target, attribute, computedValue, label), |
| 200 |
}); |
| 201 |
}); |
| 202 |
|
| 203 |
return produce(state, (next) => { |
| 204 |
rules.map((rule) => { |
| 205 |
const index = next.appearanceRules.findIndex( |
| 206 |
(el) => el.id === rule.id |
| 207 |
); |
| 208 |
|
| 209 |
if (index === -1) { |
| 210 |
next.appearanceRules.push(rule); |
| 211 |
} else { |
| 212 |
next.appearanceRules[index] = rule; |
| 213 |
} |
| 214 |
}); |
| 215 |
}); |
| 216 |
} |
| 217 |
|
| 218 |
default: |
| 219 |
// console.log(`unknown action type: ${action.type}`); |
| 220 |
return state; |
| 221 |
} |
| 222 |
}; |
| 223 |
|
| 224 |
const GalleryProvider = (props: GalleryProviderProps) => { |
| 225 |
const themesCtx = React.useContext(ThemesContext); |
| 226 |
const [state, dispatch] = React.useReducer(reducer, initialState); |
| 227 |
|
| 228 |
const { isLoading, error, data } = useQuery( |
| 229 |
[`galleries`, props.id], |
| 230 |
() => { |
| 231 |
return fetch( |
| 232 |
window.vimeographyApiSettings.root + |
| 233 |
`vimeography/v1/galleries/${props.id}` |
| 234 |
).then((res) => { |
| 235 |
return res.json(); |
| 236 |
}); |
| 237 |
}, |
| 238 |
{ staleTime: Infinity } |
| 239 |
); |
| 240 |
|
| 241 |
React.useEffect(() => { |
| 242 |
if (!data) return; |
| 243 |
dispatch({ payload: data, type: "HYDRATE" }); |
| 244 |
}, [data]); |
| 245 |
|
| 246 |
// Detect any theme CSS customizations on load and |
| 247 |
// send them to the themes context to hydrate |
| 248 |
// the appearance controls with the correct values |
| 249 |
React.useEffect(() => { |
| 250 |
if (!data) return; |
| 251 |
if (!themesCtx.data) return; |
| 252 |
|
| 253 |
const activeTheme: Theme = themesCtx.data.find( |
| 254 |
(theme: Theme) => theme.name === data.theme_name |
| 255 |
); |
| 256 |
|
| 257 |
if (!activeTheme) return; |
| 258 |
|
| 259 |
const customStyle = document.getElementById( |
| 260 |
`vimeography-gallery-${props.id}-custom-css` |
| 261 |
); |
| 262 |
|
| 263 |
if (!customStyle) return; |
| 264 |
console.log(`custom style found!`); |
| 265 |
console.log(customStyle); |
| 266 |
console.log(`rules:`); |
| 267 |
console.dir(customStyle.sheet.cssRules); |
| 268 |
// since it is possible that the element it hidden (as in a modal) |
| 269 |
// try to get the defined value based on the loaded stylesheet |
| 270 |
activeTheme.settings.map((setting) => { |
| 271 |
setting.properties.map((prop) => { |
| 272 |
// build the expected selector |
| 273 |
|
| 274 |
// accounts for incorrect single-colon pseudo selectors in any theme settings |
| 275 |
// https://regex101.com/r/zzlv1J/1 |
| 276 |
const modifiedTarget = prop.target.replace( |
| 277 |
/([^:])(:)([^:])/g, |
| 278 |
"$1::$3" |
| 279 |
); |
| 280 |
|
| 281 |
const selectorToMatch = setting.namespace |
| 282 |
? `#vimeography-gallery-${props.id}${modifiedTarget}` |
| 283 |
: modifiedTarget; |
| 284 |
|
| 285 |
console.log( |
| 286 |
`attempting to match theme setting in custom stylesheet rules: ${selectorToMatch}` |
| 287 |
); |
| 288 |
|
| 289 |
// search the stylesheet for the selector |
| 290 |
for (let rule of customStyle.sheet.cssRules) { |
| 291 |
// need to find a match for the target defined in our theme settings |
| 292 |
|
| 293 |
if (rule.selectorText === selectorToMatch) { |
| 294 |
// get the value for the current prop attribute |
| 295 |
|
| 296 |
console.log(`we have a match`); |
| 297 |
|
| 298 |
let value = rule.style.getPropertyValue( |
| 299 |
convertToDashedAttribute(prop.attribute) |
| 300 |
); |
| 301 |
|
| 302 |
// if no value, no customization found for this particular setting |
| 303 |
if (!value) continue; |
| 304 |
|
| 305 |
// console.log(`value found for ${selectorToMatch}!`); |
| 306 |
// console.log(value); |
| 307 |
|
| 308 |
// check if it needs to be un-transformed to extract the value |
| 309 |
if (prop.transform) { |
| 310 |
// console.log( |
| 311 |
// `extracting value from transform ${prop.transform}` |
| 312 |
// ); |
| 313 |
|
| 314 |
const searchTerm = `{{value}}`; |
| 315 |
|
| 316 |
// find the index of `{{value}}` in the transform |
| 317 |
const tokenIndex = prop.transform.indexOf(searchTerm); |
| 318 |
|
| 319 |
// find the characters which appear after the searchTerm in the transform |
| 320 |
const terminator = prop.transform.substr( |
| 321 |
tokenIndex + searchTerm.length |
| 322 |
); |
| 323 |
|
| 324 |
// get the index of the terminating characters within the existing customized css value |
| 325 |
const terminatorIndex = value.indexOf(terminator); |
| 326 |
|
| 327 |
// pull the value out of the string |
| 328 |
const extractedValue = value.substring( |
| 329 |
tokenIndex, |
| 330 |
terminatorIndex |
| 331 |
); |
| 332 |
|
| 333 |
// console.log( |
| 334 |
// `extracted ${extractedValue} as the value based on the transform` |
| 335 |
// ); |
| 336 |
|
| 337 |
value = extractedValue; |
| 338 |
} |
| 339 |
|
| 340 |
// reformat the incoming `action.payload.value` to the expected format for the ui control based on the settings type `setting.type` |
| 341 |
|
| 342 |
let computedValue; |
| 343 |
|
| 344 |
// console.log(setting); |
| 345 |
|
| 346 |
switch (setting.type) { |
| 347 |
case "colorpicker": { |
| 348 |
// { r: 51, g: 51, b: 51, a: 1 } react-color may already convert this for us, test! |
| 349 |
computedValue = value; |
| 350 |
break; |
| 351 |
} |
| 352 |
|
| 353 |
case "numeric": |
| 354 |
case "slider": { |
| 355 |
// must be a number |
| 356 |
const strippedValue = value.replace("px", ""); |
| 357 |
const convertedValue = Math.ceil(strippedValue); |
| 358 |
|
| 359 |
computedValue = convertedValue; |
| 360 |
break; |
| 361 |
} |
| 362 |
|
| 363 |
default: { |
| 364 |
computedValue = value; |
| 365 |
break; |
| 366 |
} |
| 367 |
} |
| 368 |
|
| 369 |
console.log(`updating value for ${setting.id} to ${computedValue}`); |
| 370 |
|
| 371 |
// update the default `activeTheme`s `setting.value` for `setting.id` to the determined configured `value` |
| 372 |
themesCtx.dispatch({ |
| 373 |
type: `THEME.SETTING.UPDATE_DEFAULT_VALUE`, |
| 374 |
payload: { |
| 375 |
themeName: activeTheme.name, |
| 376 |
settingId: setting.id, |
| 377 |
value: computedValue, |
| 378 |
}, |
| 379 |
}); |
| 380 |
|
| 381 |
dispatch({ |
| 382 |
type: `UPDATE_GALLERY_APPEARANCE`, |
| 383 |
payload: { ...setting, value: computedValue }, |
| 384 |
}); |
| 385 |
} |
| 386 |
} |
| 387 |
}); |
| 388 |
}); |
| 389 |
|
| 390 |
// Remove the style that came from the server since we'll be |
| 391 |
// generating a new one in the client anyway |
| 392 |
customStyle.remove(); |
| 393 |
}, [data, themesCtx]); |
| 394 |
|
| 395 |
return ( |
| 396 |
<GalleryContext.Provider |
| 397 |
value={{ |
| 398 |
isLoading, |
| 399 |
error, |
| 400 |
data, |
| 401 |
state, |
| 402 |
dispatch, |
| 403 |
}} |
| 404 |
> |
| 405 |
<Helmet> |
| 406 |
<script type="text/javascript">{`!function(e,t,n){function a(){var e=t.getElementsByTagName("script")[0],n=t.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://beacon-v2.helpscout.net",e.parentNode.insertBefore(n,e)}if(e.Beacon=n=function(t,n,a){e.Beacon.readyQueue.push({method:t,options:n,data:a})},n.readyQueue=[],"complete"===t.readyState)return a();e.attachEvent?e.attachEvent("onload",a):e.addEventListener("load",a,!1)}(window,document,window.Beacon||function(){});`}</script> |
| 407 |
<script type="text/javascript">{`window.Beacon('init', 'f7cb9d02-1ec7-4320-ad10-fcbc85e4d544')`}</script> |
| 408 |
<style |
| 409 |
type="text/css" |
| 410 |
id={`vimeography-gallery-${props.id}-custom-css-preview`} |
| 411 |
>{` |
| 412 |
${state.appearanceRules.map((rule) => rule.css).join("\r\n\r\n")} |
| 413 |
`}</style> |
| 414 |
</Helmet> |
| 415 |
|
| 416 |
{props.children} |
| 417 |
</GalleryContext.Provider> |
| 418 |
); |
| 419 |
}; |
| 420 |
|
| 421 |
export default GalleryProvider; |
| 422 |
|