Skip to content

Commit

Permalink
add javascript app
Browse files Browse the repository at this point in the history
still requires a lot of work, but the basic use cases are covered.
  • Loading branch information
waf committed Apr 23, 2017
1 parent 9e8706f commit 115031c
Show file tree
Hide file tree
Showing 5 changed files with 329 additions and 0 deletions.
23 changes: 23 additions & 0 deletions oauth_redirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Success</title>
</head>
<body>
Good job! Redirecting...
<script>
// github.com will redirect to this page, after the user allows oauth access.
// this page is in a popup window / new tab. We read the `code` query parameter
// and send it to the main (opener) window using an event.
var code = window.location.href.match(/\?code=(.*)/);
if(code) {
code = code[1];
window.opener.dispatchEvent(new CustomEvent(
"oauth-code-received",
{ detail: code }
));
}
</script>
</body>
</html>
110 changes: 110 additions & 0 deletions src/github-issues.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import axios from 'axios' // ajax library
import serialization from './github-serialization.js'
import auth from './github-oauth.js'

/*
* GitHub Issues API. Handles saving and polling of
* projects, stored as github issue comments.
*
* Expects a configuration of:
* {
* organization: "bkkhack",
* repository: "hackmap",
* label: "BKKHack Main Thread",
* pollIntervalSeconds: 60,
* onAuthenticationRequired: () => Promise(token)
* onProjectsUpdated: (projects) => ()
* }
*/
export default class GitHubIssueService {
constructor(config) {
this.config = config;
this.ajax = this.createGithubApiClient(config.organization, config.repository);

// if the user has already logged in (e.g. in a previous session), use the
// authenticated client for all operations. Authenticated calls have a
// higher rate limit, even for calls that don't require authentication
if(auth.isLoggedIn()) {
this.ensureAuthenticatedClient();
}

// issue ajax requests to load data from github
// the model will be updated periodically from the callbacks
this.ajax.get('issues', { params: { labels: this.config.label } })
.then(issueResponse => {
if(!(issueResponse.data || issueResponse.data.length)) {
throw "Could not find an open issue labeled: " + this.config.label;
}
this.issueNumber = issueResponse.data[0].number;
})
.then(() => this.pollIssueForComments())
.catch(err => this.reportError(err));
}

postNewProject(project) {
return this.ensureAuthenticatedClient()
.then(() => {
let body = serialization.serializeProjectToComment(project);
return this.ajax
.post('issues/' + this.issueNumber + '/comments', { body: body });
})
.then((response) => {
return serialization.deserializeCommentToProject(response.data);
});
}

updateProject(project) {
return this.ensureAuthenticatedClient()
.then(() => {
let body = serialization.serializeProjectToComment(project);
return this.ajax.patch('issues/comments/' + project.id, { body: body });
})
.then(response => {
return serialization.deserializeCommentToProject(response.data);
});
}

pollIssueForComments() {
var commentsUrl = "issues/" + this.issueNumber + "/comments";

var poll = () => {
console.log("polling...");
return this.ajax.get(commentsUrl)
.then((response) => {
var projects = response.data
.map(serialization.deserializeCommentToProject);
this.config.onProjectsUpdated(projects);
})
.catch((err) => this.reportError(err));
}

window.setInterval(poll, 1000 * this.config.pollIntervalSeconds);
return poll();
}

ensureAuthenticatedClient() {
return this.config.onAuthenticationRequired()
.then(token => {
this.ajax = this.createGithubApiClient(
this.config.organization,
this.config.repository,
token);
});
}

createGithubApiClient(org, repo, token) {
var ajaxConfig = {
baseURL: 'https://api.github.com/repos/' + org + '/' + repo + '/',
};
if(token) {
ajaxConfig.headers = {
'Authorization': 'token ' + token
};
}
return axios.create(ajaxConfig);
}

reportError(err) {
console.log(err);
}
}
45 changes: 45 additions & 0 deletions src/github-oauth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import axios from 'axios' // ajax library

const clientId = "1b717d04ec7f3615bb18";
// the following url needs to match the app configuration on github.com
const landingPage = window.location.origin + "/oauth_redirect.html";
const tokenKey = "token";
const oAuthUrl = `https://github.com/login/oauth/authorize?scope=public_repo&redirect_uri=${landingPage}&client_id=${clientId}`;
const codeToTokenUrl = "https://bkkhackmap.herokuapp.com/authenticate/";

// ping to wake heroku instance. we don't care about the response. we just want the oauth login to quick.
axios.options(codeToTokenUrl);

export default {
isLoggedIn: function() {
return !!window.localStorage.getItem(tokenKey);
},
// implements https://developer.github.com/v3/oauth/#web-application-flow
getOAuthToken: function() {
return new Promise((resolve, reject) => {
// if we already have a token, just return it
var token = window.localStorage.getItem(tokenKey);
if(token) {
resolve(token);
return;
}
// otherwise, open a window to do the github login
// github will redirect to landingPage, which will fire an
// event with the github auth code
var oauthWindow = window.open(oAuthUrl); // needs to be a new window. github can't be embedded in an iframe for security reasons
window.addEventListener("oauth-code-received", function(event) {
var code = event.detail;
// we received the code; exchange it for an auth token.
// this part needs to interact with a server because we need to hide the oAuth secret.
// the server is just a heroku app running https://github.com/prose/gatekeeper
axios.get(codeToTokenUrl + code)
.then(response => {
window.localStorage.setItem(tokenKey, response.data.token);
oauthWindow.close();
resolve(response.data.token);
})
.catch(error => reject(error));
});
});
}
}
34 changes: 34 additions & 0 deletions src/github-serialization.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@

const commentExtractionRegex = /<!-- ([.\d]+),([.\d]+) -->/;

function parseCoordinatesFromComment(commentLines) {
var lastLine = commentLines.slice(-1)[0];
var matches = commentExtractionRegex.exec(lastLine);

return matches ? {x: matches[1], y: matches[2]}
: {x: 0, y: 0};
}

export default {
deserializeCommentToProject: function(comment) {
var textLines = comment.body.split('\r\n');
var coords = parseCoordinatesFromComment(textLines);
return {
id: comment.id,
title: textLines[0],
description: textLines[1],
username: comment.user.login,
avatar_thumbnail: comment.user.avatar_url + "&s=" + 40,
avatar: comment.user.avatar_url + "&s=" + 120,
x: coords.x,
y: coords.y,
};
},

serializeProjectToComment: function(project) {
let body = project.title + '\r\n' +
(project.description || '') + '\r\n' +
`<!-- ${project.x},${project.y} -->`;
return body;
}
}
117 changes: 117 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Vue from 'vue';
import GitHubIssueService from './github-issues.js'
import GitHubAuth from './github-oauth.js'

const model = {
projects: [],
mapWidth:10,
mapHeight:0,
form: {
isOpen: false,
title: "",
description: ""
},
selectedProject: {},
authenticationUrl: ""
}
const githubIssue = new GitHubIssueService({
organization: "waf",
repository: "hackmap",
label: "BKKHack Main Thread",
onAuthenticationRequired: GitHubAuth.getOAuthToken,
pollIntervalSeconds: 60,
onProjectsUpdated: projects => {
model.projects = projects;
}
});


var projectsColumn = new Vue({
el: '.projects.side-column',
data: model,
methods: {
toggleForm: function() {
if(!this.form.isOpen) {
this.form.isOpen = true;
return;
}
this.form.title = this.form.title.trim();
this.form.description = this.form.description.trim();

if(!this.form.title && !this.form.description) {
//empty form, close it.
this.form.isOpen = false;
return;
} else if(!this.form.title) {
//user filled in a description, but not a title
alert("Please enter a topic for your project");
return;
} else {
// title and optional description provided, save the text
githubIssue.postNewProject(this.form)
.then(project => {
// add the new project and blank the form.
this.projects.push(project);
this.form.title = "";
this.form.description = "";
})
.catch(err => console.error(err));
this.form.isOpen = false;
}
},
drag: function(event) {
console.log(event);
var avatar = event.target.querySelector("img");
event.dataTransfer.setDragImage(avatar, 20, 20);
var projectId = event.target.dataset.id;
event.dataTransfer.setData("projectId", projectId);
}
}
})

var map = new Vue({
el: '.center-column',
data: model,
methods: {
drop: function(event) {
var projectId = event.dataTransfer.getData("projectId");
var project = model.projects.filter(p => p.id == projectId)[0];
this.updateMapDimensions();
var oldX = project.x;
var oldY = project.y;
project.x = event.offsetX / model.mapWidth;
project.y = event.offsetY / model.mapHeight;
githubIssue.updateProject(project)
.catch(err => {
console.log("update rejected");
project.x = oldX;
project.y = oldY;
});
},
dragover: function(event) {
event.preventDefault(); // mark this element as drop target.
event.dataTransfer.dropEffect = 'copy';
},
drag: function(event) {
var projectId = event.target.dataset.id;
event.dataTransfer.setData("projectId", projectId);
},
updateMapDimensions: function() {
var floorplan = this.$el.querySelector('.floorplan');
model.mapWidth = floorplan.clientWidth;
model.mapHeight = floorplan.clientHeight;
},
},
mounted: function () {
window.addEventListener('resize', this.updateMapDimensions);
this.updateMapDimensions();
},
beforeDestroy: function () {
window.removeEventListener('resize', this.updateMapDimensions);
}
});

var projectDetails = new Vue({
el: '.details.side-column',
data: model
})

0 comments on commit 115031c

Please sign in to comment.