Last active
November 27, 2021 18:58
-
-
Save traderd65/8bec2bff6d2b949620360d24e6ca5e71 to your computer and use it in GitHub Desktop.
Cloudinary Style Transfer Contest App
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Follows the CodePen setup: | |
13. If not using the “faas” account (recommended): | |
a. Add style-transfer add-on | |
b. Enable unsigned uploads | |
c. Create the upload preset ‘styletransfer” with tag “styletransfer” | |
“use filename” = false | |
disallow public_id = true | |
allowed formats = “jpg, gif, png, jpeg, webp” | |
type = “upload” | |
access mode = “public” | |
format = “jpg” | |
incoming transformation = “c_fill,g_center,h_800,w_800” | |
14. Update the cloud name in the webtask to the new cloud name, and references to “faas” in the code: | |
const cloudinary_cloudname = 'faas'; | |
15. Add the four starter images from the faas account to kick off the CodePen: | |
https://res.cloudinary.com/faas/image/upload/v1503415275/coffee_cup.jpg | |
https://res.cloudinary.com/faas/image/upload/v1503415299/sailing_angel.jpg | |
https://res.cloudinary.com/faas/image/upload/v1503415323/coffee_cup_st.jpg | |
http://res.cloudinary.com/faas/image/upload/v1503522957/style-transfer-loading.svg | |
16. Update the three CodePen image URLs to the new cloud account images in the HTML panel to new values as needed: | |
https://res.cloudinary.com/faas/image/upload/w_200,h_200,c_fill,dpr_2.0/sailing_angel.jpg ... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Follows the Webtask setup: | |
9. Fork the CodePen: https://codepen.io/cloudinary/pen/LjqJEG | |
10. Scroll to the bottom of the CodePen JS panel. Update the “webtask_url” variable to the webtask created above. | |
You’ll likely have to change only the container value if you haven’t renamed the webtask (i.e. style-transfer) | |
const webtask_url = "https://faas-cloudinary.com/wt-...-0/style-transfer"; | |
11. Update the webtask to point to the new CodePen pen (shows the original URL here for illustration): | |
const url_codepen = 'https://codepen.io/cloudinary/pen/LjqJEG'; | |
12. Update the webtask to point to the new CodePen CSS: | |
In the new CodePen, select the “Change View” button in the upper right of the page. | |
Select “.css” link in the “Direct Code Links” section in the dropdown that appears. | |
Copy the resulting URL, and update the url_stylecss value with this new URL value: | |
const url_stylecss = 'https://codepen.io/cloudinary/pen/LjqJEG.css'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
After setting up Webtask, CodePen and Cloudinary: | |
------------------------------------------------- | |
* Launch the new CodePen pen | |
* Upload two images, generate a style image and submit it to the contest. | |
* Click through the view and the vote URLs. Confirm both open correctly. | |
* Check the webtask storage Confirm your entry shows in the entries list. | |
* Vote on an entry in the vote page. Check the webtask storage and refresh it. Confirm your vote shows at the bottom. | |
* Download the data dump. Download the marketing CSV. Are the secrets set correctly? | |
In case of a need to reset data: | |
-------------------------------- | |
* Download the data dump and edit as needed. | |
* Open the webtask. Open the Storage panel. | |
* Hit the Refresh link at top of the panel. | |
* Paste the updated data into the text box. | |
* Save the new data with the Update button. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
The content has three moving parts: | |
----------------------------------- | |
* CodePen pen (https://codepen.io/cloudinary/pen/LjqJEG) | |
* webtask.io webtask (https://{custom-domain}/{webtask-container}/style-transfer) | |
* Cloudinary account | |
Webtask code: | |
------------- | |
https://webtask.it.auth0.com/edit/{container-name}#webtaskName=style-transfer&token={login-token} | |
Note that the login token provides full access to all webtasks of the account! | |
Cloudinary account: | |
------------------- | |
Cloud name: faas (set cloud admin flag <Unsigned Add-Ons> in console to enable style transfer without signed URLs) | |
Account Email: cloudinary@mackenzieking.co (grace account with higher limit of allowed style transfer operations) | |
Application pages: | |
------------------ | |
* Stylized image generation and submission page | |
* Submission listing and voting page | |
The styled image URL generation and submission page is hosted on CodePen. The CodePen pen has the front end HTML/CSS/JS, which displays the source, target, and styled images, and calls the Cloudinary image uploader. From this page, once images have been uploaded and styled, the pen visitor can submit the image to the webtask.io webtask backend, which returns a voting URL. | |
The webtask.io webtask has the backend ExpressJS code, which generates and displays the submission listing page, displaying all the stylized image entries and their source/target images. If a URL going to the webtask includes a valid image reference token, the referenced image will display first on the page. If the URL includes a valid voting token, voting buttons will display on each of the entries that are not the user’s own entry. The webtask is an ExpressJS app and uses the CodePen CSS. | |
URL format: https://faas-cloudinary.com/{webtask-container}/style-transfer/vote/{view-token}?vote={vote-token} | |
Custom domain https://faas-cloudinary.com - registered on namecheap, hosted on cloudflare, points to auth0.com webtask.io servers (refer to Webtask documentation for setting up DNS records and pointing to CloudFlare nameservers for the domain) | |
webtask-container - the container from the webtask.io profile (created via “wt init”, found with “wt ls --profile {user-profile}” | |
style-transfer - the webtask taskname, configurable |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
'use latest'; | |
// this is the official webtask end point | |
var namor = require('namor'); | |
var request_send = require('request'); | |
// Confs | |
const cloudinary_cloudname = 'faas'; | |
const webtask_name = 'style-transfer'; | |
const webtask_container = 'wt-60a287cd40c53f6e56bd60ac8922bc3e-0'; | |
const start_date = new Date('2017-09-23T00:00:00').getTime(); | |
const end_date = new Date('2017-09-30T00:00:00').getTime(); | |
// URLs | |
const url_cloudinarylogo = 'https://cloudinary-res.cloudinary.com/image/asset/dpr_2.0/logo-e0df892053afd966cc0bfe047ba93ca4.png'; | |
const url_codepen = 'https://codepen.io/cloudinary/full/LjqJEG'; | |
const url_stylecss = url_codepen.replace(/full/, 'pen') + '.css'; | |
const url_webtask_base = `https://faas-cloudinary.com/${webtask_container}/${webtask_name}`; | |
const url_votelink = `${url_webtask_base}/vote`; | |
/* | |
*** STORAGE FORMAT *** | |
{ | |
"entries" : { | |
"voting-token" { | |
"ip", // ??? unique user ID or just for logging ??? | |
"date", // submission date | |
"url", // submitted image url | |
"email", // email address | |
"marketing",// accepts marketing | |
"vote" // vote for best | |
} | |
}, | |
// Denormalize the storage to make searching easier. | |
// Can be removed if storage space becomes an issue. (??? why double fields ???) | |
"votes" : { | |
"view-token" : "view-token-vote", | |
"view-token" : "view-token-vote" | |
}, | |
"images": { | |
"url":"view-token", | |
"url":"view-token" | |
}, | |
"tallies": { | |
"view-token": vote_count, | |
"view-token": vote_count | |
} | |
} | |
*/ | |
import express from 'express'; | |
import { fromExpress } from 'webtask-tools'; | |
import bodyParser from 'body-parser'; | |
const app = express(); | |
app.use(bodyParser.json()); | |
// top level should go to entries page | |
app.get('/', (req, res) => { | |
res.redirect(url_webtask_base + '/view/no-token'); | |
}); | |
// Quick start date and end date display | |
app.get('/date/start', (req, res) => { | |
res.set('Content-Type', 'text/html'); res.status(200).send(displayDate(start_date)); | |
}); | |
app.get('/date/end', (req, res) => { | |
res.set('Content-Type', 'text/html'); res.status(200).send(displayDate(end_date)); | |
}); | |
// view entries, even if you can't vote | |
app.get(['/vote', '/view'], (req, res) => { | |
res.redirect(url_webtask_base + '/view/no-token'); | |
}); | |
// vote listing page of url format .../vote/votetoken | |
app.get(['/view/:tokenid', '/vote/:tokenid'], (req, res) => { | |
var image_list = ''; | |
var top_message = ''; | |
var voting_allowed = false; | |
var viewtoken = req.params.tokenid; | |
var votetoken = req.query.vote ? req.query.vote : ''; | |
if (Date.now() < start_date) { | |
top_message = '<div class="error-message">Voting has not opened yet.</div>'; | |
} | |
if (Date.now() > end_date) { | |
top_message = '<div class="error-message">Voting has ended.</div>'; | |
} | |
var view_order = req.webtaskContext.query.view ? req.webtaskContext.query.view : 'random'; | |
view_order = (['random', 'recent', 'reverse', 'leader'].indexOf(view_order) > -1) ? view_order : 'random'; | |
req.webtaskContext.storage.get(function(error, data) { | |
if (error) { | |
image_list = 'Unable to find image list'; | |
} else { | |
image_list = renderVoteList(data, viewtoken, votetoken, view_order); | |
} | |
image_list += renderVoteFlash({}); | |
if (data.entries[viewtoken] && (data.entries[viewtoken]['vote-token'] === votetoken) && (Date.now() > start_date) && (Date.now() < end_date)) { | |
voting_allowed = true; | |
} | |
var voting_script = (voting_allowed) ? renderVotingScript({ viewtoken: viewtoken, votetoken: votetoken }) : ''; | |
// TODO: integrate this into rendering functions below | |
var voting_header = '<div id="vote-header"><h4><a href="?view=random&vote=' + votetoken + '" id="vote-random" class="oh-yes vote-header ' + ((view_order === 'random') ? 'selected' : '') + '" >Random</a></h4><h4><a href="?view=recent&vote=' + votetoken + '" id="vote-recent" class="oh-yes vote-header ' + ((view_order === 'recent') ? 'selected' : '') + '">Recent</a></h4><h4><a href="?view=reverse&vote=' + votetoken + '" id="vote-reverse" class="oh-yes vote-header ' + ((view_order === 'reverse') ? 'selected' : '') + '">Oldest</a></h4><h4><a href="?view=leader&vote=' + votetoken + '" id="vote-leader" class="oh-yes vote-header ' + ((view_order === 'leader') ? 'selected' : '') + '">Leaderboard</a></h4></div>'; | |
const HTML = renderView({ | |
title: 'Cloudinary Style Transfer Submissions', | |
top_message: top_message, | |
body: voting_header + '<div id="image-vote-list">' + image_list + '</div>', | |
scripts: voting_script | |
}); | |
res.set('Content-Type', 'text/html'); | |
res.status(200).send(HTML); | |
}); | |
}); | |
// accept an incoming vote POST | |
app.post('/vote', (req, res) => { | |
function cb(ret) { | |
if (!ret.error) { ret.error = ''; } | |
res.json(ret); | |
} | |
let viewtoken = (req.body.view) ? req.body.view.trim() : ""; | |
var votetoken = (req.body.token) ? req.body.token.trim() : ""; | |
var voteurl = (req.body.vote) ? req.body.vote.trim() : ""; | |
if (viewtoken === "") { | |
return cb({error: "Token missing, unable to vote"}); | |
} | |
if (votetoken === "") { | |
return cb({error: "Token missing, unable to vote."}) | |
} | |
if (voteurl === "") { | |
return cb({error: "No vote cast. What's up?"}) | |
} | |
if (Date.now() > end_date) { | |
return cb({error: 'Voting has ended.'}); | |
} | |
if (Date.now() < start_date) { | |
return cb({error: 'Ooops. Voting has not started.'}) | |
} | |
req.webtaskContext.storage.get(function (error, data) { | |
if (error) return cb(error); | |
data = data || { entries : {}, images : {}, votes: {}, tallies: {} }; | |
// see if valid token | |
if (!data.entries[viewtoken]) { | |
return cb({"error" : "Ooops! Can't confirm voting eligibility!"}); | |
} | |
if (data.entries[viewtoken]['vote-token'] !== votetoken) { | |
return cb({"error" : "Ooops! Can't confirm voting eligibility!!"}); | |
} | |
// see if valid vote | |
if (!data.images[voteurl]) { | |
return cb({"error" : "Ooops! Can't find vote image!"}); | |
} | |
// remove previous vote in tallies if exists | |
if (data.entries[viewtoken].vote) { | |
data.tallies[data.entries[viewtoken].vote] -= 1; | |
} | |
// cast the vote in the entries (this is canonical) | |
data.entries[viewtoken].vote = data.images[voteurl]; | |
// list of votes to make tallying easier | |
data.votes[viewtoken] = data.images[voteurl]; | |
// update vote counts, these values are transient, helper values | |
data.tallies[data.images[voteurl]] = data.tallies[data.images[voteurl]] ? data.tallies[data.images[voteurl]] + 1 : 1; | |
var attempts = 3; | |
req.webtaskContext.storage.set(data, function set_cb(error) { | |
if (error) { | |
if (error.code === 409 && attempts--) { | |
data.entries[viewtoken].vote = data.images[voteurl]; | |
data.votes[viewtoken] = data.images[voteurl]; | |
data.tallies[data.images[voteurl]] = data.tallies[data.images[voteurl]] ? data.tallies[data.images[voteurl]] + 1 : 1; | |
return req.webtaskContext.storage.set(data, set_cb); | |
} | |
return cb(error); | |
} | |
}); | |
var message = 'Success! Thanks for your vote! You can change your vote by voting again.'; | |
cb({ message: message }); | |
}); | |
}); | |
// admin: get a list of emails and marketing opt-in | |
app.get('/admin/emails/:email_access', (req, res) => { | |
if (req.webtaskContext.secrets.ADMIN_EMAIL_DOWNLOAD && (req.params.email_access === req.webtaskContext.secrets.ADMIN_EMAIL_DOWNLOAD)) { | |
req.webtaskContext.storage.get(function (error, data) { | |
if (error) return res.json({ error: 'Unable to load storage.'}); | |
var csv_string = "Email,Marketing Okay,Image URL\n"; | |
Object.keys(data.entries).forEach(function(key) { | |
csv_string += data.entries[key].email + ','; | |
csv_string += data.entries[key].mkt + ','; | |
csv_string += '"' + data.entries[key].url + "\"\n"; | |
}); | |
res.set('Content-Type', 'text/csv'); | |
res.status(200).send(csv_string); | |
}); | |
} else { | |
res.json({error: 'No access'}); | |
} | |
}); | |
// Straight dump of the data | |
app.get('/admin/data/:data_dump', (req, res) => { | |
if (req.webtaskContext.secrets.ADMIN_DATA_DUMP && (req.params.data_dump === req.webtaskContext.secrets.ADMIN_DATA_DUMP)) { | |
req.webtaskContext.storage.get(function (error, data) { | |
if (error) return res.json({ error: 'Unable to load storage'}); | |
res.json(data); | |
}); | |
} else { | |
res.json({error: 'No access'}); | |
} | |
}); | |
// Set (or reset) the tally data for the leaderboard | |
app.get('/admin/generate_tallies/:tally_token', (req, res) => { | |
if (req.webtaskContext.secrets.ADMIN_GENERATE_TALLY && (req.params.tally_token === req.webtaskContext.secrets.ADMIN_GENERATE_TALLY)) { | |
req.webtaskContext.storage.get(function (error, data) { | |
if (error) return res.json({ error: 'Unable to load storage'}); | |
var tally = {}; | |
Object.keys(data.entries).forEach(function(key) { | |
if (data.entries[key].vote) { | |
tally[data.entries[key].vote] = tally[data.entries[key].vote] ? (tally[data.entries[key].vote] + 1) : 1; | |
} | |
}); | |
data.tallies = tally; | |
var attempts = 3; | |
req.webtaskContext.storage.set(data, function set_cb(error) { | |
if (error) { | |
if (error.code === 409 && attempts--) { | |
// potentially misses tallying a vote that happened during tally counting | |
data.tallies = tally; | |
return req.webtaskContext.storage.set(data, set_cb); | |
} | |
} | |
res.json({error: '', message: 'Tallies updated.'}); | |
}); | |
}); | |
} else { | |
res.json({error: 'No access'}); | |
} | |
}); | |
// view entries, even if you can't vote | |
app.get('/leaderboard', (req, res) => { | |
res.redirect(url_webtask_base + '/leaderboard/no-token'); | |
}); | |
// vote listing page of url format .../vote/votetoken | |
app.get('/leaderboard/*', (req, res) => { | |
var image_list = ''; | |
var voting_allowed = false; | |
req.webtaskContext.storage.get(function(error, data) { | |
if (error) { | |
image_list = 'Unable to find image list'; | |
} else { | |
if (!data.tallies) { data.tallies = {}; } | |
var tallied = Object.keys(data.tallies).sort(function(a, b) { | |
return data.tallies[b] - data.tallies[a]; | |
}); | |
var image_list = ''; | |
tallied.every(function(key, index) { | |
image_list += renderImageViewAndVotes({ listid: index, src: data.entries[key].url, tally: data.tallies[key] }); | |
// display the highest 12 | |
return index < 12; | |
}); | |
} | |
const HTML = renderView({ | |
title: 'Cloudinary Style Transfer - Contest Submission Leaderboard', | |
top_message: '', | |
body: '<h4 class="oh-yes">Cloudinary Style Transfer - Contest Submission Leaderboard</h4><div id="leaderboard">' + image_list + '</div>', | |
scripts: '' | |
}); | |
res.set('Content-Type', 'text/html'); | |
res.status(200).send(HTML); | |
}); | |
}); | |
// submit an image to the contest | |
app.post('/submit', (req, res) => { | |
var url = (req.body.url) ? req.body.url.trim() : ""; | |
var email = (req.body.email) ? req.body.email.trim() : ""; | |
function cb(ret) { | |
if (!ret.error) { ret.error = ''; } | |
res.json(ret); | |
} | |
if (email === "") { | |
return cb({error: 'Email address is required.'}) | |
} | |
if (!validEmail(email)) { | |
return cb({error: 'Email does not appear valid.'}); | |
} | |
if (url === '') { | |
return cb({error: "Cloudinary image url wasn't submitted. What's up?"}) | |
} | |
// a valid cloudinary image URL is required | |
if (!validCloudinaryURL(url)) { | |
return cb({error: 'Cloudinary image url does not appear valid.'}); | |
} | |
var viewtoken = namor.generate({ words: 3, numbers: 0}); | |
var votingtoken = generateVotingToken(); | |
var entry = { | |
"date" : new Date().getTime(), | |
"ip": req.webtaskContext.headers['x-forwarded-for'], | |
"url" : url, | |
"email" : email, | |
"mkt" : req.body.marketing === "1", | |
"vote-token": votingtoken | |
} | |
req.webtaskContext.storage.get(function (error, data) { | |
if (error) return cb(error); | |
data = data || { entries: {}, images: {}, votes: {}, tallies: {} }; | |
// confirm hasn't already submitted a vote | |
if (data.images[url]) { | |
return cb({"error" : "Ooops! Image already submitted!"}); | |
} | |
data.entries[viewtoken] = entry; | |
data.images[url] = viewtoken; | |
data.tallies[viewtoken] = 0; | |
var attempts = 3; | |
req.webtaskContext.storage.set(data, function set_cb(error) { | |
if (error) { | |
if (error.code === 409 && attempts--) { | |
data.entries[viewtoken] = entry; | |
data.images[url] = viewtoken; | |
data.tallies[viewtoken] = 0; | |
return req.webtaskContext.storage.set(data, set_cb); | |
} | |
return cb(error); | |
} | |
}); | |
var viewlink = `${url_webtask_base}/view/${viewtoken}`; | |
var votinglink = `${url_webtask_base}/vote/${viewtoken}?vote=${votingtoken}`; | |
var message = renderSubmitMessage({ viewlink: viewlink, votinglink: votinglink, start_date: displayDate(start_date) }); | |
sendEmailNotification(req, { viewlink: viewlink, votinglink: votinglink, start_date: displayDate(start_date), end_date: displayDate(end_date), submission_email: email }); | |
cb({ vote: votinglink, message: message }); | |
}); | |
}); | |
// Handle 404s somewhat gracefully | |
app.use(function (req, res, next) { | |
const HTML = renderView({ | |
title: 'Cloudinary Style Transfer', | |
top_message: '', | |
body: "<p>Sorry, can't find that page.</p><a href='" + url_codepen + "' target='_blank'>View the Cloudinary Style Transfer Contest CodePen</a>", | |
scripts: '' | |
}); | |
res.status(404).send(HTML); | |
}) | |
module.exports = fromExpress(app); | |
/* | |
* | |
* Helper and rendering functions | |
* | |
*/ | |
// voting success submit message, rendered HTML returned in JSON | |
function renderSubmitMessage(locals) { | |
return `<p>Right on! Yay for submitting!</p> | |
<p><a class="voting-submitted-link" href="${locals.viewlink}" target="_blank">View all the entries submitted, yours first!</a><p> | |
<p>Starting on ${locals.start_date}, you'll be able to vote on your favorite Neural Artwork Style Transfer image at the following URL (this is your vote, don't share the link!):</p> | |
<p><a target="_blank" href="${locals.votinglink}">${locals.votinglink}</a></p> | |
<p>We'll send you an email reminding you when voting opens.</p> | |
<p>Good luck!</p> | |
`; | |
} | |
// TODO: in an advanced setup, use HTML version, too | |
function renderEmailMessage(locals) { | |
return `Thanks for experimenting with the Cloudinary Neural Artwork Style Transfer feature! | |
You can view all entries here: | |
${locals.viewlink} | |
Starting on ${locals.start_date}, you'll be able to vote on your favorite Neural Artwork | |
Style Transfer image at the following URL (this is your vote, don't share the link!): | |
${locals.votinglink} | |
Voting ends on ${locals.end_date}. | |
Good luck! | |
`; | |
} | |
// Generic page template | |
function renderView(locals) { | |
locals.scripts = locals.scripts ? locals.scripts : ''; | |
return ` | |
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset="utf-8"> | |
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" /> | |
<title>${locals.title}</title> | |
<link rel="stylesheet prefetch" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css" /> | |
<link rel="stylesheet prefetch" href="https://fonts.googleapis.com/css?family=Roboto:500,400italic,300,700,500italic,400" /> | |
<link rel="stylesheet prefetch" href="https://fonts.googleapis.com/css?family=Bowlby+One+SC" /> | |
<link rel="stylesheet prefetch" href="https://cloudinary.com/stylesheets/g/cloudinary_public.css?1500989656" /> | |
<link rel="stylesheet" href="${url_stylecss}" /> | |
<script type='text/javascript' src='//platform-api.sharethis.com/js/sharethis.js#property=599f09a489ce4100138ae2ba&product=inline-share-buttons' async='async'></script> | |
</head> | |
<body> | |
<h3 class="presents"><a href="http://cloudinary.com/"> | |
<img alt="Cloudinary Logo" class="dark-logo" height="38" src="${url_cloudinarylogo}" /></a><span class="presents-text">Presents</span></h3> | |
<h1 class="oh-yes">Style Transfer</h1> | |
<hr class="separator" /> | |
<div id="vote-message" class="vote-message start-none"><hr class="separator" /></div> | |
${locals.top_message} | |
${locals.body} | |
<script src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js'></script> | |
${locals.scripts} | |
</body> | |
</html> | |
`; | |
} | |
// render the HTML for a voting list | |
function renderVoteList(data, viewtoken, votetoken, display_order) { | |
var image_list = ''; | |
var voting_allowed = false; | |
// randomize array element order, using Durstenfeld shuffle algorithm. | |
// @see https://en.wikipedia.org/wiki/Fisher-Yates_shuffle#The_modern_algorithm | |
function shuffleArray(array) { | |
for (var i = array.length - 1; i > 0; i--) { | |
var j = Math.floor(Math.random() * (i + 1)); | |
var temp = array[i]; | |
array[i] = array[j]; | |
array[j] = temp; | |
} | |
return array; | |
} | |
if (data.entries[viewtoken] && (data.entries[viewtoken]['vote-token'] === votetoken) && (Date.now() > start_date) && (Date.now() < end_date)) { | |
voting_allowed = true; | |
var own_image = data.entries[viewtoken].url; | |
var own_vote = data.entries[viewtoken].vote ? data.entries[data.entries[viewtoken].vote].url : ''; | |
// TODO: rethink the leaderboard, too many exceptions made for its rendering here. | |
if (display_order != 'leader') { | |
// display the user's own submission first | |
image_list += renderOwnSubmissionView({ src: own_image }); | |
// display the vote next, hide it for later display if haven't voted yet | |
let vote_class = (own_vote === '') ? 'start-none' : ''; | |
image_list += renderOwnVoteView({ src: own_vote, canvote: voting_allowed, classes: vote_class}); | |
} | |
} | |
// get a list of all the images | |
var list_of_images = Array(); | |
if (display_order === 'leader') { | |
voting_allowed = false; | |
if (!data.tallies) { data.tallies = {}; } | |
var tallied = Object.keys(data.tallies).sort(function(a, b) { | |
return data.tallies[b] - data.tallies[a]; | |
}); | |
tallied.every(function(key, index) { | |
list_of_images.push(data.entries[key].url); | |
return true; | |
}); | |
} else { | |
Object.keys(data.images).every(function(key, index) { | |
if (key !== own_image) { | |
list_of_images.push(key); | |
} | |
return true; | |
}); | |
} | |
// if normal vote order, reorder the list | |
if (display_order === 'random') { | |
shuffleArray(list_of_images); | |
} else if (display_order === 'recent') { | |
// of note, recent and reverse are flipped here | |
list_of_images.reverse(); | |
} | |
list_of_images.every(function(key, index) { | |
image_list += renderImageView({ listid: index, src: key, canvote: voting_allowed }); | |
return true; | |
}); | |
return image_list; | |
} | |
// General image display view, optionally includes voting markup | |
function renderImageView(locals) { | |
let sty = locals.src.match(/.*\/e_style_transfer\,l_(.*)\/(.*)\.(jpe?g|png|gif|webp|svg)$/i); | |
return `<div class="third-ish candidate"><img id="candidate-${locals.listid}" src="${locals.src}" /><span class="component-images"><img id="candidate-src-${locals.listid}" src="https://res.cloudinary.com/faas/image/upload/w_200,h_200/${sty[1]}" class="component component-left" /><img id="candidate-tgt-${locals.listid}" src="https://res.cloudinary.com/faas/image/upload/w_200,h_200/${sty[2]}" class="component component-right" /></span>` + ((locals.canvote) ? `<div class="third-ish-gogogo"><div class="outer-border"><button class="candidate-vote" data-listid="${locals.listid}" style="text-align: center;">Vote for This Image</button></div></div></div>` : '</div>'); | |
} | |
// Leaderboard image view with vote tally | |
function renderImageViewAndVotes(locals) { | |
return `<div class="third-ish candidate"><img id="candidate-${locals.listid}" src="${locals.src}" /><div class="vote-tally numero-pill">${locals.tally}</div>`; | |
} | |
// Image view of one's own submission | |
function renderOwnSubmissionView(locals) { | |
let sty = locals.src.match(/.*\/e_style_transfer\,l_(.*)\/(.*)\.(jpe?g|png|gif|webp|svg)$/i); | |
return `<div class="third-ish candidate"><img class="sourced" src="${locals.src}" /><span class="component-images"><img id="candidate-src-${locals.listid}" src="https://res.cloudinary.com/faas/image/upload/w_200,h_200/${sty[1]}" class="sourced component component-left" /><img id="candidate-tgt-${locals.listid}" src="https://res.cloudinary.com/faas/image/upload/w_200,h_200/${sty[2]}" class="sourced component component-right" /></span><br /><div class="third-ish-gogogo"><div class="vote-details">Your Submission</div></div></div>`; | |
} | |
// Image view of one's own vote | |
function renderOwnVoteView(locals) { | |
let sty = (locals.src) ? locals.src.match(/.*\/e_style_transfer\,l_(.*)\/(.*)\.(jpe?g|png|gif|webp|svg)$/i) : ['', '', '']; | |
return `<div id="candidate-voted-container" class="third-ish candidate ${locals.classes}"><img id="candidate-voted" class="sourced" src="${locals.src}" /><span class="component-images"><img id="candidate-voted-source" src="https://res.cloudinary.com/faas/image/upload/w_200,h_200/${sty[1]}" class="sourced component component-left" /><img id="candidate-voted-target" src="https://res.cloudinary.com/faas/image/upload/w_200,h_200/${sty[2]}" class="sourced component component-right" /></span><br /><div class="third-ish-gogogo"><div class="vote-details">Your Current Vote</div></div></div>`; | |
} | |
// HTML for the bottom of the screen flash message | |
function renderVoteFlash(locals) { | |
return `<div id="vote-flash" class="vote-flash start-none">You Voted!</div>`; | |
} | |
// jquery code for voting | |
// TODO: consider moving this out into a codepen pen for better viewing | |
function renderVotingScript(locals) { | |
if (!locals.viewtoken) return ``; | |
if (!locals.votetoken) return ``; | |
return ` | |
<script> | |
$(document).ready(function() { | |
$(".candidate-vote").on("click", function() { | |
var vote = $('#candidate-' + $(this).data('listid')).attr('src'); | |
var decom = vote.match(/(.*)\\/e_style_transfer\\,l_(.*)\\/(.*)\\.(jpe?g|png|gif|webp|svg)$/i) | |
var view = $('#viewing-token').data('value'); | |
var token = $('#voting-token').data('value'); | |
$.ajax({ | |
type: "POST", | |
url: "${url_votelink}", | |
data: JSON.stringify({ "view": view, "vote": vote, "token": token }), | |
dataType: "json", | |
contentType: 'application/json; charset=utf-8', | |
success: function(data) { | |
if (data.error && data.error !== "") { | |
$('#vote-message').html(data.error).removeClass('start-none'); | |
} else { | |
$('#candidate-voted').attr('src', vote); | |
$('#candidate-voted-source').attr('src', decom[1] + '/' + decom[2] ); | |
$('#candidate-voted-target').attr('src', decom[1] + '/' + decom[3] ); | |
$('#candidate-voted-container').removeClass('start-none'); | |
$('#vote-message').html(data.message).removeClass('start-none'); | |
$('#vote-flash').removeClass('start-none').addClass('vote-flash-is-showing').delay(5000).queue(function(next) { $(this).removeClass('vote-flash-is-showing').addClass('start-none'); next(); }); | |
} | |
}, | |
failure: function(errMsg) { | |
$('#contest-dialog-error').html(errMsg).removeClass('start-none'); | |
} | |
}); | |
}); | |
}); | |
</script> | |
<div id="viewing-token" data-value="${locals.viewtoken}" /> | |
<div id="voting-token" data-value="${locals.votetoken}" /> | |
`; | |
} | |
// TODO: In a bigger project, move these into a utils.js module | |
// https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript | |
function generateVotingToken() { | |
var d = new Date().getTime(); | |
if (typeof performance !== 'undefined' && typeof performance.now === 'function'){ | |
d += performance.now(); //use high-precision timer if available | |
} | |
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
var r = (d + Math.random() * 16) % 16 | 0; | |
d = Math.floor(d / 16); | |
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); | |
}); | |
} | |
function validEmail(email) { | |
var emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i; | |
// filter out foo@example.com emails, too | |
return (email.match(emailRegex) && !email.match(/example\.[a-zA-Z]{2,}/)); | |
} | |
// TODO: are c_fill,h_XXX,w_XXX allowed here? | |
// TODO: try \/?([gchewl]_[a-z0-9_]+)\,? | |
// TODO: consider validating resource URL @see https://support.cloudinary.com/hc/en-us/articles/115000756771-How-to-check-if-an-image-exists-on-my-account- | |
function validCloudinaryURL(url) { | |
var urlRegex = /^https:\/\/res\.cloudinary\.com\/faas\/image\/upload\/e_style_transfer\,l_[a-z0-9_\-]+(\/styletransfer)?\/[a-z0-9]+\.(jpe?g|png|gif|webp|svg)$/i; | |
return url.match(urlRegex); | |
} | |
function displayDate(timestamp_to_format) { | |
var date = new Date(timestamp_to_format); | |
var monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; | |
return date.getDate() + ' ' + monthNames[date.getMonth()] + ' ' + (date.getYear() + 1900); | |
} | |
function sendEmailNotification(req, locals) { | |
var send_url = ''; | |
var send_api_key = ''; | |
if (req.webtaskContext.secrets.MANDRILL_API_KEY) { | |
send_url = 'https://mandrillapp.com/api/1.0/messages/send.json'; | |
send_api_key = req.webtaskContext.secrets.MANDRILL_API_KEY; | |
var body = { | |
key: send_api_key, | |
message: { | |
subject: 'Cloudinary Neural Network Style Transfer Contest', | |
text: renderEmailMessage(locals), | |
from_email: 'dan.gilmore@cloudinary.com', | |
from_name: 'Dan Gilmore', | |
to: [ | |
{ | |
email: locals.submission_email, | |
type: 'to' | |
} | |
], | |
} | |
}; | |
request_send.post({ url: send_url, form: body }, function (err, resp, body) { | |
// TODO: really unsure what to do in case of error. Do we care? | |
}); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1. Set up a new webtask.io account and profile if needed | |
https://webtask.io/docs/101 (install wt-cli, run “wt init --profile PROFILE EMAIL”) | |
2. Copy the webtask code into a new local file (style-transfer.js) | |
3. Copy the following config into package.json in the same folder: | |
{ | |
"dependencies": { | |
"namor": "1.0.1", | |
"request": "2.82.0", | |
} | |
} | |
4. Create a new webtask from the command line (needed to use the host name) | |
“wt create --name style-transfer --host faas-cloudinary.com style-transfer.js --profile PROFILE” | |
You can confirm the webtask was created with “wt ls” and “wt inspect style-transfer” | |
5. Open up the webtask editor using the URL found with “wt ls” and “wt edit” commands: | |
$ wt ls --profile PROFILE | |
Name: style-transfer | |
URL: https://wt-...-0.run.webtask.io/style-transfer | |
$ wt edit --profile PROFILE style-transfer | |
Attempting to open the following url in your browser: | |
https://webtask.it.auth0.com/edit/wt-... | |
6. Configure the following at the top of the webtask code: | |
// cloudinary cloud name | |
const cloudinary_cloudname = 'faas'; | |
// webtask name | |
const webtask_name = 'style-transfer'; | |
// this is profile based | |
const webtask_container = 'wt-.......................-0'; | |
// the contest start date | |
const start_date = new Date('2017-08-16T00:00:00').getTime(); | |
// the contest end date | |
const end_date = new Date('2017-08-30T07:59:59').getTime(); | |
7. Initialize the webtask storage | |
In the editor (top of webtask code, upper left corner), click the wrench icon. | |
Select the “storage” menu item from the dropdown. In the left-side panel that opens, | |
update the default storage from {} to the following and save with the “Update” button. | |
Make sure to use straight, not curly, double quotes! | |
{ | |
"entries": {}, | |
"images": {}, | |
"votes": {}, | |
"tallies" : {} | |
} | |
The “entries” section is where all the canonical data is stored. | |
“images”, “votes”, and “tallies” are denormalized data used to simplify lookups. | |
8. Setup admin download URLs via webtask secrets. Click the Wrench icon again, | |
select the Secrets menu dropdown, and add values for the three secrets: | |
ADMIN_DATA_DUMP | |
ADMIN_GENERATE_TALLY | |
ADMIN_EMAIL_DOWNLOAD | |
The values of these secrets are used as the admin URLs for downloading the full JSON from storage, regenerating the vote tallies from the “entries” section, and download the voting CSV for notifying entrants they should vote, and adding opt-in emails to the marketing mailing lists. | |
The URLs are of the format: | |
https://faas-cloudinary.com/{webtask-container}/style-transfer/admin/emails/{ADMIN_EMAIL_DOWNLOAD} | |
https://faas-cloudinary.com/{webtask-container}/style-transfer/admin/data/{ADMIN_DATA_DUMP} | |
https://faas-cloudinary.com/{webtask-container}/style-transfer/admin/generate_tallies/{ADMIN_GENERATE_TALLY} | |
9. Setup outbound email to user upon image submission via a webtask secret and the Mandrill API | |
* https://mandrillapp.com/api/docs/ | |
* https://auth0.com/rules/mandrill | |
* https://auth0.com/docs/email/providers#configure-mandrill-for-sending-email | |
MANDRILL_API_KEY | |
========== | |
Save the webtask updates with the floppy disc icon. The webtask now needs CodePen pen information to complete. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment