Follow this blueprint to build a template for your own picture and headline on a magazine cover. You will add customizable images and text, and then publish the template on Koji.
Familiarity with web development. React and ES6 basics are a plus.
Familiarity with the Koji editor and remix process. For an overview, go through the Koji quick-start tutorial.
Koji: Beginner – Intermediate
Developer: Intermediate – Advanced
Time: 45 minutes
Remix an existing web application on Koji that implements basic elements of your favorite framework.
Install @withkoji/vcc to expose Visual Customization Controls (VCCs), dynamically update custom values, and display your template correctly in the Koji feed.
npm install --save @withkoji/vcc
Note
|
To use this package, you must configure the package.json file to run the watcher (koji-vcc watch ) concurrently with your development server.
|
Create .koji/customization/settings.json
to define customizable values for the magazine name, cover image, background color, and text options (text, size, color, and position).
{
"settings": {
"magazineOptions": {
"magazineName": "https://images.koji-cdn.com/38587b11-db1e-4e64-b099-a63e22f3666c/nfakd-2.png",
"coverImage": "https://images.koji-cdn.com/14c02947-0b36-4b2b-ace3-6d205bb84e3b/tl7ta-download20200602T105158.824.png",
"bgColor": "#f7e8a2"
},
"textOptions": {
"title": "The greatest ever...",
"fontSize": 15,
"color": "#592efb",
"position": {
"x": 2,
"y": 30
}
},
"background": "#be47f9"
},
"@@editor": [
{
"key": "settings",
"name": "App settings",
"icon": "⚙️",
"source": "settings.json",
"fields": [
{
"key": "magazineOptions",
"name": "Magazine options",
"type": "object<MagazineOption>",
"typeOptions": {
"MagazineOption": {
"magazineName": {
"name": "Magazine name",
"type": "select",
"typeOptions": {
"placeholder": "Choose a magazine...",
"options": [
{
"value": "https://images.koji-cdn.com/5eaaf00f-c8bb-46ba-8de2-96c41a44d93a/w44bz-Cosmofix.png",
"label": "Cosmopolitan"
},
{
"value": "https://images.koji-cdn.com/48a8b044-5695-4bf0-8726-6f6d1a09b69e/usir2-BrowserPreview_tmp.png",
"label": "Forbes"
},
{
"value": "https://images.koji-cdn.com/23ac8d20-2619-43ce-80f9-0e085e72dc8d/yznns-01.png",
"label": "AARP"
},
{
"value": "https://images.koji-cdn.com/38587b11-db1e-4e64-b099-a63e22f3666c/nfakd-2.png",
"label": "GQ"
}
]
}
},
"coverImage": {
"name": "Cover image",
"type": "image"
},
"bgColor": {
"name": "Background Color",
"type": "color"
}
}
}
},
{
"key": "textOptions",
"name": "Text options",
"type": "object<TextOption>",
"typeOptions": {
"TextOption": {
"title": {
"name": "Headline",
"description": "Headline for your cover image",
"type": "text"
},
"fontSize": {
"name": "Text size",
"description": "Select a size for the title font",
"type": "range",
"typeOptions": {
"min": 4,
"max": 30,
"step": 1
}
},
"color": {
"name": "Color",
"type": "color"
},
"position": {
"name": "Text position",
"type": "coordinate"
}
}
}
}
]
}
]
}
Remove the unused customization files, if applicable.
colors.json
, images.json
, and strings.json
Import the packages.
import { FeedSdk, InstantRemixing } from '@withkoji/vcc';
Use InstantRemixing
to get and set custom values.
const instantRemixing = new InstantRemixing();
Use FeedSdk
to display the template in the Koji feed.
const feed = new FeedSdk();
Monitor the remix state.
const [isRemixing, setIsRemixing] = useState(instantRemixing.isRemixing);
useEffect(() => {
instantRemixing.onSetRemixing((isNowRemixing) => {
setIsRemixing(isNowRemixing);
});
}, []);
var isRemixing = instantRemixing.isRemixing;
instantRemixing.onSetRemixing(isNowRemixing => {
isRemixing = isNowRemixing;
render();
});
Dynamically get and set custom values.
const [magazineOptions, setMagazineOptions] = useState(instantRemixing.get(['settings', 'magazineOptions']));
const [textOptions, setTextOptions] = useState(instantRemixing.get(['settings', 'textOptions']));
useEffect(() => {
instantRemixing.onValueChanged(([scope = '', key = ''], value) => {
if (scope === 'settings' && key === 'magazineOptions') setMagazineOptions(value);
if (scope === 'settings' && key === 'textOptions') setTextOptions(value);
});
}, []);
var magazineOptions = instantRemixing.get(['settings', 'magazineOptions']);
var textOptions = instantRemixing.get(['settings', 'textOptions']);
// Set values on remix
instantRemixing.onValueChanged(([scope = "", key = ""], value) => {
if (scope === 'settings' && key === 'magazineOptions') magazineOptions = value;
if (scope === "settings" && key === "textOptions") textOptions = value;
render();
});
Optimize the user’s cover image.
const optimizeURL = (url) => `${url}?fit=bounds&width=${window.innerWidth - 15}&height=${window.innerHeight - 15}&optimize=medium`;
Add click handlers for editable elements.
// Click handlers for remixing
const handleClick = (e) => {
if (isRemixing) {
if (e.target.closest('.editable')) {
instantRemixing.onPresentControl(['settings', 'textOptions']);
} else {
instantRemixing.onPresentControl(['settings', 'magazineOptions']);
}
}
};
Use dynamic sizing to support different browsers and devices.
// Handle dynamic resizing
const [size, setSize] = useState({});
// Set a reference for the magazine div (import useRef from React to follow this pattern)
const magazineRef = useRef(null);
// Set dynamic size properties for the cover image
const runSetSize = () => {
setSize({
height: magazineRef.current.height,
width: magazineRef.current.width,
top: magazineRef.current.offsetTop,
left: magazineRef.current.offsetLeft,
});
};
// Re-run the set sizing function when the window is resized
useEffect(() => {
window.addEventListener('resize', runSetSize);
return () => window.removeEventListener('resize', runSetSize);
}, []);
// Handle dynamic resizing
var size = {};
const setSize = () => {
let magazine = document.getElementById('magazine');
if (magazine) {
let oldSize = size;
size = {
height: magazine.height,
width: magazine.width,
top: magazine.offsetTop,
left: magazine.offsetLeft
};
if (oldSize.height !== size.height || oldSize.width !== size.width || oldSize.top !== size.top || oldSize.left !== size.left) {
render();
}
}
}
window.addEventListener("resize", setSize);
Indicate the template is ready for instant remixes and to display in the feed.
useEffect(() => {
// Wrap in the final useEffect so it's sure to run after anything else
instantRemixing.ready();
feed.load();
}, []);
instantRemixing.ready();
feed.load();
Render the template with custom values. Apply conditional styling to editable elements during remix.
<Wrapper
onClick={handleClick}
style={{
bgColor: magazineOptions.bgColor,
}}
>
<Magazine
onLoad={runSetSize}
ref={magazineRef}
src={magazineOptions.magazineName}
/>
{
size.height &&
<MagazineCover
style={{
...size,
backgroundImage: optimizeURL(magazineOptions.coverImage),
}}
>
<H1
className={isRemixing ? 'editable text' : 'text'}
fontSize={size.height ? (textOptions.fontSize / 200) * size.height : 20}
color={textOptions.color}
x={textOptions.position.x}
y={textOptions.position.y}
>
{textOptions.title}
</H1>
</MagazineCover>
}
</Wrapper>
// render app
const render = () => {
let coverStyles = `
width: ${size.width}px;
height: ${size.height}px;
top: ${size.top}px;
left: ${size.left}px;
background-image: url(${optimizeURL(magazineOptions.coverImage)});
`;
let titleStyles = `
font-size: ${size.height ? (textOptions.fontSize / 200) * size.height : 20}px;
color: ${textOptions.color};
top: ${textOptions.position.y}%;
left: ${textOptions.position.x}%;
`;
document.body.innerHTML = `
<div id="wrapper">
<img id="magazine" src="${magazineOptions.magazineName}"/>
<div id="magazineCover" style="${coverStyles}">
<div id="title" class="${isRemixing ? 'editable ': ''}text" style="${titleStyles}">
${textOptions.title}
</div>
</div>
</div>
`;
document.getElementById('wrapper').addEventListener('click', handleClick);
document.getElementById('magazine').onload = setSize;
};
// render
render();
Define styles for the template, including the headline, cover image. Remove unused styles.
const Wrapper = styled.div`
background-color: ${({ style: { bgColor } }) => bgColor};
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
`;
const MagazineCover = styled.div`
width: ${({ style: { width } }) => width}px;
height: ${({ style: { height } }) => height}px;
top: ${({ style: { top } }) => top}px;
left: ${({ style: { left } }) => left}px;
background: url("${({ style: { backgroundImage } }) => backgroundImage}") no-repeat center center / cover;
position: absolute;
z-index: 0;
`;
const Magazine = styled.img`
max-height: 100%;
max-width: 100%;
z-index: 1;
pointer-events: none;
`;
const H1 = styled.div`
position: absolute;
top: ${({ y }) => `${y}%`};
left: ${({ x }) => `${x}%`};
font-family: 'Roboto', sans-serif;
color: ${({ color }) => color};
font-size: ${({ fontSize }) => `${fontSize}px`};
max-width: 35%;
cursor: default;
`;
div#wrapper {
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
div#magazineCover {
background: no-repeat center center / cover;
position: absolute;
z-index: 0;
}
img#magazine {
max-height: 100%;
max-width: 100%;
z-index: 1;
pointer-events: none;
}
div#title {
position: absolute;
font-family: "Roboto", sans-serif;
max-width: 35%;
cursor: default;
}
.editable {
border: 2px dashed grey;
}
Add styles for editable elements during remix.
.editable {
border: 2px dashed grey;
}
Add the font stylesheet.
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
Create .koji/project/entitlements.json
and enable instant remix support and listing in the Koji feed.
{
"entitlements": {
"InstantRemixing": true,
"FeedEvents": true
}
}
Use the tools in the Koji editor to test template functionality. For example:
Template default view – Refresh the Live preview tab.
Conditional styling of editable elements – In the live preview, switch between Preview and Editing mode.
VCC targeting – In Editing mode, click each editable element. The corresponding VCC should open.
Remix functionality – In the JSON file, switch to Visual view, and use VCC editor to customize values. The template should update immediately.
Template styles in another browser tab or device – To open a preview in a new browser tab, click the remote staging button in the preview pane. To open a preview on a mobile device, click the QR code button in the preview pane to display a QR code, then scan the QR code with your mobile device.
Click Publish Now and enter a Name (defines the permalink URL to your template), Description (displays along with your template on Koji), and other publish settings, as desired. Then, click Publish New Version.
When publishing is completed, click the link in the message to view your live template on Koji. Your template is now available to remix and share anywhere on the web. You can create a fun version for yourself and share it on your favorite site to see what your friends make.
To see this blueprint as a completed template on Koji, visit the following link. From there, you can view the source code or remix the template into your own project.
The following code is a completed sample of the template logic described in this blueprint.
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { FeedSdk, InstantRemixing } from '@withkoji/vcc';
const Wrapper = styled.div`
background-color: ${({ style: { bgColor } }) => bgColor};
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
`;
const MagazineCover = styled.div`
width: ${({ style: { width } }) => width}px;
height: ${({ style: { height } }) => height}px;
top: ${({ style: { top } }) => top}px;
left: ${({ style: { left } }) => left}px;
background: url("${({ style: { backgroundImage } }) => backgroundImage}") no-repeat center center / cover;
position: absolute;
z-index: 0;
`;
const Magazine = styled.img`
max-height: 100%;
max-width: 100%;
z-index: 1;
pointer-events: none;
`;
const H1 = styled.div`
position: absolute;
top: ${({ y }) => `${y}%`};
left: ${({ x }) => `${x}%`};
font-family: 'Roboto', sans-serif;
color: ${({ color }) => color};
font-size: ${({ fontSize }) => `${fontSize}px`};
max-width: 35%;
cursor: default;
`;
const optimizeURL = (url) => `${url}?fit=bounds&width=${window.innerWidth - 15}&height=${window.innerHeight - 15}&optimize=medium`;
const instantRemixing = new InstantRemixing();
const feed = new FeedSdk();
const App = () => {
// Handle remixing state
const [isRemixing, setIsRemixing] = useState(instantRemixing.isRemixing);
useEffect(() => {
instantRemixing.onSetRemixing((isNowRemixing) => {
setIsRemixing(isNowRemixing);
});
}, []);
// Handle value updates
const [magazineOptions, setMagazineOptions] = useState(instantRemixing.get(['settings', 'magazineOptions']));
const [textOptions, setTextOptions] = useState(instantRemixing.get(['settings', 'textOptions']));
useEffect(() => {
instantRemixing.onValueChanged(([scope = '', key = ''], value) => {
if (scope === 'settings' && key === 'magazineOptions') setMagazineOptions(value);
if (scope === 'settings' && key === 'textOptions') setTextOptions(value);
});
}, []);
// Click handlers for remixing
// Click handlers for remixing
const handleClick = (e) => {
if (isRemixing) {
if (e.target.closest('.editable')) {
instantRemixing.onPresentControl(['settings', 'textOptions']);
} else {
instantRemixing.onPresentControl(['settings', 'magazineOptions']);
}
}
};
// Handle dynamic resizing
const [size, setSize] = useState({});
// Set a reference for the magazine div (import useRef from React to follow this pattern)
const magazineRef = useRef(null);
// Set dynamic size properties for the cover image
const runSetSize = () => {
setSize({
height: magazineRef.current.height,
width: magazineRef.current.width,
top: magazineRef.current.offsetTop,
left: magazineRef.current.offsetLeft,
});
};
// Re-run the set sizing function when the window is resized
useEffect(() => {
window.addEventListener('resize', runSetSize);
return () => window.removeEventListener('resize', runSetSize);
}, []);
useEffect(() => {
// Wrap in the final useEffect so it's sure to run after anything else
instantRemixing.ready();
feed.load();
}, []);
return (
<Wrapper
onClick={handleClick}
style={{
bgColor: magazineOptions.bgColor,
}}
>
<Magazine
onLoad={runSetSize}
ref={magazineRef}
src={magazineOptions.magazineName}
/>
{
size.height &&
<MagazineCover
style={{
...size,
backgroundImage: optimizeURL(magazineOptions.coverImage),
}}
>
<H1
className={isRemixing ? 'editable text' : 'text'}
fontSize={size.height ? (textOptions.fontSize / 200) * size.height : 20}
color={textOptions.color}
x={textOptions.position.x}
y={textOptions.position.y}
>
{textOptions.title}
</H1>
</MagazineCover>
}
</Wrapper>
);
};
export default App;
import { FeedSdk, InstantRemixing } from "@withkoji/vcc";
import './styles.css';
const instantRemixing = new InstantRemixing();
const feed = new FeedSdk();
var isRemixing = instantRemixing.isRemixing;
instantRemixing.onSetRemixing(isNowRemixing => {
isRemixing = isNowRemixing;
render();
});
var magazineOptions = instantRemixing.get(['settings', 'magazineOptions']);
var textOptions = instantRemixing.get(['settings', 'textOptions']);
// Set values on remix
instantRemixing.onValueChanged(([scope = "", key = ""], value) => {
if (scope === 'settings' && key === 'magazineOptions') magazineOptions = value;
if (scope === "settings" && key === "textOptions") textOptions = value;
render();
});
// Optimize image urls using fastly params
const optimizeURL = url =>
`${url}?fit=bounds&width=${window.innerWidth - 15}&height=${window.innerHeight - 15}&optimize=medium`;
const handleClick = e => {
if (e.target.closest(".text")) {
instantRemixing.onPresentControl(["settings", "textOptions"]);
} else {
instantRemixing.onPresentControl(['settings', 'magazineOptions']);
}
};
// Handle dynamic resizing
var size = {};
const setSize = () => {
let magazine = document.getElementById('magazine');
if (magazine) {
let oldSize = size;
size = {
height: magazine.height,
width: magazine.width,
top: magazine.offsetTop,
left: magazine.offsetLeft
};
if (oldSize.height !== size.height || oldSize.width !== size.width || oldSize.top !== size.top || oldSize.left !== size.left) {
render();
}
}
}
window.addEventListener("resize", setSize);
instantRemixing.ready();
feed.load();
// render app
const render = () => {
let coverStyles = `
width: ${size.width}px;
height: ${size.height}px;
top: ${size.top}px;
left: ${size.left}px;
background-image: url(${optimizeURL(magazineOptions.coverImage)});
`;
let titleStyles = `
font-size: ${size.height ? (textOptions.fontSize / 200) * size.height : 20}px;
color: ${textOptions.color};
top: ${textOptions.position.y}%;
left: ${textOptions.position.x}%;
`;
document.body.innerHTML = `
<div id="wrapper">
<img id="magazine" src="${magazineOptions.magazineName}"/>
<div id="magazineCover" style="${coverStyles}">
<div id="title" class="${isRemixing ? 'editable ': ''}text" style="${titleStyles}">
${textOptions.title}
</div>
</div>
</div>
`;
document.getElementById('wrapper').addEventListener('click', handleClick);
document.getElementById('magazine').onload = setSize;
};
// render
render();
/**
* styles.css
*
* What it Does:
* This is your global CSS file, this file will style every page and
* component in your application.
*
* Things to Change:
* If you want any CSS properties to persist through all of your app
* or if any element you import has to be styled globally, this is the
* file to use.
*/
* {
margin: 0;
padding: 0;
}
h1 {
margin: 16px;
color: white;
}
body {
background-color: black;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}
img {
max-width: 100%;
}
div#wrapper {
height: 100vh;
width: 100vw;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
div#magazineCover {
background: no-repeat center center / cover;
position: absolute;
z-index: 0;
}
img#magazine {
max-height: 100%;
max-width: 100%;
z-index: 1;
pointer-events: none;
}
div#title {
position: absolute;
font-family: "Roboto", sans-serif;
max-width: 35%;
cursor: default;
}
.editable {
border: 2px dashed grey;
}