diff --git a/public_html/css/core.css b/public_html/css/core.css
new file mode 100644
index 00000000..21c92654
--- /dev/null
+++ b/public_html/css/core.css
@@ -0,0 +1,25 @@
+body {
+ margin: 0;
+ padding: 0;
+ text-align: center;
+ background: #fff;
+ color: #000;
+ font-family: 'Droid Sans', sans-serif;
+ font-size: 17px;
+}
+
+#middle {
+ padding: 0 2em;
+}
+
+#content {
+ margin: 1.5em 0;
+ padding: 0;
+ display: inline-block;
+ text-align: left;
+}
+
+a {
+ color: #6a2;
+ text-decoration: none;
+}
diff --git a/public_html/css/forms.css b/public_html/css/forms.css
new file mode 100644
index 00000000..e91e2aca
--- /dev/null
+++ b/public_html/css/forms.css
@@ -0,0 +1,46 @@
+.form-wrapper {
+ display: table;
+}
+
+.form-row {
+ display: table-row;
+}
+
+.form-label,
+.form-input {
+ display: table-cell;
+ line-height: 37px;
+ vertical-align: text-bottom;
+}
+
+.form-input {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.form-row label {
+ padding-right: 1em;
+ text-align: right;
+}
+
+textarea,
+input[type=text],
+input[type=password] {
+ padding: 2px 4px;
+ border: 1px solid #eee;
+ background: #fafafa;
+ font-family: monospace;
+ font-size: 17px;
+ text-overflow: ellipsis;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+button,
+input[type=button] {
+ padding: 0.1em 0.5em;
+ border: 1px solid #eee;
+ background: #eee;
+ font-family: 'Droid Sans', sans-serif;
+ font-size: 17px;
+}
diff --git a/public_html/css/login-form.css b/public_html/css/login-form.css
new file mode 100644
index 00000000..5122ce5d
--- /dev/null
+++ b/public_html/css/login-form.css
@@ -0,0 +1,3 @@
+#login-form p {
+ text-align: center;
+}
diff --git a/public_html/css/messages.css b/public_html/css/messages.css
new file mode 100644
index 00000000..5e2628c0
--- /dev/null
+++ b/public_html/css/messages.css
@@ -0,0 +1,15 @@
+.message {
+ margin-bottom: 0.2em;
+ padding: 0.4em 0.5em;
+ text-align: center;
+}
+
+.message.error {
+ background: #fdd;
+ box-shadow: 0 0 0 1px #fcc inset;
+}
+
+.message.info {
+ background: #def;
+ box-shadow: 0 0 0 1px #cdf inset;
+}
diff --git a/public_html/css/registration-form.css b/public_html/css/registration-form.css
new file mode 100644
index 00000000..c3feef76
--- /dev/null
+++ b/public_html/css/registration-form.css
@@ -0,0 +1,3 @@
+#registration-form p {
+ text-align: center;
+}
diff --git a/public_html/css/top-navigation.css b/public_html/css/top-navigation.css
new file mode 100644
index 00000000..3a25b2a7
--- /dev/null
+++ b/public_html/css/top-navigation.css
@@ -0,0 +1,38 @@
+#top-navigation {
+ width: 100%;
+}
+
+#top-navigation ul {
+ list-style-type: none;
+ padding: 0 2em;
+ margin: 0;
+}
+
+#top-navigation li {
+ display: inline-block;
+}
+
+#top-navigation li a {
+ display: inline-block;
+ padding: 0.5em 1em;
+ color: #000;
+}
+
+#top-navigation li.active a {
+ background: #faffca;
+}
+
+#top-navigation li a:focus,
+#top-navigation li a:hover {
+ outline: 0;
+ background: #efa;
+ color: #000;
+}
+
+#top-navigation li:first-child a {
+ margin-left: -1em;
+}
+
+#top-navigation li:last-child a {
+ margin-right: -1em;
+}
diff --git a/public_html/index.html b/public_html/index.html
new file mode 100644
index 00000000..2bf34047
--- /dev/null
+++ b/public_html/index.html
@@ -0,0 +1,166 @@
+
+
+
+
+ szurubooru
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public_html/js/Api.js b/public_html/js/Api.js
new file mode 100644
index 00000000..ebad8e44
--- /dev/null
+++ b/public_html/js/Api.js
@@ -0,0 +1,57 @@
+var App = App || {};
+
+App.API = function() {
+
+ var baseUrl = '/api/';
+
+ function get(url, data) {
+ return request('GET', url, data);
+ };
+
+ function post(url, data) {
+ return request('POST', url, data);
+ };
+
+ function put(url, data) {
+ return request('PUT', url, data);
+ };
+
+ function _delete(url, data) {
+ return request('DELETE', url, data);
+ };
+
+ function request(method, url, data) {
+ var fullUrl = baseUrl + '/' + url;
+ fullUrl = fullUrl.replace(/\/{2,}/, '/');
+
+ return new Promise(function(resolve, reject) {
+ $.ajax({
+ success: function(data, textStatus, xhr) {
+ resolve({
+ status: xhr.status,
+ json: data});
+ },
+ error: function(xhr, textStatus, errorThrown) {
+ reject({
+ status: xhr.status,
+ json: xhr.responseJSON
+ ? xhr.responseJSON
+ : {error: errorThrown}});
+ },
+ type: method,
+ url: fullUrl,
+ data: data,
+ });
+ });
+ };
+
+ return {
+ get: get,
+ post: post,
+ put: put,
+ delete: _delete
+ };
+
+};
+
+App.DI.registerSingleton('api', App.API);
diff --git a/public_html/js/Auth.js b/public_html/js/Auth.js
new file mode 100644
index 00000000..2a11cc32
--- /dev/null
+++ b/public_html/js/Auth.js
@@ -0,0 +1,92 @@
+var App = App || {};
+
+App.Auth = function(jQuery, api, appState) {
+
+ function loginFromCredentials(userName, password, remember) {
+ return new Promise(function(resolve, reject) {
+ api.post('/login', {userName: userName, password: password})
+ .then(function(response) {
+ appState.set('loggedIn', true);
+ appState.set('loggedInUser', response.json.user);
+ appState.set('loginToken', response.json.token);
+ jQuery.cookie(
+ 'auth',
+ response.json.token.name,
+ remember ? { expires: 365 } : {});
+ resolve(response);
+ }).catch(function(response) {
+ reject(response);
+ });
+ });
+ };
+
+ function loginFromToken(token) {
+ return new Promise(function(resolve, reject) {
+ api.post('/login', {token: token})
+ .then(function(response) {
+ appState.set('loggedIn', response.json.user && response.json.user.id);
+ appState.set('loggedInUser', response.json.user);
+ appState.set('loginToken', response.json.token.name);
+ resolve(response);
+ }).catch(function(response) {
+ reject(response);
+ });
+ });
+ };
+
+ function loginAnonymous() {
+ return new Promise(function(resolve, reject) {
+ api.post('/login')
+ .then(function(response) {
+ appState.set('loggedIn', false);
+ appState.set('loggedInUser', response.json.user);
+ appState.set('loginToken', null);
+ resolve(response);
+ }).catch(function(response) {
+ reject(response);
+ });
+ });
+ };
+
+ function logout() {
+ return new Promise(function(resolve, reject) {
+ appState.set('loggedIn', false);
+ appState.set('loginToken', null);
+ jQuery.removeCookie('auth');
+ resolve();
+ });
+ };
+
+ function tryLoginFromCookie() {
+ return new Promise(function(resolve, reject) {
+ if (appState.get('loggedIn')) {
+ resolve();
+ return;
+ }
+
+ var authCookie = jQuery.cookie('auth');
+ if (!authCookie) {
+ reject();
+ return;
+ }
+
+ loginFromToken(authCookie).then(function(response) {
+ resolve();
+ }).catch(function(response) {
+ jQuery.removeCookie('auth');
+ reject();
+ });
+ });
+ };
+
+ return {
+ loginFromCredentials: loginFromCredentials,
+ loginFromToken: loginFromToken,
+ loginAnonymous: loginAnonymous,
+ tryLoginFromCookie: tryLoginFromCookie,
+ logout: logout,
+ };
+
+};
+
+App.DI.registerSingleton('auth', App.Auth);
diff --git a/public_html/js/Bootstrap.js b/public_html/js/Bootstrap.js
new file mode 100644
index 00000000..635e40a3
--- /dev/null
+++ b/public_html/js/Bootstrap.js
@@ -0,0 +1,33 @@
+var App = App || {};
+
+App.Bootstrap = function(auth, router) {
+
+ auth.tryLoginFromCookie()
+ .then(startRouting)
+ .catch(function(error) {
+ auth.loginAnonymous()
+ .then(startRouting)
+ .catch(function(response) {
+ console.log(response);
+ alert('Fatal authentication error: ' + response.json.error);
+ });
+ });
+
+ function startRouting() {
+ try {
+ router.start();
+ } catch (err) {
+ console.log(err);
+ }
+ }
+
+ return {
+ startRouting: startRouting,
+ };
+
+};
+
+App.DI.registerSingleton('bootstrap', App.Bootstrap);
+App.DI.registerManual('jQuery', function() { return $; });
+
+var bootstrap = App.DI.get('bootstrap');
diff --git a/public_html/js/DI.js b/public_html/js/DI.js
new file mode 100644
index 00000000..b2b87f91
--- /dev/null
+++ b/public_html/js/DI.js
@@ -0,0 +1,65 @@
+var App = App || {};
+
+App.DI = (function() {
+
+ var STRIP_COMMENTS = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/mg;
+ var ARGUMENT_NAMES = /([^\s,]+)/g;
+
+ var factories = {};
+ var instances = {};
+
+ function get(key) {
+ var instance = instances[key];
+ if (!instance) {
+ var factory = factories[key];
+ if (!factory)
+ throw new Error('Unregistered key: ' + key);
+ var objectInitializer = factory.initializer;
+ var singleton = factory.singleton;
+ var deps = resolveDependencies(objectInitializer);
+ var instance = {};
+ instance = objectInitializer.apply(instance, deps);
+ if (singleton)
+ instances[key] = instance;
+ }
+ return instance;
+ }
+
+ function resolveDependencies(objectIntializer) {
+ var deps = [];
+ var depKeys = getFunctionParameterNames(objectIntializer);
+ for (var i = 0; i < depKeys.length; i ++) {
+ deps[i] = get(depKeys[i]);
+ }
+ return deps;
+ }
+
+ function register(key, objectInitializer) {
+ factories[key] = {initializer: objectInitializer, singleton: false};
+ };
+
+ function registerSingleton(key, objectInitializer) {
+ factories[key] = {initializer: objectInitializer, singleton: true};
+ };
+
+ function registerManual(key, objectInitializer) {
+ instances[key] = objectInitializer();
+ };
+
+ function getFunctionParameterNames(func) {
+ var fnStr = func.toString().replace(STRIP_COMMENTS, '');
+ var result = fnStr.slice(fnStr.indexOf('(') + 1, fnStr.indexOf(')')).match(ARGUMENT_NAMES);
+ if (result === null) {
+ result = [];
+ }
+ return result;
+ }
+
+ return {
+ get: get,
+ register: register,
+ registerManual: registerManual,
+ registerSingleton: registerSingleton,
+ };
+
+})();
diff --git a/public_html/js/Presenters/LoginPresenter.js b/public_html/js/Presenters/LoginPresenter.js
new file mode 100644
index 00000000..386f43d0
--- /dev/null
+++ b/public_html/js/Presenters/LoginPresenter.js
@@ -0,0 +1,59 @@
+var App = App || {};
+App.Presenters = App.Presenters || {};
+
+App.Presenters.LoginPresenter = function(
+ jQuery,
+ topNavigationPresenter,
+ messagePresenter,
+ auth,
+ router,
+ appState) {
+
+ topNavigationPresenter.select('login');
+
+ var $el = jQuery('#content');
+ var $messages;
+ var template = _.template(jQuery('#login-form-template').html());
+
+ var eventHandlers = {
+
+ loginFormSubmit: function(e) {
+ e.preventDefault();
+ messagePresenter.hideMessages($messages);
+
+ var userName = $el.find('[name=user]').val();
+ var password = $el.find('[name=password]').val();
+ var remember = $el.find('[name=remember]').val();
+
+ //todo: client side error reporting
+
+ auth.loginFromCredentials(userName, password, remember)
+ .then(function(response) {
+ router.navigateToMainPage();
+ //todo: "redirect" to main page
+ }).catch(function(response) {
+ messagePresenter.showError($messages, response.json && response.json.error || response);
+ });
+ },
+
+ };
+
+ if (appState.get('loggedIn'))
+ router.navigateToMainPage();
+
+ render();
+
+ function render() {
+ $el.html(template());
+ $el.find('form').submit(eventHandlers.loginFormSubmit);
+ $messages = $el.find('.messages');
+ $messages.width($el.find('form').width());
+ };
+
+ return {
+ render: render,
+ };
+
+};
+
+App.DI.register('loginPresenter', App.Presenters.LoginPresenter);
diff --git a/public_html/js/Presenters/LogoutPresenter.js b/public_html/js/Presenters/LogoutPresenter.js
new file mode 100644
index 00000000..ac0b6e35
--- /dev/null
+++ b/public_html/js/Presenters/LogoutPresenter.js
@@ -0,0 +1,34 @@
+var App = App || {};
+App.Presenters = App.Presenters || {};
+
+App.Presenters.LogoutPresenter = function(
+ jQuery,
+ topNavigationPresenter,
+ messagePresenter,
+ auth,
+ router) {
+
+ topNavigationPresenter.select('logout');
+
+ var $messages = jQuery('#content');
+
+ var eventHandlers = {
+ mainPageLinkClick: function(e) {
+ e.preventDefault();
+ router.navigateToMainPage();
+ },
+ };
+
+ auth.logout().then(function() {
+ var $messageDiv = messagePresenter.showInfo($messages, 'Logged out. Back to main page');
+ $messageDiv.find('a').click(eventHandlers.mainPageLinkClick);
+ }).catch(function(response) {
+ messagePresenter.showError($messages, response.json && response.json.error || response);
+ });
+
+ return {
+ };
+
+};
+
+App.DI.register('logoutPresenter', App.Presenters.LogoutPresenter);
diff --git a/public_html/js/Presenters/MessagePresenter.js b/public_html/js/Presenters/MessagePresenter.js
new file mode 100644
index 00000000..0e5b5802
--- /dev/null
+++ b/public_html/js/Presenters/MessagePresenter.js
@@ -0,0 +1,41 @@
+var App = App || {};
+App.Presenters = App.Presenters || {};
+
+App.Presenters.MessagePresenter = function(jQuery) {
+
+ function showInfo($el, message) {
+ return showMessage($el, 'info', message);
+ };
+
+ function showError($el, message) {
+ return showMessage($el, 'error', message);
+ };
+
+ function hideMessages($el) {
+ $el.children('.message').each(function() {
+ $(this).slideUp('fast', function() {
+ $(this).remove();
+ });
+ });
+ };
+
+ function showMessage($el, className, message) {
+ var $messageDiv = $('');
+ $messageDiv.addClass('message');
+ $messageDiv.addClass(className);
+ $messageDiv.html(message);
+ $messageDiv.hide();
+ $el.append($messageDiv);
+ $messageDiv.slideDown('fast');
+ return $messageDiv;
+ };
+
+ return {
+ showInfo: showInfo,
+ showError: showError,
+ hideMessages: hideMessages,
+ };
+
+};
+
+App.DI.register('messagePresenter', App.Presenters.MessagePresenter);
diff --git a/public_html/js/Presenters/RegistrationPresenter.js b/public_html/js/Presenters/RegistrationPresenter.js
new file mode 100644
index 00000000..2a713b2f
--- /dev/null
+++ b/public_html/js/Presenters/RegistrationPresenter.js
@@ -0,0 +1,68 @@
+var App = App || {};
+App.Presenters = App.Presenters || {};
+
+App.Presenters.RegistrationPresenter = function(
+ jQuery,
+ topNavigationPresenter,
+ messagePresenter,
+ api) {
+
+ topNavigationPresenter.select('register');
+
+ var $el = jQuery('#content');
+ var template = _.template(jQuery('#registration-form-template').html());
+
+ var eventHandlers = {
+
+ registrationFormSubmit: function(e) {
+ e.preventDefault();
+ messagePresenter.hideMessages($messages);
+
+ var userName = $el.find('[name=user]').val();
+ var password = $el.find('[name=password1]').val();
+ var passwordConfirmation = $el.find('[name=password2]').val();
+ var email = $el.find('[name=email]').val();
+
+ if (userName.length == 0) {
+ messagePresenter.showError($messages, 'User name cannot be empty.');
+ return;
+ }
+
+ if (password.length == 0) {
+ messagePresenter.showError($messages, 'Password cannot be empty.');
+ return;
+ }
+
+ if (password != passwordConfirmation) {
+ messagePresenter.showError($messages, 'Passwords must be the same.');
+ return;
+ }
+
+ api.post('/users', {userName: userName, password: password, email: email})
+ .then(function(response) {
+ //todo: show message about registration success
+ //if it turned out that user needs to confirm his e-mail, notify about it
+ //also, show link to login
+ }).catch(function(response) {
+ messagePresenter.showError($messages, response.json && response.json.error || response);
+ });
+ }
+
+ };
+
+ render();
+
+ function render() {
+ $el.html(template());
+ $el.find('form').submit(eventHandlers.registrationFormSubmit);
+ $messages = $el.find('.messages');
+ $messages.width($el.find('form').width());
+ };
+
+ return {
+ render: render,
+ };
+
+};
+
+App.DI.register('registrationPresenter', App.Presenters.RegistrationPresenter);
diff --git a/public_html/js/Presenters/TopNavigationPresenter.js b/public_html/js/Presenters/TopNavigationPresenter.js
new file mode 100644
index 00000000..340010ed
--- /dev/null
+++ b/public_html/js/Presenters/TopNavigationPresenter.js
@@ -0,0 +1,37 @@
+var App = App || {};
+App.Presenters = App.Presenters || {};
+
+App.Presenters.TopNavigationPresenter = function(jQuery, appState) {
+
+ var selectedElement = null;
+ var template = _.template(jQuery('#top-navigation-template').html());
+ var $el = jQuery('#top-navigation');
+
+ var eventHandlers = {
+ loginStateChanged: function() {
+ render();
+ },
+ };
+
+ appState.startObserving('loggedIn', 'top-navigation', eventHandlers.loginStateChanged);
+ render();
+
+ function select(newSelectedElement) {
+ selectedElement = newSelectedElement;
+ $el.find('li').removeClass('active');
+ $el.find('li.' + selectedElement).addClass('active');
+ };
+
+ function render() {
+ $el.html(template({loggedIn: appState.get('loggedIn')}));
+ $el.find('li.' + selectedElement).addClass('active');
+ };
+
+ return {
+ render: render,
+ select: select,
+ };
+
+};
+
+App.DI.register('topNavigationPresenter', App.Presenters.TopNavigationPresenter);
diff --git a/public_html/js/Presenters/UserListPresenter.js b/public_html/js/Presenters/UserListPresenter.js
new file mode 100644
index 00000000..c8794742
--- /dev/null
+++ b/public_html/js/Presenters/UserListPresenter.js
@@ -0,0 +1,22 @@
+var App = App || {};
+App.Presenters = App.Presenters || {};
+
+App.Presenters.UserListPresenter = function(jQuery, topNavigationPresenter, appState) {
+
+ topNavigationPresenter.select('users');
+
+ var $el = jQuery('#content');
+
+ render();
+
+ function render() {
+ $el.html('Logged in: ' + appState.get('loggedIn'));
+ };
+
+ return {
+ render: render
+ };
+
+};
+
+App.DI.register('userListPresenter', App.Presenters.UserListPresenter);
diff --git a/public_html/js/Router.js b/public_html/js/Router.js
new file mode 100644
index 00000000..3c68755f
--- /dev/null
+++ b/public_html/js/Router.js
@@ -0,0 +1,53 @@
+var App = App || {};
+
+App.Router = function(jQuery) {
+
+ var root = '#/';
+
+ injectRoutes();
+
+ function navigateToMainPage() {
+ window.location.href = root;
+ };
+
+ function navigate(url) {
+ window.location.href = url;
+ };
+
+ function start() {
+ Path.listen();
+ };
+
+ function changePresenter(presenterGetter) {
+ jQuery('#content').empty();
+ var presenter = presenterGetter();
+ };
+
+ function injectRoutes() {
+ inject('#/login', function() { return new App.DI.get('loginPresenter'); });
+ inject('#/logout', function() { return new App.DI.get('logoutPresenter'); });
+ inject('#/register', function() { return new App.DI.get('registrationPresenter'); });
+ inject('#/users', function() { return App.DI.get('userListPresenter'); });
+ setRoot('#/users');
+ };
+
+ function setRoot(newRoot) {
+ root = newRoot;
+ Path.root(newRoot);
+ };
+
+ function inject(path, presenterGetter) {
+ Path.map(path).to(function() {
+ changePresenter(presenterGetter);
+ });
+ };
+
+ return {
+ start: start,
+ navigate: navigate,
+ navigateToMainPage: navigateToMainPage,
+ };
+
+};
+
+App.DI.registerSingleton('router', App.Router);
diff --git a/public_html/js/State.js b/public_html/js/State.js
new file mode 100644
index 00000000..22d4a2a2
--- /dev/null
+++ b/public_html/js/State.js
@@ -0,0 +1,48 @@
+var App = App || {};
+
+App.State = function() {
+
+ var properties = {};
+ var observers = {};
+
+ function get(key) {
+ return properties[key];
+ };
+
+ function set(key, value) {
+ properties[key] = value;
+ if (key in observers) {
+ for (observerName in observers[key]) {
+ if (observers[key].hasOwnProperty(observerName)) {
+ observers[key][observerName](key, value);
+ }
+ }
+ }
+ };
+
+ function startObserving(key, observerName, callback) {
+ if (!(key in observers))
+ observers[key] = {};
+ if (!(observerName in observers[key]))
+ observers[key][observerName] = {};
+ observers[key][observerName] = callback;
+ };
+
+ function stopObserving(key, observerName) {
+ if (!(key in observers))
+ return;
+ if (!(observerName in observers[key]))
+ return;
+ delete observers[key][observerName];
+ };
+
+ return {
+ get: get,
+ set: set,
+ startObserving: startObserving,
+ stopObserving: stopObserving,
+ };
+
+};
+
+App.DI.registerSingleton('appState', App.State);