diff --git a/.gitignore b/.gitignore
index d18387f6..045f7ae9 100755
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,13 @@
cookbook.tar.gz
.idea/
+
+# Built js package
+js/*
+
+### NPM ###
+
+# Node modules
+node_modules
+# Package lock
+package-lock.json
diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6623931e..74162d45 100755
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -5,7 +5,7 @@
Cookbook
An integrated cookbook using schema.org JSON files as recipes
- 0.6.5
+ 0.7.0
agpl
Jeppe Zapp
Cookbook
diff --git a/appinfo/routes.php b/appinfo/routes.php
index a3327b91..783af79c 100755
--- a/appinfo/routes.php
+++ b/appinfo/routes.php
@@ -25,6 +25,7 @@ return [
['name' => 'recipe#image', 'url' => '/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+']],
['name' => 'config#reindex', 'url' => '/reindex', 'verb' => 'POST'],
['name' => 'config#config', 'url' => '/config', 'verb' => 'POST'],
+ /* API routes */
],
'resources' => [
'recipe' => ['url' => '/api/recipes']
diff --git a/css/style.css b/css/style.css
index 91c4f917..a4e26e6b 100755
--- a/css/style.css
+++ b/css/style.css
@@ -1,179 +1,26 @@
-/**
- * Helpers
- */
-
-.icon-arrow-up {
- background-image: var(--icon-triangle-n-000);
-}
-
-.icon-arrow-down {
- background-image: var(--icon-triangle-s-000);
-}
-
-.input-group {
- display: flex;
- width: 100%;
-}
-
- .input-group > input {
- width: 100%;
- margin: 0;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- }
-
- .input-group > .input-group-addon {
- display: flex;
- }
-
- .input-group > .input-group-addon > button {
- padding: 0;
- margin: 0;
- min-width: 34px;
- min-height: 34px;
- }
-
- .input-group > .input-group-addon > button {
- border-radius: 0;
- border-left-color: transparent;
- border-right-color: transparent;
- }
-
- .input-group > .input-group-addon > button:last-child {
- border-top-right-radius: var(--border-radius);
- border-bottom-right-radius: var(--border-radius);
- border-right-width: 1px;
- }
-
-.textarea-group {
-
-}
- .textarea-group-addon {
- float: right;
- display: flex;
- position: relative;
- top: 1px;
- z-index: 1;
- }
-
- .textarea-group-addon::after {
- display: table;
- content: '';
- clear: both;
- }
-
- .textarea-group-addon button {
- min-width: 34px;
- min-height: 34px;
- border-radius: 0;
- margin: 0;
- }
-
- .textarea-group-addon button:first-child {
- border-top-left-radius: var(--border-radius);
- }
-
- .textarea-group-addon button:last-child {
- border-top-right-radius: var(--border-radius);
- }
-
/**
* Main
*/
-#app {
+ #app {
width: 100%;
}
-/**
- * Navigation
- */
-#app-navigation {}
- #app-navigation .app-navigation-create {
- padding: 10px;
- }
-
- #app-navigation .app-navigation-create .button {
- display: inline-block;
- width: 100%;
- padding: 10px;
- padding-left: 34px;
- background-position: 10px center;
- text-align: left;
- margin: 0;
- }
-
- #app-navigation li {
- background-position: 10px center;
- text-align: left;
- margin: 0;
- }
-
- #app-navigation .app-navigation-new {
- display: flex !important;
- }
- #app-navigation .app-navigation-new:not(:first-child) {
- padding-top: 0;
- }
-
- #app-navigation #create-recipe > button {
- height: 40px !important;
- }
-
- #app-navigation .app-navigation-new input {
- border-radius: var(--border-radius) 0 0 var(--border-radius);
- line-height: 40px;
- height: 40px;
- margin: 0;
- flex-grow: 1;
- border-right-width: 0;
- flex-basis: 0;
- }
-
- #app-navigation .app-navigation-new button {
- border-radius: 0 var(--border-radius) var(--border-radius) 0;
- padding: 0 !important;
- width: 40px !important;
- height: 40px !important;
- background-position: 50% 50%;
- }
-
- /* For some reason, some images would disappear below 30px width if they were displayed inline */
- #app-navigation > ul > li > a:first-child img {
- display: block;
- float: left;
- margin-top: 12px;
- }
-
-/**
- * Settings
- */
-#app-settings {}
- #app-settings .button {
- padding: 6px 12px;
- padding-left: 12px;
- padding-left: 34px;
- margin: 0 0 1em 0;
- border-radius: var(--border-radius);
- background-position: left 9px center;
- z-index: 2;
- }
-
- #app-settings input[type="text"],
- #app-settings input[type="number"],
- #app-settings .button {
- width: 100%;
- display: block;
- }
-
-/**
- * Content
- */
-#app-content {
- min-width: calc(100% - 300px);
+.app-navigation-new button {
+ min-height: 44px !important;
+ background-image: var(--icon-add-000) !important;
+ background-repeat: no-repeat !important;
}
-
-#app-content-wrapper {
- flex-wrap: wrap;
+.app-navigation-entry *:not(.app-navigation-entry-icon) {
+ background: initial !important;
+}
+.app-navigation-entry.recipe {
+ /* Let's not waste space in front of the recipe if we're only using the icon to show loading */
+ padding-left: 0 !important;
+}
+.app-navigation-entry:hover,
+.app-navigation-entry.router-link-exact-active {
+ opacity: 1 !important;
+ box-shadow: inset 4px 0 var(--color-primary) !important;
}
.home {
padding: 1rem;
@@ -437,122 +284,14 @@
width: 100%;
}}
- #app-content-wrapper form fieldset > input[name="image"] {
- width: calc(100% - 14em);
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- border-right: 0;
- }
- @media(max-width:1199px) { #app-content-wrapper form fieldset > input[name="image"] {
- width: calc(100% - 3em);
- }}
+ .app-navigation-entry:not(:hover) li.recipe {
+ box-shadow: inset 4px 0 rgba(255, 255, 255, 0.5);
+ }
+ .app-navigation-entry:hover li.recipe {
+ box-shadow: inset 4px 0 rgba(255, 255, 255, 1);
+ }
- #app-content-wrapper form fieldset > input[name="image"] + button {
- border-top-right-radius: var(--border-radius);
- border-bottom-right-radius: var(--border-radius);
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
- width: 3em;
- margin: 0;
- }
-
- #app-content-wrapper form fieldset > input[name="image"] + button > * {
- pointer-events: none;
- }
-
- #app-content-wrapper form fieldset > label {
- display: inline-block;
- width: 10em;
- line-height: 18px;
- font-weight: bold;
- float: left;
- }
- @media(max-width:1199px) { #app-content-wrapper form fieldset > label {
- display: block;
- float: none;
- }}
-
- #app-content-wrapper form fieldset ul label input[type="checkbox"] {
- margin-left: 1em;
- vertical-align: middle;
- cursor: pointer;
- }
-
- #app-content-wrapper form fieldset > ul {
- margin-top: 2rem;
- padding-left: 1em;
- }
-
- #app-content-wrapper form fieldset > ul + button {
- width: 36px;
- text-align: center;
- padding: 0;
- float: right;
- }
-
- #app-content-wrapper form fieldset > ul > li {
- margin: 0 0 1em 0;
- }
-
- #app-content-wrapper form fieldset > ul > li > input {
- width: 100%;
- margin: 0;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- }
-
- #app-content-wrapper form fieldset > ul > li > button {
- position: absolute;
- right: 10px;
- margin-top: 8px;
- margin-right: 9px;
- z-index: 10;
- border: 0;
- background-color: transparent;
- }
-
- #app-content-wrapper form fieldset > ul > li > textarea {
- min-height: 10em;
- resize: vertical;
- width: 100%;
- margin: 0;
- border-top-right-radius: 0;
- }
-
- #app-content-wrapper form fieldset > ul > li .step-number {
- padding: 6px;
- float: left;
- }
-
- #app-content-wrapper form button[type="submit"] {
- margin-left: auto;
- display: block;
- }
-
-/**
- * Print
- */
@media print {
- #header,
- #app-navigation,
- #controls,
- .recipe-toolbar,
- #app-content header:not(.printable),
- .times button {
- display: none !important;
- }
-
- #content,
- #app-content-wrapper {
- display: block !important;
- padding: 0 !important;
- overflow: visible !important;
- }
-
- #app-content {
- margin-left: 0 !important;
- }
-
a:link:after,
a:visited:after {
content:" [" attr(href) "] ";
diff --git a/js/script.js b/js/script.js
deleted file mode 100755
index bd84d0a4..00000000
--- a/js/script.js
+++ /dev/null
@@ -1,747 +0,0 @@
-(function (OC, window, $, undefined) {
-'use strict';
-
-var appName = 'cookbook';
-
-$(document).ready(function () {
-
-/**
- * The API helper
- */
-var Cookbook = function (baseUrl) {
- this._baseUrl = baseUrl;
-};
-
-Cookbook.prototype = {
- /**
- * Reindexes all recipes
- */
- reindex: function () {
- var deferred = $.Deferred();
- var self = this;
- $.ajax({
- url: this._baseUrl + '/reindex',
- method: 'POST'
- }).done(function () {
- deferred.resolve();
- }).fail(function (jqXHR, textStatus, errorThrown) {
- deferred.reject(new Error(jqXHR.responseText));
- });
- return deferred.promise();
- },
-
- /**
- * Updates a recipe with form data
- */
- update: function(form) {
- var action = form.getAttribute('action');
- var url = action === '#' ? location.hash.substr(1) : action;
- var data = $(form).serialize();
- var deferred = $.Deferred();
-
- $.ajax({
- url: this._baseUrl + '/' + url,
- method: form.getAttribute('method'),
- data: data
- }).done(function (response) {
- deferred.resolve(response);
- }).fail(function (jqXHR, textStatus, errorThrown) {
- deferred.reject(new Error(jqXHR.responseText));
- });
-
- return deferred.promise();
- },
-
- /**
- * Loads a recipe by id
- *
- * @param {String} id
- */
- load: function (id) {
- location.hash = id;
- },
-
- /**
- * Gets the loaded recipe id
- *
- * @return {String} Id
- */
- getActiveId: function () {
- return parseInt(location.hash.replace( /[^0-9]/g, '')) || null;
- },
-
- /**
- * Imports a recipe from a URL
- *
- * @param {String} url
- */
- import: function (url) {
- var deferred = $.Deferred();
- var self = this;
-
- $('#import-recipe .icon-download').hide();
- $('#import-recipe .icon-loading').show();
-
- $.ajax({
- url: this._baseUrl + '/import',
- method: 'POST',
- data: 'url=' + url
- }).done(function (recipe) {
- $('#import-recipe .icon-download').show();
- $('#import-recipe .icon-loading').hide();
-
- location.hash = 'recipes/' + recipe.id;
- deferred.resolve();
- }).fail(function (jqXHR, textStatus, errorThrown) {
- $('#import-recipe .icon-download').show();
- $('#import-recipe .icon-loading').hide();
-
- deferred.reject(new Error(jqXHR.responseText));
- });
- return deferred.promise();
- },
-
- /**
- * Gets all recipes
- *
- * @return {Array} Recipes
- */
- getAll: function () {
- return this._recipes;
- },
-
- /**
- * Loads all recipes for display
- */
- loadAll: function () {
- var deferred = $.Deferred();
- var self = this;
- $.get(this._baseUrl + '/recipes').done(function (recipes) {
- self._recipes = recipes;
- deferred.resolve();
- }).fail(function (jqXHR, textStatus, errorThrown) {
- deferred.reject(new Error(jqXHR.responseText));
- });
-
- return deferred.promise();
- },
-
- /**
- * Sets the config update interval
- *
- * @param {Number} interval
- */
- setUpdateInterval: function(interval) {
- var self = this;
-
- $.ajax({
- url: self._baseUrl + '/config',
- method: 'POST',
- data: { 'update_interval': interval }
- }).fail(function(e) {
- alert(t(appName, 'Could not set recipe update interval to {interval}', {interval: interval}));
- });
- },
-
- /**
- * Sets the config to print recipe image
- *
- * @param {Boolean} interval
- */
- setPrintImage: function(printImage) {
- var self = this;
-
- $.ajax({
- url: self._baseUrl + '/config',
- method: 'POST',
- data: { 'print_image': printImage ? 1 : 0 }
- }).fail(function(e) {
- alert(t(appName, 'Could not set preference for image printing'));
- });
- },
-
- /**
- * Sets the recipe base directory using a callback
- *
- * @param {Function} cb
- */
- setFolder: function(cb) {
- var self = this;
-
- OC.dialogs.filepicker(
- t(appName, 'Path to your recipe collection'),
- function (path) {
- $.ajax({
- url: self._baseUrl + '/config',
- method: 'POST',
- data: { 'folder': path },
- }).done(function () {
- self.loadAll()
- .then(function() {
- self._activeRecipe = null;
- location.hash = '';
-
- cb(path);
- });
- }).fail(function(e) {
- alert(t(appName, 'Could not set recipe folder to {path}', {path: path}));
- cb(null);
- });
- },
- false,
- 'httpd/unix-directory',
- true
- );
- },
-
- /**
- * Shows a notification to the user
- *
- * @param {String} title
- * @param {Object} options
- */
- notify: function notify(title, options) {
- if(!('Notification' in window)) {
- return;
- } else if(Notification.permission === "granted") {
- var notification = new Notification(title, options);
- } else if(Notification.permission !== 'denied') {
- Notification.requestPermission(function(permission) {
- if(!('permission' in Notification)) {
- Notification.permission = permission;
- }
- if(permission === "granted") {
- var notification = new Notification(title, options);
- } else {
- alert(title);
- }
- });
- }
- }
-};
-
-/**
- * The content view
- */
-var Content = function (cookbook) {
- var self = this;
-
- /**
- * Render
- */
- self.render = function () {
- var route = location.hash.substr(1);
-
- if(route.length === 0) {
- route = 'home';
- }
- $.ajax({
- url: cookbook._baseUrl + '/' + route,
- method: 'GET',
- })
- .done(function (html) {
- $('#app-content-wrapper').html(html);
-
- // Common
- $('#print-recipe').click(self.onPrintRecipe);
- $('#delete-recipe').click(self.onDeleteRecipe);
-
- // Editor
- $('#app-content-wrapper form').off('submit');
- $('#app-content-wrapper form').submit(self.onUpdateRecipe);
-
- $('#pick-image').off('click');
- $('#pick-image').click(self.onPickImage);
-
- $('#app-content-wrapper form ul + button.add-list-item').off('click');
- $('#app-content-wrapper form ul + button.add-list-item').click(self.onAddListItem);
-
- $('#app-content-wrapper form ul li input[type="text"]').off('keypress');
- $('#app-content-wrapper form ul li input[type="text"]').on('keypress', self.onListInputKeyDown);
-
- $('#app-settings [title]').tooltip('destroy');
- $('#app-settings [title]').tooltip();
-
- self.updateListItems();
-
- // View
- $('header img').click(self.onImageClick);
- $('main .instruction').click(self.onInstructionClick);
- $('.time button').click(self.onTimerToggle);
-
- nav.highlightActive();
- })
- .fail(function(e) {
- $('#app-content-wrapper').load(cookbook._baseUrl + '/error');
-
- if(e && e instanceof Error) { throw e; }
- });
- };
-
- /**
- * Event: Pick image
- */
- self.onPickImage = function(e) {
- e.preventDefault();
-
- OC.dialogs.filepicker(
- t(appName, 'Path to your Recipe Image'),
- function (path) {
- $('input[name="image"]').val(path);
- },
- false,
- ['image/jpeg', 'image/png'],
- true,
- OC.dialogs.FILEPICKER_TYPE_CHOOSE
- );
- }
-
- /**
- * Event: Delete recipe
- */
- self.onDeleteRecipe = function(e) {
- if(!confirm(t(appName, 'Are you sure you want to delete this recipe?'))) { return; }
-
- var id = e.currentTarget.dataset.id;
-
- $.ajax({
- url: cookbook._baseUrl + '/api/recipes/' + id,
- method: 'DELETE',
- })
- .done(function(html) {
- if(cookbook.getActiveId() == id) {
- location.hash = '';
- }
-
- self.render();
- nav.render();
- })
- .fail(function(e) {
- alert(t(appName, 'Failed to delete recipe'));
-
- if(e && e instanceof Error) { throw e; }
- });
- };
-
- /**
- * Event: Print recipe
- */
- self.onPrintRecipe = function(e) {
- window.print();
- };
-
- /**
- * Event: click on a recipe instruction
- */
- self.onInstructionClick = function(e) {
- $(e.target).toggleClass('done');
- }
-
- /**
- * Event: click the recipe's image
- */
- self.onImageClick = function(e) {
- $(e.target).parent().toggleClass('collapsed');
- }
-
- /**
- * Event: Toggle timer
- */
- self.onTimerToggle = function(e) {
- if($(e.target).hasClass('icon-play')) {
- var hours = parseInt($(e.target).data('hours'));
- var minutes = parseInt($(e.target).data('minutes'));
- var seconds = 0;
-
- if(
- (self.hours === undefined || self.hours === hours) &&
- (self.minutes === undefined || self.minutes === minutes)
- ) {
- self.hours = hours;
- self.minutes = minutes;
- self.seconds = 0;
- }
-
- self.timer = window.setInterval(function() {
- self.seconds--;
-
- if(self.seconds <= 0) {
- self.seconds = 59;
- self.minutes--;
- }
-
- if(self.minutes <= 0) {
- self.minutes = 59;
- self.hours--;
- }
-
- var text = '';
-
- if(self.hours < 10) { text += '0'; }
- text += self.hours + ':';
-
- if(self.minutes < 10) { text += '0'; }
- text += self.minutes + ':';
-
- if(self.seconds < 10) { text += '0'; }
- text += self.seconds;
-
- $(e.target).closest('.time').find('p').text(text);
-
- if(self.hours < 0 || self.minutes < 0) {
- self.onTimerEnd(e.target);
- }
- }, 1000);
- } else {
- window.clearInterval(self.timer);
- }
- $(e.target).toggleClass('icon-play icon-pause');
- }
-
- /**
- * Event: Timer ended
- */
- self.onTimerEnd = function(button) {
- window.clearInterval(self.timer);
- $(button).removeClass('icon-pause').addClass('icon-play');
- cookbook.notify(t(appName, 'Cooking time is up!'));
- }
-
- /**
- * Updates all lists items with click events
- */
- self.updateListItems = function(e) {
- $('#app-content-wrapper form .remove-list-item')
- .off('click')
- .click(self.onDeleteListItem);
-
- $('#app-content-wrapper form .move-list-item-up')
- .off('click')
- .click(self.onMoveListItemUp)
- .prop('disabled', false);
-
- $('#app-content-wrapper form li:first-of-type .move-list-item-up').prop('disabled', true);
-
- $('#app-content-wrapper form .move-list-item-down')
- .off('click')
- .click(self.onMoveListItemDown)
- .prop('disabled', false);
-
- $('#app-content-wrapper form li:last-of-type .move-list-item-down').prop('disabled', true);
-
- $('#app-content-wrapper form ul li input[type="text"]')
- .off('keypress')
- .on('keypress', self.onListInputKeyDown);
-
- console.log('order');
- $('#app-content-wrapper form ul').each(function() {
- var stepNumber = 1;
- $(this).find('.step-number').each(function() {
- $(this).text(stepNumber++ + '.');
- });
- });
- }
-
- /**
- * Event: Click delete list element
- */
- self.onDeleteListItem = function(e) {
- e.preventDefault();
-
- var button = e.currentTarget;
- var tools = button.parentElement;
- var listItem = tools.parentElement;
- var list = listItem.parentElement;
-
- list.removeChild(listItem);
-
- self.updateListItems();
- };
-
- /**
- * Event: Keydown on a list itme input
- */
- self.onListInputKeyDown = function(e) {
- if(e.keyCode === 13 || e.keyCode === 10) {
- e.preventDefault();
-
- var $li = $(e.currentTarget).parents('li');
- var $ul = $li.parents('ul');
-
- if($li.index() >= $ul.children('li').length) {
- self.onAddListItem(e);
-
- } else {
- $ul.children('li').eq($li.index()).find('input').focus();
-
- }
- }
- };
-
- /**
- * Event: Click add list item
- */
- self.onAddListItem = function(e) {
- e.preventDefault();
-
- var $ul = $(e.currentTarget).closest('fieldset').children('ul');
- var template = $ul.find('template').html();
- var $item = $(template);
-
- $ul.append($item);
-
- $item.find('input').focus();
-
- self.updateListItems();
- };
-
- /**
- * Event: Click move list item up
- */
- self.onMoveListItemUp = function(e) {
- e.preventDefault();
-
- var button = e.currentTarget;
- var tools = button.parentElement;
- var listItem = tools.parentElement;
-
- if(!listItem.previousElementSibling) {
- return;
- }
-
- $(listItem).insertBefore($(listItem.previousElementSibling));
-
- self.updateListItems();
- };
-
- /**
- * Event: Click move list item down
- */
- self.onMoveListItemDown = function(e) {
- e.preventDefault();
-
- var button = e.currentTarget;
- var tools = button.parentElement;
- var listItem = tools.parentElement;
-
- if(!listItem.nextElementSibling) {
- return;
- }
-
- $(listItem).insertAfter($(listItem.nextElementSibling));
-
- self.updateListItems();
- };
-
- /**
- * Event: Update recipe
- */
- self.onUpdateRecipe = function(e) {
- e.preventDefault();
-
- cookbook.update(e.currentTarget)
- .then(function(id) {
- location.hash = '/recipes/' + id;
- self.render();
- nav.render();
- })
- .fail(function(e) {
- alert(t(appName, 'Could not update recipe') + (e instanceof Error ? ': ' + e.message : ''));
-
- if(e && e instanceof Error) { throw e; }
- });
- return false;
- };
-};
-
-/**
- * The navigation view
- */
-var Nav = function (cookbook) {
- var self = this;
-
- /**
- * Event: Change recipe folder
- */
- self.onChangeRecipeFolder = function(e) {
- cookbook.setFolder(function(path) {
- e.currentTarget.value = path;
-
- self.render();
- });
- };
-
- /**
- * Event: Change recipe update interval
- */
- self.onChangeRecipeUpdateInterval = function(e) {
- cookbook.setUpdateInterval(e.currentTarget.value);
- };
-
- /**
- * Event: Change recipe update interval
- */
- self.onChangePrintImage = function(e) {
- cookbook.setPrintImage(e.currentTarget.checked);
- };
-
- /**
- * Event: Import new recipe
- */
- self.onImportRecipe = function(e) {
- e.preventDefault();
-
- var url = e.currentTarget.url.value;
-
- cookbook.import(url)
- .done(function() {
- self.render();
- })
- .fail(function(e) {
- alert(t(appName, 'Could not add recipe') + (e instanceof Error ? ': ' + e.message : ''));
-
- if(e && e instanceof Error) { throw e; }
- });
- };
-
- /**
- * Event: Pick a category
- */
- self.onCategorizeRecipes = function(e) {
- e.preventDefault();
-
- self.render();
- };
-
- /**
- * Event: Submit new search query
- */
- self.onFindRecipes = function(query) {
- if(query) {
- location.hash = '#search/' + encodeURIComponent(query);
- } else {
- location.hash = '#';
- }
-
- self.render();
- };
-
- /**
- * Event: Reindex database
- */
- self.onReindexRecipes = function(e) {
- cookbook.reindex()
- .done(function () {
- self.render();
- })
- .fail(function(e) {
- alert(t(appName, 'Could not rebuild recipe index.') + (e instanceof Error ? ': ' + e.message : ''));
-
- if(e && e instanceof Error) { throw e; }
- });
- };
-
- /**
- * Event: Clear recipe search
- */
- self.onClearRecipeSearch = function() {
- location.hash = '#';
- }
-
- /**
- * Get the current input keywords
- *
- * @return {String} Keywords
- */
- self.getKeywords = function() {
- return [self.query].join(',');
- }
-
- /**
- * Highlight the active item
- */
- self.highlightActive = function() {
- $('#app-navigation #categories a').each(function() {
- $(this).toggleClass('active', $(this).attr('href').substr(1) === location.hash.substr(1));
- });
- }
-
- /**
- * Render the view
- */
- self.render = function () {
- $.ajax({
- url: cookbook._baseUrl + '/categories',
- method: 'GET',
- })
- .done(function(json) {
- json = json || [];
-
- var html = '
' + t(appName, 'All recipes') + ' ';
-
- html += json.map(function(category) {
- var entry = '';
- entry += '';
- entry += '' + category.recipe_count + ' ';
- entry += category.name === '*' ? t(appName, 'No category') : category.name;
- entry += ' ';
- return entry;
- }).join("\n");
-
- $('#app-navigation #categories').html(html);
-
- self.highlightActive();
- })
- .fail(function(e) {
- alert(t(appName, 'Failed to fetch categories'));
-
- if(e && e instanceof Error) { throw e; }
- });
-
- // Add a new recipe
- $('#import-recipe').off('submit');
- $('#import-recipe').submit(self.onImportRecipe);
-
- // Change cache update interval
- $('#recipe-update-interval').off('change');
- $('#recipe-update-interval').change(self.onChangeRecipeUpdateInterval);
-
- // Change print image setting
- $('#recipe-print-image').off('change');
- $('#recipe-print-image').change(self.onChangePrintImage);
-
- // Change recipe folder
- $('#recipe-folder').off('change');
- $('#recipe-folder').change(self.onChangeRecipeFolder);
-
- // Categorise recipes
- $('#categorize-recipes select').off('change');
- $('#categorize-recipes select').on('change', self.onCategorizeRecipes);
-
- // Clear recipe search
- $('#clear-recipe-search').off('click');
- $('#clear-recipe-search').click(self.onClearRecipeSearch);
-
- // Reindex recipes
- $('#reindex-recipes').off('click');
- $('#reindex-recipes').click(self.onReindexRecipes);
-
- };
-
- this.search = new OCA.Search(self.onFindRecipes, self.onClearRecipeSearch);
-}
-
-var cookbook = new Cookbook(OC.generateUrl('/apps/cookbook'));
-var nav = new Nav(cookbook);
-var content = new Content(cookbook);
-
-// Render the views
-nav.render();
-content.render();
-
-// Render content view on hash change
-window.addEventListener('hashchange', content.render);
-
-});
-
-})(OC, window, jQuery);
diff --git a/lib/Controller/MainController.php b/lib/Controller/MainController.php
index 86dac6f2..e59b13ca 100755
--- a/lib/Controller/MainController.php
+++ b/lib/Controller/MainController.php
@@ -46,7 +46,7 @@ class MainController extends Controller
return new TemplateResponse($this->appName, 'index', $view_data); // templates/index.php
}
-
+
/**
* @NoAdminRequired
* @NoCSRFRequired
@@ -75,7 +75,7 @@ class MainController extends Controller
{
try {
$recipes = $this->service->getAllRecipesInSearchIndex();
-
+
foreach ($recipes as $i => $recipe) {
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
@@ -86,7 +86,7 @@ class MainController extends Controller
]
);
}
-
+
$response = new TemplateResponse($this->appName, 'content/search', ['recipes' => $recipes]);
$response->renderAs('blank');
@@ -107,7 +107,7 @@ class MainController extends Controller
return $response;
}
-
+
/**
* @NoAdminRequired
* @NoCSRFRequired
@@ -117,7 +117,7 @@ class MainController extends Controller
$query = urldecode($query);
try {
$recipes = $this->service->findRecipesInSearchIndex($query);
-
+
foreach ($recipes as $i => $recipe) {
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
@@ -128,7 +128,7 @@ class MainController extends Controller
]
);
}
-
+
$response = new TemplateResponse($this->appName, 'content/search', ['query' => $query, 'recipes' => $recipes]);
$response->renderAs('blank');
@@ -137,7 +137,7 @@ class MainController extends Controller
return new DataResponse($e->getMessage(), 500);
}
}
-
+
/**
* @NoAdminRequired
* @NoCSRFRequired
@@ -145,12 +145,10 @@ class MainController extends Controller
public function category($category)
{
$category = urldecode($category);
-
try {
$recipes = $this->service->getRecipesByCategory($category);
-
foreach ($recipes as $i => $recipe) {
- $recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute(
+ $recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image',
[
'id' => $recipe['recipe_id'],
@@ -159,11 +157,8 @@ class MainController extends Controller
]
);
}
-
- $response = new TemplateResponse($this->appName, 'content/search', ['recipes' => $recipes]);
- $response->renderAs('blank');
-
- return $response;
+
+ return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
}
@@ -187,7 +182,7 @@ class MainController extends Controller
);
$recipe['id'] = $id;
$recipe['print_image'] = $this->service->getPrintImage();
- $response = new TemplateResponse($this->appName, 'content/recipe', $recipe);
+ $response = new TemplateResponse($this->appName, 'content/recipe_vue', $recipe);
$response->renderAs('blank');
return $response;
@@ -204,7 +199,7 @@ class MainController extends Controller
{
try {
$recipe = [];
-
+
$response = new TemplateResponse($this->appName, 'content/edit', $recipe);
$response->renderAs('blank');
@@ -213,7 +208,7 @@ class MainController extends Controller
return new DataResponse($e->getMessage(), 500);
}
}
-
+
/**
* @NoAdminRequired
* @NoCSRFRequired
@@ -243,7 +238,7 @@ class MainController extends Controller
try {
$recipe_data = $_POST;
$file = $this->service->addRecipe($recipe_data);
-
+
return new DataResponse($file->getParent()->getId());
} catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500);
@@ -266,7 +261,7 @@ class MainController extends Controller
$recipe['id'] = $id;
}
-
+
$response = new TemplateResponse($this->appName, 'content/edit', $recipe);
$response->renderAs('blank');
diff --git a/lib/Controller/RecipeController.php b/lib/Controller/RecipeController.php
index 40fd4e0d..997a52be 100755
--- a/lib/Controller/RecipeController.php
+++ b/lib/Controller/RecipeController.php
@@ -46,7 +46,7 @@ class RecipeController extends Controller
$recipes = $this->service->findRecipesInSearchIndex(isset($_GET['keywords']) ? $_GET['keywords'] : '');
}
foreach ($recipes as $i => $recipe) {
- $recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb']);
+ $recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb']);
}
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
}
@@ -64,6 +64,7 @@ class RecipeController extends Controller
if (null === $json) {
return new DataResponse($id, Http::STATUS_NOT_FOUND, ['Content-Type' => 'application/json']);
}
+ $json['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $json['id'], 'size' => 'full']);
return new DataResponse($json, Http::STATUS_OK, ['Content-Type' => 'application/json']);
}
@@ -134,10 +135,10 @@ class RecipeController extends Controller
$file = $this->service->getRecipeImageFileByFolderId($id, $size);
return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=604800']);
-
+
} catch (\Exception $e) {
$file = file_get_contents(dirname(__FILE__) . '/../../img/recipe-' . $size . '.jpg');
-
+
return new DataDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg']);
}
}
diff --git a/lib/Service/RecipeService.php b/lib/Service/RecipeService.php
index 2989411d..24954801 100755
--- a/lib/Service/RecipeService.php
+++ b/lib/Service/RecipeService.php
@@ -7,6 +7,7 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Image;
use OCP\IConfig;
+use OCP\IL10N;
use OCP\Files\IRootFolder;
use OCP\Files\FileInfo;
use OCP\Files\File;
@@ -26,13 +27,15 @@ class RecipeService
private $user_id;
private $db;
private $config;
+ private $il10n;
- public function __construct(?string $UserId, IRootFolder $root, RecipeDb $db, IConfig $config)
+ public function __construct(?string $UserId, IRootFolder $root, RecipeDb $db, IConfig $config, IL10N $il10n)
{
$this->user_id = $UserId;
$this->root = $root;
$this->db = $db;
$this->config = $config;
+ $this->il10n = $il10n;
}
/**
@@ -113,8 +116,17 @@ class RecipeService
*/
public function checkRecipe(array $json): array
{
- if (!$json) { throw new Exception('Recipe array was null'); }
- if (empty($json['name'])) { throw new Exception('Field "name" is required'); }
+ if (!$json) {
+ throw new Exception('Recipe array was null');
+ }
+
+ if (empty($json['name'])) {
+ throw new Exception('Field "name" is required');
+ }
+
+ if (strpos(empty($json['name']), '/') !== false) {
+ throw new Exception('Illegal characters in recipe name');
+ }
// Make sure the schema.org fields are present
$json['@context'] = 'http://schema.org';
@@ -164,7 +176,16 @@ class RecipeService
} else {
$json['image'] = '';
}
-
+
+ // The image is a URL without a scheme, fix it
+ if (strpos($json['image'], '//') === 0) {
+ if(isset($json['url']) && strpos($json['url'], 'https') === 0) {
+ $json['image'] = 'https:' . $json['image'];
+ } else {
+ $json['image'] = 'http:' . $json['image'];
+ }
+ }
+
// Clean up the image URL string
$json['image'] = stripslashes($json['image']);
@@ -337,6 +358,7 @@ class RecipeService
$json['url'] = "";
}
+ // Parse duration fields
$durations = ['prepTime', 'cookTime', 'totalTime'];
$duration_patterns = [
'/P.*T(\d+H)?(\d+M)?/', // ISO 8601
@@ -369,6 +391,11 @@ class RecipeService
}
}
+ while($duration_minutes >= 60) {
+ $duration_minutes -= 60;
+ $duration_hours++;
+ }
+
$json[$duration] = 'PT' . $duration_hours . 'H' . $duration_minutes . 'M';
}
@@ -419,6 +446,11 @@ class RecipeService
}
}
+ // Check if json is an array for some reason
+ if($json && isset($json[0])) {
+ $json = $json[0];
+ }
+
if (!$json || !isset($json['@type']) || $json['@type'] !== 'Recipe') {
continue;
}
@@ -463,7 +495,18 @@ class RecipeService
if(!isset($json[$prop]) || !is_array($json[$prop])) { $json[$prop] = []; }
- $src = $prop_element->getAttribute('src');
+ if(!empty($prop_element->getAttribute('src'))) {
+ $src = $prop_element->getAttribute('src');
+
+ } else if(
+ null !== $prop_element->getAttributeNode('content') &&
+ !empty($prop_element->getAttributeNode('content')->value)
+ ) {
+ $src = $prop_element->getAttributeNode('content')->value;
+
+ } else {
+ break;
+ }
array_push($json[$prop], $src);
break;
@@ -474,7 +517,10 @@ class RecipeService
if(!isset($json[$prop]) || !is_array($json[$prop])) { $json[$prop] = []; }
- if(null !== $prop_element->getAttributeNode('content')) {
+ if(
+ null !== $prop_element->getAttributeNode('content') &&
+ !empty($prop_element->getAttributeNode('content')->value)
+ ) {
array_push($json[$prop], $prop_element->getAttributeNode('content')->value);
} else {
array_push($json[$prop], $prop_element->nodeValue);
@@ -500,7 +546,7 @@ class RecipeService
}
}
}
-
+
// Make one final desparate attempt at getting the instructions
if (!isset($json['recipeInstructions']) || !$json['recipeInstructions'] || sizeof($json['recipeInstructions']) < 1) {
$json['recipeInstructions'] = [];
@@ -612,6 +658,16 @@ class RecipeService
}
}
+
+ // The image field was empty, remove images in the recipe folder
+ } else {
+ if($recipe_folder->nodeExists('full.jpg')) {
+ $recipe_folder->get('full.jpg')->delete();
+ }
+
+ if($recipe_folder->nodeExists('thumb.jpg')) {
+ $recipe_folder->get('thumb.jpg')->delete();
+ }
}
// If image data was fetched, write it to disk
@@ -640,6 +696,14 @@ class RecipeService
$thumb_image_file->putContent($thumb_image->data());
}
+ // Write .nomedia file to avoid gallery indexing
+ if(!$recipe_folder->nodeExists('.nomedia')) {
+ $recipe_folder->newFile('.nomedia');
+ }
+
+ // Make sure the directory has been marked as changed
+ $recipe_folder->touch();
+
return $recipe_file;
}
@@ -867,7 +931,7 @@ class RecipeService
$path = $this->config->getUserValue($this->user_id, 'cookbook', 'folder');
if (!$path) {
- $path = '/Recipes';
+ $path = '/' . $this->il10n->t('Recipes');
}
return $path;
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..b43937c8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "nextcloud-cookbook",
+ "version": "0.7.0",
+ "description": "",
+ "main": "src/main.js",
+ "scripts": {
+ "build": "node node_modules/webpack/bin/webpack.js --progress --hide-modules --config webpack.build.js",
+ "dev": "node node_modules/webpack/bin/webpack.js --progress --watch --config webpack.devel.js",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/mrzapp/nextcloud-cookbook.git"
+ },
+ "author": "",
+ "license": "AGPL-3.0-or-later",
+ "bugs": {
+ "url": "https://github.com/mrzapp/nextcloud-cookbook/issues"
+ },
+ "homepage": "https://github.com/mrzapp/nextcloud-cookbook#readme",
+ "dependencies": {
+ "@nextcloud/event-bus": "^1.1.4",
+ "@nextcloud/vue": "^1.5.0",
+ "vue": "^2.6.11",
+ "vue-i18n": "^8.17.1",
+ "vue-router": "^3.1.6",
+ "vuex": "^3.1.3"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.9.0",
+ "babel-loader": "^8.1.0",
+ "css-loader": "^3.5.2",
+ "file-loader": "^6.0.0",
+ "url-loader": "^4.1.0",
+ "vue-loader": "^15.9.1",
+ "vue-style-loader": "^4.1.2",
+ "vue-template-compiler": "^2.6.11",
+ "vue-template-loader": "^1.1.0",
+ "webpack": "^4.42.1",
+ "webpack-cli": "^3.3.11",
+ "webpack-merge": "^4.2.2"
+ }
+}
diff --git a/src/components/AppControls.vue b/src/components/AppControls.vue
new file mode 100644
index 00000000..5e55a99f
--- /dev/null
+++ b/src/components/AppControls.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/AppIndex.vue b/src/components/AppIndex.vue
new file mode 100644
index 00000000..8a5a0a04
--- /dev/null
+++ b/src/components/AppIndex.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+ {{ recipe.name }}
+
+
+
+
+
+
+
+
diff --git a/src/components/AppMain.vue b/src/components/AppMain.vue
new file mode 100644
index 00000000..cd4cdd6f
--- /dev/null
+++ b/src/components/AppMain.vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
diff --git a/src/components/AppNavi.vue b/src/components/AppNavi.vue
new file mode 100644
index 00000000..d9c6e43e
--- /dev/null
+++ b/src/components/AppNavi.vue
@@ -0,0 +1,356 @@
+
+
+
+
+
+
+
+
+ {{ t('Recipe URL') }}
+
+
+ {{ totalRecipeCount }}
+
+
+ {{ cat.recipeCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/EditImageField.vue b/src/components/EditImageField.vue
new file mode 100644
index 00000000..a6ed23cb
--- /dev/null
+++ b/src/components/EditImageField.vue
@@ -0,0 +1,85 @@
+
+
+
+ {{ fieldLabel }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/EditInputField.vue b/src/components/EditInputField.vue
new file mode 100644
index 00000000..c012ccda
--- /dev/null
+++ b/src/components/EditInputField.vue
@@ -0,0 +1,53 @@
+
+
+
+ {{ fieldLabel }}
+
+
+
+
+
+
+
+
diff --git a/src/components/EditInputGroup.vue b/src/components/EditInputGroup.vue
new file mode 100644
index 00000000..13def7fb
--- /dev/null
+++ b/src/components/EditInputGroup.vue
@@ -0,0 +1,181 @@
+
+
+ {{ fieldLabel }}
+
+ {{ t('Add') }}
+
+
+
+
+
+
diff --git a/src/components/EditTimeField.vue b/src/components/EditTimeField.vue
new file mode 100644
index 00000000..4307e936
--- /dev/null
+++ b/src/components/EditTimeField.vue
@@ -0,0 +1,60 @@
+
+
+ {{ fieldLabel }}
+
+ :
+
+
+
+
+
+
+
diff --git a/src/components/NotFound.vue b/src/components/NotFound.vue
new file mode 100644
index 00000000..d536fcdc
--- /dev/null
+++ b/src/components/NotFound.vue
@@ -0,0 +1,20 @@
+
+ {{ t('The page was not found') }}
+
+
+
+
+
diff --git a/src/components/RecipeEdit.vue b/src/components/RecipeEdit.vue
new file mode 100644
index 00000000..d790b3f4
--- /dev/null
+++ b/src/components/RecipeEdit.vue
@@ -0,0 +1,282 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/RecipeImages.vue b/src/components/RecipeImages.vue
new file mode 100644
index 00000000..8063d129
--- /dev/null
+++ b/src/components/RecipeImages.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/RecipeIngredient.vue b/src/components/RecipeIngredient.vue
new file mode 100644
index 00000000..21498674
--- /dev/null
+++ b/src/components/RecipeIngredient.vue
@@ -0,0 +1,47 @@
+
+ {{ displayIngredient }}
+
+
+
+
+
diff --git a/src/components/RecipeInstruction.vue b/src/components/RecipeInstruction.vue
new file mode 100644
index 00000000..d19a858d
--- /dev/null
+++ b/src/components/RecipeInstruction.vue
@@ -0,0 +1,67 @@
+
+ {{ instruction }}
+
+
+
+
+
diff --git a/src/components/RecipeTimer.vue b/src/components/RecipeTimer.vue
new file mode 100644
index 00000000..80950d38
--- /dev/null
+++ b/src/components/RecipeTimer.vue
@@ -0,0 +1,140 @@
+
+
+
+
+
{{ t(label) }}
+
{{ displayTime }}
+
+
+
+
+
+
+
diff --git a/src/components/RecipeTool.vue b/src/components/RecipeTool.vue
new file mode 100644
index 00000000..e68ea24c
--- /dev/null
+++ b/src/components/RecipeTool.vue
@@ -0,0 +1,19 @@
+
+ {{ tool }}
+
+
+
+
+
diff --git a/src/components/RecipeView.vue b/src/components/RecipeView.vue
new file mode 100644
index 00000000..4eefe1d5
--- /dev/null
+++ b/src/components/RecipeView.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
+
+
{{ $store.state.recipe.name }}
+
+
+
{{ $store.state.recipe.description }}
+
+ {{ t('Source') }}: {{ $store.state.recipe.url }}
+
+
{{ t('Servings') }}: {{ $store.state.recipe.recipeYield }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('Ingredients') }}
+
+
+
+
+
+
+ {{ t('Instructions') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/SearchResults.vue b/src/components/SearchResults.vue
new file mode 100644
index 00000000..d0656ef4
--- /dev/null
+++ b/src/components/SearchResults.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+ {{ result.name }}
+
+
+
+
+
+
+
+
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 00000000..38bdd7d6
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,189 @@
+/**
+ * Nextcloud Cookbook app
+ * Vue frontend entry file
+ * ---------------------------
+ * @license AGPL3 or later
+*/
+
+// TODO: Agree on a markdown parser
+// TODO: Remove dependency on jQuery
+
+import Vue from 'vue'
+import router from './router'
+import store from './store'
+
+//import AppNavi from './components/AppNavi'
+import AppMain from './components/AppMain'
+
+(function (OC, window, $, undefined) {
+ 'use strict'
+
+ // Fetch Nextcloud nonce identifier for dynamic script loading
+ __webpack_nonce__ = btoa(OC.requestToken)
+
+ window.baseUrl = OC.generateUrl('apps/cookbook')
+
+ // Check if two routes point to the same component but have different content
+ window.shouldReloadContent = function(url1, url2) {
+ if (url1 === url2) {
+ return false // Obviously should not if both routes are the same
+ }
+
+ let comps1 = url1.split('/')
+ let comps2 = url2.split('/')
+
+ if (comps1.length < 2 || comps2.length < 2) {
+ return false // Just a failsafe, this should never happen
+ }
+
+ // The route structure is as follows:
+ // - /{item}/:id View
+ // - /{item}/:id/edit Edit
+ // - /{item}/create Create
+ // If the items are different, then the router automatically handles
+ // component loading: do not manually reload
+ if (comps1[1] !== comps2[1]) {
+ return false
+ }
+
+ // If one of the routes is edit and the other is not
+ if (comps1.length !== comps2.length) {
+ // Only reload if changing from edit to create
+ if (comps1.pop() === 'create' || comps2.pop() === 'create') {
+ return true
+ }
+
+ return false
+
+ } else if (comps1.pop() === 'create') {
+ // But, if we are moving from create to view, do not reload
+ // the create component
+ return false
+
+ }
+
+ // Only options left are that both of the routes are edit or view,
+ // but not identical, or that we're moving from view to create
+ // -> reload view
+ return true
+ }
+
+ // Check if the two urls point to the same item instance
+ window.isSameItemInstance = function(url1, url2) {
+ if (url1 === url2) {
+ return true // Obviously true if the routes are the same
+ }
+ let comps1 = url1.split('/')
+ let comps2 = url2.split('/')
+ if (comps1.length < 2 || comps2.length < 2) {
+ return false // Just a failsafe, this should never happen
+ }
+ // If the items are different, then the item instance cannot be
+ // the same either
+ if (comps1[1] !== comps2[1]) {
+ return false
+ }
+ if (comps1.length < 3 || comps2.length < 3) {
+ // ID is the third url component, so can't be the same instance if
+ // either of the urls have less than three components
+ return false
+ }
+ if (comps1[2] !== comps2[2]) {
+ // Different IDs, not same instance
+ return false
+ }
+ return true
+ }
+
+ // A simple function to sanitize HTML tags
+ window.escapeHTML = function(text) {
+ return text.replace(/[\"&'\/<>]/g, function (a) {
+ return {
+ '&': '&',
+ '"': '"',
+ "'": ''',
+ '<': '<',
+ '>': '>'
+ }[a]
+ })
+ }
+
+ // Fix the decimal separator for languages that use a comma instead of dot
+ window.fixDecimalSeparator = function(value, io) {
+ // value is the string value of the number to process
+ // io is either 'i' as in input or 'o' as in output
+ if (!value) {
+ return ''
+ }
+ if (io === 'i') {
+ // Check if it's an American number where a comma precedes a dot
+ // e.g. 12,500.25
+ if (value.indexOf('.') > value.indexOf(',')) {
+ return value.replace(',', '')
+ } else {
+ return value.replace(',', '.')
+ }
+ } else if (io === 'o') {
+ return value.toString().replace('.', ',')
+ }
+ }
+
+ // This will replace the PHP function nl2br in Vue components
+ window.nl2br = function(text) {
+ return text.replace(/\n/g, ' ')
+ }
+
+ // A simple function that converts a MySQL datetime into a timestamp.
+ window.getTimestamp = function(date) {
+ if (date) {
+ return new Date(date)
+ } else {
+ return null
+ }
+ }
+
+ // Push a new URL to the router, essentially navigating to that page.
+ window.goTo = function(url) {
+ router.push(url)
+ }
+
+ // Notify the user if notifications are allowed
+ window.notify = function notify(title, options) {
+ if (!('Notification' in window)) {
+ return
+ } else if (Notification.permission === "granted") {
+ var notification = new Notification(title, options)
+ } else if (Notification.permission !== 'denied') {
+ Notification.requestPermission(function(permission) {
+ if (!('permission' in Notification)) {
+ Notification.permission = permission
+ }
+ if (permission === "granted") {
+ var notification = new Notification(title, options)
+ } else {
+ alert(title)
+ }
+ })
+ }
+ }
+
+ // Also make the injections available in Vue components
+ Vue.prototype.$window = window
+ Vue.prototype.OC = OC
+
+ // Make translations easier by automatically providing the app name
+ let tx = function(text) {
+ return window.t('cookbook', text)
+ }
+
+ Vue.prototype.t = tx
+
+ // Start the app once document is done loading
+ $(document).ready(function () {
+ const App = Vue.extend(AppMain)
+ new App({
+ store,
+ router,
+ }).$mount("#app")
+ })
+})(OC, window, jQuery)
diff --git a/src/router/index.js b/src/router/index.js
new file mode 100644
index 00000000..800295bc
--- /dev/null
+++ b/src/router/index.js
@@ -0,0 +1,54 @@
+/**
+ * Nextcloud Cookbook app
+ * Vue router module
+ * ----------------------
+ * @license AGPL3 or later
+ */
+import Vue from 'vue'
+import VueRouter from 'vue-router'
+
+import Index from '../components/AppIndex'
+import NotFound from '../components/NotFound'
+import RecipeView from '../components/RecipeView'
+import RecipeEdit from '../components/RecipeEdit'
+import Search from '../components/SearchResults'
+
+Vue.use(VueRouter)
+
+// The router will try to match routers in a descending order.
+// Routes that share the same root, must be listed from the
+// most descriptive to the least descriptive, e.g.
+// /section/component/subcomponent/edit/:id
+// /section/component/subcomponent/new
+// /section/component/subcomponent/:id
+// /section/component/:id
+// /section/:id
+const routes = [
+ // Search routes
+ { path: '/category/:value', name: 'search-category', component: Search, props: { query: 'cat' } },
+ { path: '/name/:value', name: 'search-name', component: Search, props: { query: 'name' } },
+ { path: '/tag/:value', name: 'search-tag', component: Search, props: { query: 'tag' } },
+
+ // Recipe routes
+ // Vue router has a strange way of determining when it renders a component again and when not.
+ // In essence, when two routes point to the same component, it usually will not be re-rendered
+ // automatically. If the contents change (e.g. between /recipe/xxx and /recipe/yyy) this must
+ // be checked for and the component re-rendered manually. In order to avoid the need to write
+ // separate checks for different item types, all items MUST follow this route convention:
+ // - View: /{item}/:id
+ // - Edit: /{item}/:id/edit
+ // - Create: /{item}/create
+ { path: '/recipe/create', name: 'recipe-create', component: RecipeEdit },
+ { path: '/recipe/:id/edit', name: 'recipe-edit', component: RecipeEdit },
+ { path: '/recipe/:id', name: 'recipe-view', component: RecipeView },
+
+ // Index is the last defined route
+ { path: '/', name:'index', component: Index },
+
+ // Anything not matched goes to NotFound
+ { path: '*', name:'not-found', component: NotFound },
+];
+
+export default new VueRouter({
+ routes
+})
diff --git a/src/store/index.js b/src/store/index.js
new file mode 100644
index 00000000..247c8339
--- /dev/null
+++ b/src/store/index.js
@@ -0,0 +1,82 @@
+/**
+ * Nextcloud Cookbook app
+ * Vuex store module
+ * ----------------------
+ * @license AGPL3 or later
+ */
+import Vue from 'vue'
+import Vuex from 'vuex'
+
+Vue.use(Vuex)
+
+// We are using the vuex store linking changes within the components to updates in the navigation panel.
+export default new Vuex.Store({
+ // Vuex store handles value changes through actions and mutations.
+ // From the App, you trigger an action, that changes the store
+ // state through a set mutation. You can process the data within
+ // the mutation if you want.
+ state: {
+ user: null,
+ // Page is for keeping track of the page the user is on and
+ // setting the appropriate navigation entry active.
+ page: null,
+ // We'll save the recipe here, since the data is used by
+ // several independent components
+ recipe: null,
+ // Loading and saving states to determine which loader icons to show.
+ // State of -1 is reserved for recipe and edit views to be set when the
+ // User loads the app at one of these locations and has to wait for an
+ // asynchronous recipe loading.
+ loadingRecipe: 0,
+ // This is used if when a recipe is reloaded in edit or view
+ reloadingRecipe: 0,
+ // A recipe save is in progress
+ savingRecipe: false,
+ },
+
+ mutations: {
+ setLoadingRecipe(s, { r }) {
+ s.loadingRecipe = r
+ },
+ setPage(s, { p }) {
+ s.page = p
+ },
+ setRecipe(s, { r }) {
+ s.recipe = r
+ // Setting recipe also means that loading/reloading the recipe has finished
+ s.loadingRecipe = 0
+ s.reloadingRecipe = 0
+ },
+ setReloadingRecipe(s, { r }) {
+ s.reloadingRecipe = r
+ },
+ setSavingRecipe(s, { b }) {
+ s.savingRecipe = b
+ },
+ setUser(s, { u }) {
+ s.user = u
+ }
+ },
+
+ actions: {
+ setLoadingRecipe(c, { recipe }) {
+ c.commit('setLoadingRecipe', { r: parseInt(recipe) })
+ },
+ setPage(c, { page }) {
+ c.commit('setPage', { p: page })
+ },
+ setRecipe(c, { recipe }) {
+ c.commit('setRecipe', { r: recipe })
+ },
+ setReloadingRecipe(c, { recipe }) {
+ c.commit('setReloadingRecipe', { r: parseInt(recipe) })
+ },
+ setSavingRecipe(c, { saving }) {
+ c.commit('setSavingRecipe', { b: saving })
+ },
+ setUser(c, { user }) {
+ c.commit('setUser', { u: user })
+ },
+ }
+
+})
diff --git a/templates/content/edit.php b/templates/content/edit.php
deleted file mode 100755
index 3454d74f..00000000
--- a/templates/content/edit.php
+++ /dev/null
@@ -1,179 +0,0 @@
-
diff --git a/templates/content/recipe.php b/templates/content/recipe.php
deleted file mode 100755
index 5462ee96..00000000
--- a/templates/content/recipe.php
+++ /dev/null
@@ -1,125 +0,0 @@
-
-
-
-
-
-
- t('Print recipe')); ?>
-
-
-
-
-
- t('Delete recipe')); ?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
t('Source')); ?>:
-
-
-
t('Servings')); ?>:
-
-
- format('%I');
- $prep_hours = $prep_interval->format('%h');
- if ($prep_hours > 0 || $prep_mins > 0) {
- ?>
-
-
t('Preparation time')); ?>
-
-
-
-
- format('%I');
- $cook_hours = $cook_interval->format('%h');
- if ($cook_hours > 0 || $cook_mins > 0) {
- ?>
-
-
-
t('Cooking time')); ?>
-
-
-
-
- format('%I');
- $total_hours = $total_interval->format('%h');
- if ($total_hours > 0 || $total_mins > 0) {
- ?>
-
-
t('Total time')); ?>
-
-
-
-
-
-
-
-
-
-
-
- t('Ingredients')); ?>
-
-
-
-
-
-
- t('Tools')); ?>
-
-
-
-
-
-
-
- t('Instructions')); ?>
-
-
-
-
-
-
-
-
diff --git a/templates/content/search.php b/templates/content/search.php
deleted file mode 100644
index 11dfe7da..00000000
--- a/templates/content/search.php
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
- t('Search')); ?>
-
- t('Tag')); ?>
-
- t('Category')); ?>
-
- t('All recipes')); ?>
-
-
-
-
- t('No results')); ?>
-
-
-
-
-
diff --git a/templates/index.php b/templates/index.php
index 9098ecdc..74bba7c4 100755
--- a/templates/index.php
+++ b/templates/index.php
@@ -1,20 +1,7 @@
-
- inc('navigation/index')); ?>
- inc('settings/index')); ?>
-
-
-
-
diff --git a/templates/navigation/error.php b/templates/navigation/error.php
deleted file mode 100644
index 772f9970..00000000
--- a/templates/navigation/error.php
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
t('Error')); ?>
-
- t('This page doesn\'t exist.')); ?>
-
-
diff --git a/templates/navigation/index.php b/templates/navigation/index.php
deleted file mode 100755
index 7b2690ed..00000000
--- a/templates/navigation/index.php
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/templates/navigation/recipes.php b/templates/navigation/recipes.php
deleted file mode 100755
index c163f6f6..00000000
--- a/templates/navigation/recipes.php
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/templates/settings/index.php b/templates/settings/index.php
deleted file mode 100755
index 60d24cf4..00000000
--- a/templates/settings/index.php
+++ /dev/null
@@ -1,35 +0,0 @@
-
diff --git a/webpack.build.js b/webpack.build.js
new file mode 100644
index 00000000..fe27aa89
--- /dev/null
+++ b/webpack.build.js
@@ -0,0 +1,7 @@
+const merge = require('webpack-merge')
+const common = require('./webpack.config.js')
+
+module.exports = merge(common, {
+ mode: 'production',
+ devtool: 'source-map',
+})
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 00000000..822545f5
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,65 @@
+/**
+ * Nextcloud Cookbook app
+ * Main Webpack configuration file.
+ * Different configurations for development and build runs
+ * are located in the appropriate files.
+ */
+const path = require('path')
+const { VueLoaderPlugin } = require('vue-loader')
+
+module.exports = {
+
+ entry:{
+ vue: path.join(__dirname, 'src', 'main.js'),
+ },
+ output: {
+ path: path.resolve(__dirname, './js'),
+ publicPath: '/js/',
+ filename: '[name].js',
+ chunkFilename: '[name].js?v=[contenthash]',
+ },
+ module: {
+ rules: [
+ {
+ test: /\.css$/,
+ use: ['vue-style-loader', 'css-loader'],
+ },
+ {
+ test: /\.html$/,
+ loader: 'vue-template-loader',
+ },
+ {
+ test: /\.vue$/,
+ loader: 'vue-loader',
+ },
+ {
+ test: /\.js$/,
+ loader: 'babel-loader',
+ exclude: /node_modules/,
+ },
+ {
+ test: /\.(png|jpg|gif)$/,
+ loader: 'file-loader',
+ options: {
+ name: '[name].[ext]?[hash]'
+ },
+ },
+ {
+ test: /\.(eot|woff|woff2|ttf|svg)$/,
+ loaders: 'file-loader',
+ options: {
+ name: '[path][name].[ext]?[hash]'
+ },
+ },
+ ],
+ },
+ plugins: [new VueLoaderPlugin()],
+ resolve: {
+ extensions: ['*', '.js', '.vue', '.json'],
+ modules: [
+ path.resolve(__dirname, './node_modules')
+ ],
+ symlinks: false,
+ },
+
+}
diff --git a/webpack.devel.js b/webpack.devel.js
new file mode 100644
index 00000000..df8512a4
--- /dev/null
+++ b/webpack.devel.js
@@ -0,0 +1,7 @@
+const merge = require('webpack-merge')
+const common = require('./webpack.config.js')
+
+module.exports = merge(common, {
+ mode: 'development',
+ devtool: 'inline-cheap-source-map',
+})