This commit is contained in:
mrzapp 2020-06-01 13:08:11 +02:00
Родитель 915339ade1 f87509da98
Коммит 43ec32917c
40 изменённых файлов: 2654 добавлений и 1477 удалений

10
.gitignore поставляемый
Просмотреть файл

@ -4,3 +4,13 @@
cookbook.tar.gz cookbook.tar.gz
.idea/ .idea/
# Built js package
js/*
### NPM ###
# Node modules
node_modules
# Package lock
package-lock.json

Просмотреть файл

@ -5,7 +5,7 @@
<name>Cookbook</name> <name>Cookbook</name>
<summary>An integrated cookbook using schema.org JSON files as recipes</summary> <summary>An integrated cookbook using schema.org JSON files as recipes</summary>
<description><![CDATA[A library for all your recipes. It uses JSON files following the schema.org recipe format. To add a recipe to the collection, you can paste in the URL of the recipe, and the provided web page will be parsed and downloaded to whichever folder you specify in the app settings.]]></description> <description><![CDATA[A library for all your recipes. It uses JSON files following the schema.org recipe format. To add a recipe to the collection, you can paste in the URL of the recipe, and the provided web page will be parsed and downloaded to whichever folder you specify in the app settings.]]></description>
<version>0.6.5</version> <version>0.7.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author mail="mrzapp@users.noreply.github.com" >Jeppe Zapp</author> <author mail="mrzapp@users.noreply.github.com" >Jeppe Zapp</author>
<namespace>Cookbook</namespace> <namespace>Cookbook</namespace>

Просмотреть файл

@ -25,6 +25,7 @@ return [
['name' => 'recipe#image', 'url' => '/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+']], ['name' => 'recipe#image', 'url' => '/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+']],
['name' => 'config#reindex', 'url' => '/reindex', 'verb' => 'POST'], ['name' => 'config#reindex', 'url' => '/reindex', 'verb' => 'POST'],
['name' => 'config#config', 'url' => '/config', 'verb' => 'POST'], ['name' => 'config#config', 'url' => '/config', 'verb' => 'POST'],
/* API routes */
], ],
'resources' => [ 'resources' => [
'recipe' => ['url' => '/api/recipes'] 'recipe' => ['url' => '/api/recipes']

Просмотреть файл

@ -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 * Main
*/ */
#app { #app {
width: 100%; width: 100%;
} }
/** .app-navigation-new button {
* Navigation min-height: 44px !important;
*/ background-image: var(--icon-add-000) !important;
#app-navigation {} background-repeat: no-repeat !important;
#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-entry *:not(.app-navigation-entry-icon) {
#app-content-wrapper { background: initial !important;
flex-wrap: wrap; }
.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 { .home {
padding: 1rem; padding: 1rem;
@ -437,122 +284,14 @@
width: 100%; width: 100%;
}} }}
#app-content-wrapper form fieldset > input[name="image"] { .app-navigation-entry:not(:hover) li.recipe {
width: calc(100% - 14em); box-shadow: inset 4px 0 rgba(255, 255, 255, 0.5);
border-top-right-radius: 0; }
border-bottom-right-radius: 0; .app-navigation-entry:hover li.recipe {
border-right: 0; box-shadow: inset 4px 0 rgba(255, 255, 255, 1);
} }
@media(max-width:1199px) { #app-content-wrapper form fieldset > input[name="image"] {
width: calc(100% - 3em);
}}
#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 { @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:link:after,
a:visited:after { a:visited:after {
content:" [" attr(href) "] "; content:" [" attr(href) "] ";

Просмотреть файл

@ -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 = '<li class="icon-category-organization"><a href="#">' + t(appName, 'All recipes') + '</a></li>';
html += json.map(function(category) {
var entry = '<li class="icon-category-files">';
entry += '<a href="#category/' + encodeURIComponent(category.name) + '">';
entry += '<span class="pull-right">' + category.recipe_count + '</span>';
entry += category.name === '*' ? t(appName, 'No category') : category.name;
entry += '</a></li>';
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);

Просмотреть файл

@ -46,7 +46,7 @@ class MainController extends Controller
return new TemplateResponse($this->appName, 'index', $view_data); // templates/index.php return new TemplateResponse($this->appName, 'index', $view_data); // templates/index.php
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
* @NoCSRFRequired * @NoCSRFRequired
@ -75,7 +75,7 @@ class MainController extends Controller
{ {
try { try {
$recipes = $this->service->getAllRecipesInSearchIndex(); $recipes = $this->service->getAllRecipesInSearchIndex();
foreach ($recipes as $i => $recipe) { foreach ($recipes as $i => $recipe) {
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute( $recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image', 'cookbook.recipe.image',
@ -86,7 +86,7 @@ class MainController extends Controller
] ]
); );
} }
$response = new TemplateResponse($this->appName, 'content/search', ['recipes' => $recipes]); $response = new TemplateResponse($this->appName, 'content/search', ['recipes' => $recipes]);
$response->renderAs('blank'); $response->renderAs('blank');
@ -107,7 +107,7 @@ class MainController extends Controller
return $response; return $response;
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
* @NoCSRFRequired * @NoCSRFRequired
@ -117,7 +117,7 @@ class MainController extends Controller
$query = urldecode($query); $query = urldecode($query);
try { try {
$recipes = $this->service->findRecipesInSearchIndex($query); $recipes = $this->service->findRecipesInSearchIndex($query);
foreach ($recipes as $i => $recipe) { foreach ($recipes as $i => $recipe) {
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute( $recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image', 'cookbook.recipe.image',
@ -128,7 +128,7 @@ class MainController extends Controller
] ]
); );
} }
$response = new TemplateResponse($this->appName, 'content/search', ['query' => $query, 'recipes' => $recipes]); $response = new TemplateResponse($this->appName, 'content/search', ['query' => $query, 'recipes' => $recipes]);
$response->renderAs('blank'); $response->renderAs('blank');
@ -137,7 +137,7 @@ class MainController extends Controller
return new DataResponse($e->getMessage(), 500); return new DataResponse($e->getMessage(), 500);
} }
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
* @NoCSRFRequired * @NoCSRFRequired
@ -145,12 +145,10 @@ class MainController extends Controller
public function category($category) public function category($category)
{ {
$category = urldecode($category); $category = urldecode($category);
try { try {
$recipes = $this->service->getRecipesByCategory($category); $recipes = $this->service->getRecipesByCategory($category);
foreach ($recipes as $i => $recipe) { foreach ($recipes as $i => $recipe) {
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute( $recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
'cookbook.recipe.image', 'cookbook.recipe.image',
[ [
'id' => $recipe['recipe_id'], 'id' => $recipe['recipe_id'],
@ -159,11 +157,8 @@ class MainController extends Controller
] ]
); );
} }
$response = new TemplateResponse($this->appName, 'content/search', ['recipes' => $recipes]); return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
$response->renderAs('blank');
return $response;
} catch (\Exception $e) { } catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500); return new DataResponse($e->getMessage(), 500);
} }
@ -187,7 +182,7 @@ class MainController extends Controller
); );
$recipe['id'] = $id; $recipe['id'] = $id;
$recipe['print_image'] = $this->service->getPrintImage(); $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'); $response->renderAs('blank');
return $response; return $response;
@ -204,7 +199,7 @@ class MainController extends Controller
{ {
try { try {
$recipe = []; $recipe = [];
$response = new TemplateResponse($this->appName, 'content/edit', $recipe); $response = new TemplateResponse($this->appName, 'content/edit', $recipe);
$response->renderAs('blank'); $response->renderAs('blank');
@ -213,7 +208,7 @@ class MainController extends Controller
return new DataResponse($e->getMessage(), 500); return new DataResponse($e->getMessage(), 500);
} }
} }
/** /**
* @NoAdminRequired * @NoAdminRequired
* @NoCSRFRequired * @NoCSRFRequired
@ -243,7 +238,7 @@ class MainController extends Controller
try { try {
$recipe_data = $_POST; $recipe_data = $_POST;
$file = $this->service->addRecipe($recipe_data); $file = $this->service->addRecipe($recipe_data);
return new DataResponse($file->getParent()->getId()); return new DataResponse($file->getParent()->getId());
} catch (\Exception $e) { } catch (\Exception $e) {
return new DataResponse($e->getMessage(), 500); return new DataResponse($e->getMessage(), 500);
@ -266,7 +261,7 @@ class MainController extends Controller
$recipe['id'] = $id; $recipe['id'] = $id;
} }
$response = new TemplateResponse($this->appName, 'content/edit', $recipe); $response = new TemplateResponse($this->appName, 'content/edit', $recipe);
$response->renderAs('blank'); $response->renderAs('blank');

Просмотреть файл

@ -46,7 +46,7 @@ class RecipeController extends Controller
$recipes = $this->service->findRecipesInSearchIndex(isset($_GET['keywords']) ? $_GET['keywords'] : ''); $recipes = $this->service->findRecipesInSearchIndex(isset($_GET['keywords']) ? $_GET['keywords'] : '');
} }
foreach ($recipes as $i => $recipe) { 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']); return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
} }
@ -64,6 +64,7 @@ class RecipeController extends Controller
if (null === $json) { if (null === $json) {
return new DataResponse($id, Http::STATUS_NOT_FOUND, ['Content-Type' => 'application/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']); 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); $file = $this->service->getRecipeImageFileByFolderId($id, $size);
return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=604800']); return new FileDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg', 'Cache-Control' => 'public, max-age=604800']);
} catch (\Exception $e) { } catch (\Exception $e) {
$file = file_get_contents(dirname(__FILE__) . '/../../img/recipe-' . $size . '.jpg'); $file = file_get_contents(dirname(__FILE__) . '/../../img/recipe-' . $size . '.jpg');
return new DataDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg']); return new DataDisplayResponse($file, Http::STATUS_OK, ['Content-Type' => 'image/jpeg']);
} }
} }

Просмотреть файл

@ -7,6 +7,7 @@ use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException; use OCP\Files\NotPermittedException;
use OCP\Image; use OCP\Image;
use OCP\IConfig; use OCP\IConfig;
use OCP\IL10N;
use OCP\Files\IRootFolder; use OCP\Files\IRootFolder;
use OCP\Files\FileInfo; use OCP\Files\FileInfo;
use OCP\Files\File; use OCP\Files\File;
@ -26,13 +27,15 @@ class RecipeService
private $user_id; private $user_id;
private $db; private $db;
private $config; 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->user_id = $UserId;
$this->root = $root; $this->root = $root;
$this->db = $db; $this->db = $db;
$this->config = $config; $this->config = $config;
$this->il10n = $il10n;
} }
/** /**
@ -113,8 +116,17 @@ class RecipeService
*/ */
public function checkRecipe(array $json): array public function checkRecipe(array $json): array
{ {
if (!$json) { throw new Exception('Recipe array was null'); } if (!$json) {
if (empty($json['name'])) { throw new Exception('Field "name" is required'); } 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 // Make sure the schema.org fields are present
$json['@context'] = 'http://schema.org'; $json['@context'] = 'http://schema.org';
@ -164,7 +176,16 @@ class RecipeService
} else { } else {
$json['image'] = ''; $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 // Clean up the image URL string
$json['image'] = stripslashes($json['image']); $json['image'] = stripslashes($json['image']);
@ -337,6 +358,7 @@ class RecipeService
$json['url'] = ""; $json['url'] = "";
} }
// Parse duration fields
$durations = ['prepTime', 'cookTime', 'totalTime']; $durations = ['prepTime', 'cookTime', 'totalTime'];
$duration_patterns = [ $duration_patterns = [
'/P.*T(\d+H)?(\d+M)?/', // ISO 8601 '/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'; $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') { if (!$json || !isset($json['@type']) || $json['@type'] !== 'Recipe') {
continue; continue;
} }
@ -463,7 +495,18 @@ class RecipeService
if(!isset($json[$prop]) || !is_array($json[$prop])) { $json[$prop] = []; } 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); array_push($json[$prop], $src);
break; break;
@ -474,7 +517,10 @@ class RecipeService
if(!isset($json[$prop]) || !is_array($json[$prop])) { $json[$prop] = []; } 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); array_push($json[$prop], $prop_element->getAttributeNode('content')->value);
} else { } else {
array_push($json[$prop], $prop_element->nodeValue); array_push($json[$prop], $prop_element->nodeValue);
@ -500,7 +546,7 @@ class RecipeService
} }
} }
} }
// Make one final desparate attempt at getting the instructions // Make one final desparate attempt at getting the instructions
if (!isset($json['recipeInstructions']) || !$json['recipeInstructions'] || sizeof($json['recipeInstructions']) < 1) { if (!isset($json['recipeInstructions']) || !$json['recipeInstructions'] || sizeof($json['recipeInstructions']) < 1) {
$json['recipeInstructions'] = []; $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 // If image data was fetched, write it to disk
@ -640,6 +696,14 @@ class RecipeService
$thumb_image_file->putContent($thumb_image->data()); $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; return $recipe_file;
} }
@ -867,7 +931,7 @@ class RecipeService
$path = $this->config->getUserValue($this->user_id, 'cookbook', 'folder'); $path = $this->config->getUserValue($this->user_id, 'cookbook', 'folder');
if (!$path) { if (!$path) {
$path = '/Recipes'; $path = '/' . $this->il10n->t('Recipes');
} }
return $path; return $path;

43
package.json Normal file
Просмотреть файл

@ -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"
}
}

Просмотреть файл

@ -0,0 +1,242 @@
<template>
<div class="wrapper">
<!-- Use $store.state.page for page matching to make sure everything else has been set beforehand! -->
<Breadcrumbs class="breadcrumbs" rootIcon="icon-category-organization">
<Breadcrumb :title="t('Home')" :to="'/'" :disableDrop="true" />
<!-- INDEX PAGE -->
<Breadcrumb v-if="isIndex" class="active" :title="t('All recipes')" :disableDrop="true"></Breadcrumb>
<Breadcrumb v-if="isIndex" class="no-arrow" title="" :disableDrop="true">
<ActionButton icon="icon-search" class="action-button" :disabled="true" :ariaLabel="t('Search')" @click="$window.goTo('/search')" />
</Breadcrumb>
<!-- SEARCH PAGE -->
<Breadcrumb v-if="isSearch" class="not-link" :title="searchTitle" :disableDrop="true" />
<Breadcrumb v-if="isSearch && $route.params.value" class="active" :title="$route.params.value" :disableDrop="true" />
<!-- RECIPE PAGES -->
<!-- Edit recipe -->
<Breadcrumb v-if="isEdit" class="not-link" :title="t('Edit recipe')" :disableDrop="true" />
<Breadcrumb v-if="isEdit" class="active" :title="$store.state.recipe.name" :disableDrop="true">
<ActionButton
:icon="$store.state.reloadingRecipe===parseInt($route.params.id) ? 'icon-loading-small' : 'icon-history'"
class="action-button"
:ariaLabel="t('Reload recipe')"
@click="reloadRecipeEdit()"
/>
</Breadcrumb>
<!-- Create new recipe -->
<Breadcrumb v-else-if="isCreate" class="active" :title="t('New recipe')" :disableDrop="true" />
<Breadcrumb v-if="isEdit || isCreate" class="no-arrow" title="" :disableDrop="true">
<ActionButton
:icon="$store.state.savingRecipe ? 'icon-loading-small' : 'icon-checkmark'"
class="action-button"
:ariaLabel="t('Save changes')"
@click="saveChanges()"
/>
</Breadcrumb>
<!-- View recipe -->
<Breadcrumb v-if="isRecipe" class="active" :title="$store.state.recipe.name" :disableDrop="true">
<ActionButton
:icon="$store.state.reloadingRecipe===parseInt($route.params.id) ? 'icon-loading-small' : 'icon-history'"
class="action-button"
:ariaLabel="t('Reload recipe')"
@click="reloadRecipeView()"
/>
</Breadcrumb>
<Breadcrumb v-if="isRecipe" class="no-arrow" title="" :disableDrop="true">
<ActionButton
icon="icon-rename"
class="action-button"
:ariaLabel="t('Edit recipe')"
@click="$window.goTo('/recipe/'+$store.state.recipe.id+'/edit')"
/>
</Breadcrumb>
<Breadcrumb v-if="isRecipe" class="no-arrow" title="" :disableDrop="true">
<ActionButton icon="icon-category-office" class="action-button" :ariaLabel="t('Print recipe')" @click="printRecipe()" />
</Breadcrumb>
<Breadcrumb v-if="isRecipe" class="no-arrow" title="" :disableDrop="true">
<ActionButton icon="icon-delete" class="action-button" :ariaLabel="t('Delete recipe')" @click="deleteRecipe()" />
</Breadcrumb>
<!-- Is the app loading? -->
<Breadcrumb v-if="isLoading" class="active no-arrow" :title="t('App is loading')" :disableDrop="true">
<ActionButton icon="icon-loading-small" :ariaLabel="t('Loading...')" />
</Breadcrumb>
<!-- Is a recipe loading? -->
<Breadcrumb v-else-if="isLoadingRecipe" class="active no-arrow" :title="t('Loading recipe')" :disableDrop="true">
<ActionButton icon="icon-loading-small" :ariaLabel="t('Loading...')" />
</Breadcrumb>
<!-- No recipe found -->
<Breadcrumb v-else-if="recipeNotFound" class="active no-arrow" :title="t('Recipe not found')" :disableDrop="true" />
<!-- No page found -->
<Breadcrumb v-else-if="pageNotFound" class="active no-arrow" :title="t('Page not found')" :disableDrop="true" />
</Breadcrumbs>
</div>
</template>
<script>
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Breadcrumbs from '@nextcloud/vue/dist/Components/Breadcrumbs'
import Breadcrumb from '@nextcloud/vue/dist/Components/Breadcrumb'
export default {
name: 'AppControls',
components: {
ActionButton, Breadcrumbs, Breadcrumb
},
data () {
return {
}
},
computed: {
isCreate () {
if (this.$store.state.page === 'create') {
return true
}
return false
},
isEdit () {
if (this.isLoadingRecipe) {
return false // Do not show both at the same time
}
// Editing requires that a recipe was found
if (this.$store.state.page === 'edit' && this.$store.state.recipe) {
return true
}
return false
},
isIndex () {
if (this.isLoadingRecipe) {
return false // Do not show both at the same time
}
if (this.$store.state.page === 'index') {
return true
}
return false
},
isLoading () {
// The page is being loaded
if (this.$store.state.page === null) {
return true
}
return false
},
isLoadingRecipe () {
// A recipe is being loaded
if (this.$store.state.loadingRecipe) {
return true
}
return false
},
isRecipe () {
if (this.isLoadingRecipe) {
return false // Do not show both at the same time
}
// Viewing recipe requires that one was found
if (this.$store.state.page === 'recipe' && this.$store.state.recipe) {
return true
}
return false
},
isSearch () {
if (this.isLoadingRecipe) {
return false // Do not show both at the same time
}
if (this.$store.state.page === 'search') {
return true
}
return false
},
pageNotFound () {
if (this.$store.state.page === 'notfound') {
return true
}
return false
},
recipeNotFound () {
// Editing or viewing recipe was attempted, but no recipe was found
if (['edit', 'recipe'].indexOf(this.$store.state.page) !== -1
&& !this.$store.state.recipe) {
return true
}
return false
},
searchTitle () {
if (this.$route.name === 'search-category') {
return this.t('Category')
} else if (this.$route.name === 'search-name') {
return this.t('Recipe name')
} else if (this.$route.name === 'search-tag') {
return this.t('Tag')
} else {
return this.t('Search for recipes')
}
}
},
methods: {
deleteRecipe: function() {
// Confirm delete
if (!confirm(this.t('Are you sure you want to delete this recipe?'))) {
return
}
let id = this.$store.state.recipe.id
let $this = this
$.ajax({
url: window.baseUrl + '/api/recipes/' + id,
method: 'DELETE',
})
.done(function(reply) {
$this.$window.goTo('/')
$this.$root.$emit('refreshNavigation')
})
.fail(function(e) {
alert($this.t('Delete failed'))
if (e && e instanceof Error) {
throw e
}
})
},
printRecipe: function() {
window.print()
},
reloadRecipeEdit: function() {
this.$root.$emit('reloadRecipeEdit')
},
reloadRecipeView: function() {
this.$root.$emit('reloadRecipeView')
},
saveChanges: function() {
this.$root.$emit('saveRecipe')
},
},
mounted () {
},
}
</script>
<style scoped>
.wrapper {
width: 100%;
}
.active {
font-weight: bold;
cursor: default !important;
}
.breadcrumbs {
flex-basis: 100%;
}
.no-arrow::before {
content: '' !important;
}
@media print {
* {
display: none !important;
}
}
</style>

Просмотреть файл

@ -0,0 +1,81 @@
<template>
<ul>
<li v-for="recipe in recipes" :key="recipe.recipe_id">
<router-link :to="'/recipe/'+recipe.recipe_id">
<img v-if="recipe.imageUrl" :src="recipe.imageUrl">
<span>{{ recipe.name }}</span>
</router-link>
</li>
</ul>
</template>
<script>
export default {
name: 'Index',
data () {
return {
recipes: []
}
},
methods: {
/**
* Load all recipes from the database
*/
loadAll: function () {
var deferred = $.Deferred()
var $this = this
$.get(this.$window.baseUrl + '/api/recipes').done(function (recipes) {
$this.recipes = recipes
deferred.resolve()
// Always set page name last
$this.$store.dispatch('setPage', { page: 'index' })
}).fail(function (jqXHR, textStatus, errorThrown) {
deferred.reject(new Error(jqXHR.responseText))
// Always set page name last
$this.$store.dispatch('setPage', { page: 'index' })
})
return deferred.promise()
},
},
mounted () {
this.loadAll()
},
}
</script>
<style scoped>
ul {
display: flex;
flex-wrap: wrap;
flex-direction: row;
width: 100%;
}
ul li {
width: 300px;
max-width: 100%;
margin: 0.5rem 1rem 1rem;
}
ul li a {
display: block;
height: 105px;
box-shadow: 0 0 3px #AAA;
border-radius: 3px;
}
ul li a:hover {
box-shadow: 0 0 5px #888;
}
ul li img {
float: left;
height: 105px;
border-radius: 3px 0 0 3px;
}
ul li span {
display: block;
padding: 0.5rem 0.5em 0.5rem calc(105px + 0.5rem);
}
</style>

Просмотреть файл

@ -0,0 +1,53 @@
<template>
<div id="app">
<AppNavi id="app-navigation" />
<div id="app-content">
<div id="app-content-wrapper">
<AppControls />
<router-view></router-view>
</div>
</div>
</div>
</template>
<script>
import AppControls from './AppControls'
import AppNavi from './AppNavi'
export default {
name: 'Main',
components: {
AppControls,
AppNavi,
},
watch: {
/* This is left here as an example in case the routes need to be debugged again
'$route' (to, from) {
console.log(this.$window.isSameBaseRoute(from.fullPath, to.fullPath))
},
*/
},
}
</script>
<style>
#app-content {
min-width: calc(100% - 300px);
}
#app-content-wrapper {
flex-wrap: wrap;
}
@media print {
#app-content-wrapper {
display: block !important;
padding: 0 !important;
overflow: visible !important;
}
#app-content {
margin-left: 0 !important;
}
}
</style>

356
src/components/AppNavi.vue Normal file
Просмотреть файл

@ -0,0 +1,356 @@
<template>
<!-- This component should ideally not have a conflicting name with AppNavigation from the nextcloud/vue package -->
<AppNavigation>
<router-link :to="'/recipe/create'">
<AppNavigationNew class="create" :text="t('Create recipe')" />
</router-link>
<ul>
<ActionInput
class="download"
@submit="downloadRecipe"
:disabled="downloading ? 'disabled' : null"
:icon="downloading ? 'icon-loading-small' : 'icon-download'">
{{ t('Recipe URL') }}
</ActionInput>
<AppNavigationItem :title="t('All recipes')" icon="icon-category-organization" :to="'/'">
<AppNavigationCounter slot="counter">{{ totalRecipeCount }}</AppNavigationCounter>
</AppNavigationItem>
<AppNavigationItem v-for="(cat,idx) in categories"
:key="cat+idx"
:ref="'app-navi-cat-'+idx"
:title="cat.name"
icon="icon-category-files"
:allowCollapse="true"
:to="'/category/'+cat.name"
@update:open="categoryOpen(idx)"
>
<AppNavigationCounter slot="counter">{{ cat.recipeCount }}</AppNavigationCounter>
<template>
<AppNavigationItem class="recipe" v-for="(rec,idy) in cat.recipes"
:key="idx+'-'+idy"
:title="rec.name"
:to="'/recipe/'+rec.recipe_id"
:icon="$store.state.loadingRecipe===parseInt(rec.recipe_id) || !rec.recipe_id ? 'icon-loading-small' : null"
/>
</template>
</AppNavigationItem>
</ul>
<AppNavigationSettings :open="true">
<div id="app-settings">
<fieldset>
<ul>
<li>
<ActionButton
class="button"
:icon="scanningLibrary ? 'icon-loading-small' : 'icon-history'"
@click="reindex()"
:title="t('Rescan library')"
/>
</li>
<li>
<label class="settings-input">{{ t('Recipe folder') }}</label>
<input type="text" :value="recipeFolder" @click="pickRecipeFolder" :placeholder="t('Please pick a folder')">
</li>
<li>
<label class="settings-input">
{{ t('Update interval in minutes') }}
</label>
<div class="update">
<input type="number" class="input settings-input" v-model="updateInterval" placeholder="0">
<button class="icon-info" disabled="disabled" :title="t('Last update: ')"></button>
</div>
</li>
<li>
<input type="checkbox" class="checkbox" v-model="printImage" id="recipe-print-image">
<label for="recipe-print-image">
{{ t('Print image with recipe') }}
</label>
</li>
</ul>
</fieldset>
</div>
</AppNavigationSettings>
</AppNavigation>
</template>
<script>
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import ActionInput from '@nextcloud/vue/dist/Components/ActionInput'
import AppNavigation from '@nextcloud/vue/dist/Components/AppNavigation'
import AppNavigationCaption from '@nextcloud/vue/dist/Components/AppNavigationCaption'
import AppNavigationCounter from '@nextcloud/vue/dist/Components/AppNavigationCounter'
import AppNavigationItem from '@nextcloud/vue/dist/Components/AppNavigationItem'
import AppNavigationNew from '@nextcloud/vue/dist/Components/AppNavigationNew'
import AppNavigationSettings from '@nextcloud/vue/dist/Components/AppNavigationSettings'
import AppNavigationSpacer from '@nextcloud/vue/dist/Components/AppNavigationSpacer'
export default {
name: 'AppNavi',
components: {
ActionButton,
ActionInput,
AppNavigation,
AppNavigationCaption,
AppNavigationCounter,
AppNavigationItem,
AppNavigationNew,
AppNavigationSettings,
AppNavigationSpacer,
},
data () {
return {
categories: [],
downloading: false,
printImage: false,
recipeFolder: "",
scanningLibrary: false,
uncatRecipes: 0,
// By setting the reset value initially to true, it will skip one watch event
// (the one when config is loaded at page load)
resetInterval: true,
resetPrintImage: true,
updateInterval: 0,
}
},
computed: {
totalRecipeCount () {
let total = this.uncatRecipes
for (let i=0; i<this.categories.length; i++) {
total += this.categories[i].recipeCount
}
return total
}
},
watch: {
printImage: function(newVal, oldVal) {
// Avoid infinite loop on page load and when reseting value after failed submit
if (this.resetPrintImage) {
this.resetPrintImage = false
return
}
var $this = this
$.ajax({
url: this.$window.baseUrl + '/config',
method: 'POST',
data: { 'print_image': newVal ? 1 : 0 }
}).done(function (response) {
// Should this check the response of the query? To catch some errors that redirect the page
}).fail(function(e) {
alert($this.t('Could not set preference for image printing'));
$this.resetPrintImage = true
$this.printImage = oldVal
})
},
updateInterval: function(newVal, oldVal) {
// Avoid infinite loop on page load and when reseting value after failed submit
if (this.resetInterval) {
this.resetInterval = false
return
}
var $this = this
$.ajax({
url: $this.$window.baseUrl + '/config',
method: 'POST',
data: { 'update_interval': newVal }
}).done(function (response) {
// Should this check the response of the query? To catch some errors that redirect the page
}).fail(function(e) {
alert($this.t('Could not set recipe update interval to {interval}', { interval: newVal }))
$this.resetInterval = true
$this.updateInterval = oldVal
})
},
},
methods: {
categoryOpen: function(idx) {
if (!this.categories[idx].recipes.length || this.categories[idx].recipes[0].id) {
// Recipes have already been loaded
return
}
let cat = this.categories[idx]
$.get(this.$window.baseUrl + '/category/'+cat.name).done(function(json) {
cat.recipes = json
}).fail(function (jqXHR, textStatus, errorThrown) {
cat.recipes = []
alert($this.t('Failed to load category '+cat.name+' recipes'))
if (e && e instanceof Error) {
throw e
}
})
},
/**
* Download and import the recipe at given URL
*/
downloadRecipe: function(e) {
let deferred = $.Deferred()
let $this = this
this.downloading = true
$.ajax({
url: this.$window.baseUrl + '/import',
method: 'POST',
data: 'url=' + e.target[1].value
}).done(function (recipe) {
$this.downloading = false
$this.$window.goTo('/recipe/' + recipe.id)
e.target[1].value = ''
deferred.resolve()
}).fail(function (jqXHR, textStatus, errorThrown) {
$this.downloading = false
deferred.reject(new Error(jqXHR.responseText))
alert($this.t(jqXHR.responseJSON))
})
return deferred.promise()
},
/**
* Fetch and display recipe categories
*/
getCategories: function() {
let $this = this
$.get(this.$window.baseUrl + '/categories').done(function(json) {
json = json || []
// Reset the old values
$this.uncatRecipes = 0
$this.categories = []
for (let i=0; i<json.length; i++) {
if (json[i].name === '*') {
$this.uncatRecipes = parseInt(json[i].recipe_count)
} else {
$this.categories.push({
name: json[i].name,
recipeCount: parseInt(json[i].recipe_count),
recipes: [{ id: 0, name: $this.t('Loading category recipes...') }],
})
}
}
for (let i=0; i<$this.categories.length; i++) {
// Reload recipes in open categories
if (!$this.$refs['app-navi-cat-'+i]) {
continue
}
if ($this.$refs['app-navi-cat-'+i][0].opened) {
console.log("Reloading recipes in "+$this.$refs['app-navi-cat-'+i][0].title)
$this.categoryOpen(i)
}
}
})
.fail(function(e) {
alert($this.t('Failed to fetch categories'))
if (e && e instanceof Error) {
throw e
}
})
},
/**
* Select a recipe folder using the Nextcloud file picker
*/
pickRecipeFolder: function(e) {
let $this = this
OC.dialogs.filepicker(
this.t('Path to your recipe collection'),
function (path) {
$.ajax({
url: $this.$window.baseUrl + '/config',
method: 'POST',
data: { 'folder': path },
}).done(function () {
$this.loadAll()
.then(function() {
$this.$store.dispatch('setRecipe', { recipe: null })
$this.$window.goTo('/')
$this.recipeFolder = path
})
}).fail(function(e) {
alert($this.t('Could not set recipe folder to {path}', { path: path }))
})
},
false,
'httpd/unix-directory',
true
)
},
/**
* Reindex all recipes
*/
reindex: function () {
if (this.scanningLibrary) {
// No repeat clicks until we're done
return
}
this.scanningLibrary = true
var deferred = $.Deferred()
var $this = this
$.ajax({
url: this.$window.baseUrl + '/reindex',
method: 'POST'
}).done(function () {
deferred.resolve()
$this.scanningLibrary = false
console.log("Library reindexing complete")
$this.getCategories()
if (['index', 'search'].indexOf($this.$store.state.page) > -1) {
// This refreshes the current router view in case items in it changed during reindex
$this.$router.go()
}
}).fail(function (jqXHR, textStatus, errorThrown) {
deferred.reject(new Error(jqXHR.responseText))
$this.scanningLibrary = false
console.log("Library reindexing failed!")
})
return deferred.promise()
},
/**
* Set loading recipe index to show the loading icon
*/
setLoadingRecipe: function(id) {
this.$store.dispatch('setLoadingRecipe', { recipe: id })
},
},
mounted () {
// Register a method hook for navigation refreshing
// This component should only load once, but better safe than sorry...
this.$root.$off('refreshNavigation')
this.$root.$on('refreshNavigation', () => {
this.getCategories()
})
this.getCategories()
},
}
</script>
<style scoped>
#app-settings .button {
padding: 0;
height: 44px;
border-radius: var(--border-radius);
z-index: 2;
}
#app-settings input[type="text"],
#app-settings input[type="number"],
#app-settings .button {
width: 100%;
display: block;
}
.update > input {
width: calc(100% - 0.5rem - 34px) !important;
margin-right: 0.5rem;
float: left;
}
.update > button {
margin: 3px 0 !important;
width: 34px !important;
height: 34px !important;
float: left;
}
@media print {
* {
display: none !important;
}
}
</style>

Просмотреть файл

@ -0,0 +1,85 @@
<template>
<fieldset>
<label>
{{ fieldLabel }}
</label>
<input type="text" v-model="$parent.recipe[fieldName]" />
<button type="button" :title="t('Pick a local image')" @click="pickImage"><span class="icon-category-multimedia"></span></button>
</fieldset>
</template>
<script>
export default {
name: "EditImageField",
props: ['fieldName','fieldLabel'],
data () {
return {
}
},
methods: {
pickImage: function(e) {
e.preventDefault()
let $this = this
OC.dialogs.filepicker(
this.t('Path to your recipe image'),
function (path) {
$this.$parent.recipe.image = path
},
false,
['image/jpeg', 'image/png'],
true,
OC.dialogs.FILEPICKER_TYPE_CHOOSE
)
}
}
}
</script>
<style scoped>
fieldset {
margin-bottom: 1em;
}
fieldset > * {
margin: 0;
float: left;
}
fieldset > label {
vertical-align: top;
display: inline-block;
width: 10em;
height: 34px;
line-height: 17px;
font-weight: bold;
}
@media(max-width:1199px) { fieldset > label {
display: block;
float: none;
}}
fieldset > input {
width: calc(100% - 14em);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: 0;
}
@media(max-width:1199px) { fieldset > input {
width: calc(100% - 3em);
}}
fieldset > input + 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;
}
fieldset > input + button > * {
pointer-events: none;
}
</style>

Просмотреть файл

@ -0,0 +1,53 @@
<template>
<fieldset>
<label>
{{ fieldLabel }}
</label>
<input :type="fieldType" v-model="$parent.recipe[fieldName]" />
</fieldset>
</template>
<script>
export default {
name: "EditInputField",
props: ['fieldType','fieldName','fieldLabel'],
data () {
return {
}
},
}
</script>
<style scoped>
fieldset {
margin-bottom: 1em;
}
fieldset > * {
margin: 0;
float: left;
}
fieldset > label {
vertical-align: top;
display: inline-block;
width: 10em;
height: 34px;
line-height: 17px;
font-weight: bold;
}
@media(max-width:1199px) { fieldset > label {
display: block;
float: none;
}}
fieldset > input {
width: calc(100% - 11em);
}
@media(max-width:1199px) { fieldset > input {
width: 100%;
}}
</style>

Просмотреть файл

@ -0,0 +1,181 @@
<template>
<fieldset>
<label>{{ fieldLabel }}</label>
<ul ref="list">
<li :class="fieldType" v-for="(entry,idx) in $parent.recipe[fieldName]" :key="fieldName+idx">
<div v-if="fieldName==='instructions'" class="step-number"></div>
<input v-if="fieldType==='text'" type="text" v-model="$parent.recipe[fieldName][idx]" @keyup="keyPressed" />
<textarea v-else-if="fieldType==='textarea'" v-model="$parent.recipe[fieldName][idx]"></textarea>
<div class="controls">
<button class="icon-arrow-up" @click="moveUp(idx)"></button>
<button class="icon-arrow-down" @click="moveDown(idx)"></button>
<button class="icon-delete" @click="deleteEntry(idx)"></button>
</div>
</li>
</ul>
<button class="button add-list-item" @click="addNew()"><span class="icon-add"></span> {{ t('Add') }}</button>
</fieldset>
</template>
<script>
export default {
name: "EditInputGroup",
props: ['fieldType','fieldName','fieldLabel'],
data () {
return {
}
},
methods: {
addNew: function() {
// This is a dirty hack, but Vue components update with a slight delay so you
// can't just straight up go and set focus here
let nextFocus = this.$parent.recipe[this.fieldName].length
this.$parent.addEntry(this.fieldName)
let failSafe = 2500
let $ul = $(this.$refs['list'])
let $this = this
let focusMonitor = window.setInterval(function() {
if ($ul.children('li').length > nextFocus) {
if ($this.fieldType === 'text') {
$ul.children('li').eq(nextFocus).find('input').focus()
} else if ($this.fieldType === 'textarea') {
$ul.children('li').eq(nextFocus).find('textarea').focus()
}
window.clearInterval(focusMonitor)
}
failSafe -= 100
if (!failSafe) {
window.clearInterval(focusMonitor)
}
}, 100)
},
/**
* Delete an entry from the list
*/
deleteEntry: function(idx) {
this.$parent.deleteEntry(this.fieldName, idx)
},
/**
* Catches enter and key down presses and either adds a new row or focuses the one below
*/
keyPressed(e) {
// Using keyup for trigger will prevent repeat triggering if key is held down
if (e.keyCode === 13 || e.keyCode === 10) {
e.preventDefault()
let $li = $(e.currentTarget).parents('li')
let $ul = $li.parents('ul')
if ($li.index() >= $ul.children('li').length - 1) {
this.addNew()
} else {
$ul.children('li').eq($li.index() + 1).find('input').focus()
}
}
},
moveDown: function(idx) {
this.$parent.moveEntryDown(this.fieldName, idx)
},
moveUp: function(idx) {
this.$parent.moveEntryUp(this.fieldName, idx)
},
},
}
</script>
<style scoped>
fieldset {
margin-bottom: 1em;
width: 100%;
}
fieldset > label {
display: inline-block;
width: 10em;
line-height: 18px;
font-weight: bold;
word-spacing: initial;
}
fieldset > ul {
margin-top: 1rem;
}
fieldset > ul + button {
width: 36px;
text-align: center;
padding: 0;
float: right;
}
fieldset > ul > li {
display: flex;
width: 100%;
margin: 0 0 1em 0;
padding-right: 0.25em;
}
li.text > input {
width: 100%;
margin: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
li .controls {
display: flex;
}
li .controls > button {
padding: 0;
margin: 0;
width: 34px;
height: 34px;
border-radius: 0;
border-left-color: transparent;
border-right-color: transparent;
}
li .controls > button:last-child {
border-top-right-radius: var(--border-radius);
border-bottom-right-radius: var(--border-radius);
border-right-width: 1px;
}
li .controls > button:last-child:not(:hover):not(:focus) {
border-right-color: var(--color-border-dark);
}
li.textarea {
float: right;
position: relative;
top: 1px;
z-index: 1;
}
li.textarea > textarea {
min-height: 10em;
resize: vertical;
width: 100%;
margin: 0;
border-top-right-radius: 0;
}
li.textarea::after {
display: table;
content: '';
clear: both;
}
.icon-arrow-up {
background-image: var(--icon-triangle-n-000);
}
.icon-arrow-down {
background-image: var(--icon-triangle-s-000);
}
button {
width: auto !important;
padding: 0 1rem 0 0.75rem !important;
}
</style>

Просмотреть файл

@ -0,0 +1,60 @@
<template>
<fieldset>
<label>{{ fieldLabel }}</label>
<input type="number" min="0" v-model="$parent[fieldName][0]" placeholder="00">
<span>:</span>
<input type="number" min="0" max="59" v-model="$parent[fieldName][1]" placeholder="00">
</fieldset>
</template>
<script>
export default {
name: "EditTimeField",
props: ['fieldName','fieldLabel'],
data () {
return {
}
},
computed: {
},
methods: {
},
mounted () {
},
}
</script>
<style scoped>
fieldset {
margin-bottom: 1em;
}
fieldset > * {
margin: 0;
float: left;
}
fieldset > label {
vertical-align: top;
display: inline-block;
width: 10em;
line-height: 18px;
font-weight: bold;
}
@media(max-width:1199px) { fieldset > label {
display: block;
float: none;
}}
fieldset > span {
margin: 0 0.5rem;
line-height: 34px;
}
fieldset > input {
width: 50px !important;
text-align: center;
}
</style>

Просмотреть файл

@ -0,0 +1,20 @@
<template>
<h2>{{ t('The page was not found') }}</h2>
</template>
<script>
export default {
name: 'NotFound',
mounted () {
this.$store.dispatch('setPage', { page: 'notfound' })
}
}
</script>
<style scoped>
h2 {
margin: 1rem;
}
</style>

Просмотреть файл

@ -0,0 +1,282 @@
<template>
<div class="wrapper">
<EditInputField :fieldName="'name'" :fieldType="'text'" :fieldLabel="t('Name')" />
<EditInputField :fieldName="'description'" :fieldType="'text'" :fieldLabel="t('Description')" />
<EditInputField :fieldName="'url'" :fieldType="'url'" :fieldLabel="t('URL')" />
<EditImageField :fieldName="'image'" :fieldLabel="('Image')" />
<EditTimeField :fieldName="'prepTime'" :fieldLabel="t('Preparation time')" />
<EditTimeField :fieldName="'cookTime'" :fieldLabel="t('Cooking time')" />
<EditTimeField :fieldName="'totalTime'" :fieldLabel="t('Total time')" />
<EditInputField :fieldName="'recipeCategory'" :fieldType="'text'" :fieldLabel="t('Category')" />
<EditInputField :fieldName="'keywords'" :fieldType="'rext'" :fieldLabel="t('Keywords (comma separated)')" />
<EditInputField :fieldName="'recipeYield'" :fieldType="'number'" :fieldLabel="t('Servings')" />
<EditInputGroup :fieldName="'tool'" :fieldType="'text'" :fieldLabel="t('Tools')" />
<EditInputGroup :fieldName="'recipeIngredient'" :fieldType="'text'" :fieldLabel="t('Ingredients')" />
<EditInputGroup :fieldName="'recipeInstructions'" :fieldType="'textarea'" :fieldLabel="t('Instructions')" />
</div>
</template>
<script>
import EditImageField from './EditImageField'
import EditInputField from './EditInputField'
import EditInputGroup from './EditInputGroup'
import EditTimeField from './EditTimeField'
export default {
name: 'RecipeEdit',
components: {
EditImageField,
EditInputField,
EditInputGroup,
EditTimeField,
},
props: ['id'],
data () {
return {
// Initialize the recipe schema, otherwise v-models in child components may not work
recipe: {
id: 0,
name: null,
description: '',
url: '',
image: '',
prepTime: '',
cookTime: '',
totalTime: '',
recipeCategory: '',
keywords: '',
recipeYield: '',
tool: [],
recipeIngredient: [],
recipeInstructions: [],
},
// This will hold the above configuration after recipe is loaded, so we don't have to
// keep it up to date in multiple places if it changes later
recipeInit: null,
// These are helper variables
prepTime: [0, 0],
cookTime: [0, 0],
totalTime: [0, 0],
}
},
watch: {
prepTime () {
let hours = this.prepTime[0].toString().padStart(2, '0')
let mins = this.prepTime[1].toString().padStart(2, '0')
this.recipe.prepTime = 'PT' + hours + 'H' + mins + 'M'
},
cookTime () {
let hours = this.cookTime[0].toString().padStart(2, '0')
let mins = this.cookTime[1].toString().padStart(2, '0')
this.recipe.cookTime = 'PT' + hours + 'H' + mins + 'M'
},
totalTime () {
let hours = this.totalTime[0].toString().padStart(2, '0')
let mins = this.totalTime[1].toString().padStart(2, '0')
this.recipe.totalTime = 'PT' + hours + 'H' + mins + 'M'
},
},
methods: {
addEntry: function(field) {
this.recipe[field].push('')
},
deleteEntry: function(field, index) {
this.recipe[field].splice(index, 1)
},
loadRecipeData: function() {
if (!this.$store.state.recipe) {
// Make the control row show that a recipe is loading
this.$store.dispatch('setLoadingRecipe', {
recipe: -1
})
} else if (this.$store.state.recipe.id === parseInt(this.$route.params.id)) {
// Make the control row show that the recipe is reloading
this.$store.dispatch('setReloadingRecipe', {
recipe: this.$route.params.id
})
}
let $this = this
$.ajax({
url: this.$window.baseUrl + '/api/recipes/'+this.$route.params.id,
method: 'GET',
data: null,
}).done(function (recipe) {
$this.$store.dispatch('setRecipe', { recipe: recipe })
$this.setup()
}).fail(function(e) {
alert($this.t('Loading recipe failed'))
// Disable loading indicator
if ($this.$store.state.loadingRecipe) {
$this.$store.dispatch('setLoadingRecipe', { recipe: 0 })
} else if ($this.$store.state.reloadingRecipe) {
$this.$store.dispatch('setReloadingRecipe', { recipe: 0 })
}
// Browse to new recipe creation
$this.$window.goTo('/recipe/create')
})
},
moveEntryDown: function(field, index) {
if (index >= this.recipe[field].length - 1) {
// Already at the send of array
return
}
let entry = this.recipe[field].splice(index, 1)
if (index + 1 < this.recipe[field].length) {
this.recipe[field].splice(index + 1, 0, entry)
} else {
this.recipe[field].push(entry)
}
},
moveEntryUp: function(field, index) {
if (index < 1) {
// Already at the start of array
return
}
let entry = this.recipe[field].splice(index, 1)
this.recipe[field].splice(index - 1, 0, entry)
},
save: function() {
this.$store.dispatch('setSavingRecipe', { saving: true })
let $this = this
if (this.recipe.id) {
// Update existing recipe
$.ajax({
url: this.$window.baseUrl + '/api/recipes/'+this.recipe.id,
method: 'PUT',
data: this.recipe,
}).done(function (recipe) {
$this.$store.dispatch('setSavingRecipe', { saving: false })
$this.$window.goTo('/recipe/'+recipe)
// Refresh navigation to display changes
$this.$root.$emit('refreshNavigation')
}).fail(function(e) {
$this.$store.dispatch('setSavingRecipe', { saving: false })
alert($this.t('Recipe could not be saved'))
})
} else {
// Create a new recipe
$.ajax({
url: this.$window.baseUrl + '/api/recipes',
method: 'POST',
data: this.recipe,
}).done(function (recipe) {
$this.$store.dispatch('setSavingRecipe', { saving: false })
$this.$window.goTo('/recipe/'+recipe)
// Refresh navigation to display changes
$this.$root.$emit('refreshNavigation')
}).fail(function(e) {
$this.$store.dispatch('setSavingRecipe', { saving: false })
alert($this.t('Recipe could not be saved'))
})
}
},
setup: function() {
if (this.$route.params.id) {
// Load the recipe from store and make edits to a local copy first
this.recipe = { ...this.$store.state.recipe }
// Parse time values
let timeComps = this.recipe.prepTime ? this.recipe.prepTime.match(/PT(\d+?)H(\d+?)M/) : null
if (timeComps) {
this.prepTime = [timeComps[1], timeComps[2]]
}
timeComps = this.recipe.cookTime ? this.recipe.cookTime.match(/PT(\d+?)H(\d+?)M/) : null
if (timeComps) {
this.cookTime = [timeComps[1], timeComps[2]]
}
timeComps = this.recipe.totalTime ? this.recipe.totalTime.match(/PT(\d+?)H(\d+?)M/) : null
if (timeComps) {
this.totalTime = [timeComps[1], timeComps[2]]
}
// Always set the active page last!
this.$store.dispatch('setPage', { page: 'edit' })
} else {
this.recipe = this.recipeInit
this.prepTime = [0, 0]
this.cookTime = [0, 0]
this.totalTime = [0, 0]
this.$store.dispatch('setPage', { page: 'create' })
}
},
},
mounted () {
// Store the initial recipe configuration for possible later use
if (this.recipeInit === null) {
this.recipeInit = this.recipe
}
// Register save method hook for access from the controls components
// The event hookmust first be destroyed to avoid it from firing multiple
// times if the same component is loaded again
this.$root.$off('saveRecipe')
this.$root.$on('saveRecipe', () => {
this.save()
})
// Register data load method hook for access from the controls components
this.$root.$off('reloadRecipeEdit')
this.$root.$on('reloadRecipeEdit', () => {
this.loadRecipeData()
})
},
// We can check if the user has browsed from the same recipe's view to this
// edit and save some time by not reloading the recipe data, leading to a
// more seamless experience.
// This assumes that the data has not been changed some other way between
// loading the view component and loading this edit component. If that is
// the case, the user can always manually reload by clicking the breadcrumb.
beforeRouteEnter (to, from, next) {
if (window.isSameItemInstance(from.fullPath, to.fullPath)) {
next(vm => { vm.setup() })
} else {
if (to.params && to.params.id) {
next(vm => { vm.loadRecipeData() })
} else {
next(vm => { vm.setup() })
}
}
},
/**
* This is one tricky feature of Vue router. If different paths lead to
* the same component (such as '/recipe/create' and '/recipe/xxx/edit
* or /recipe/xxx/edit and /recipe/yyy/edit)', the view will not automatically
* reload. So we have to check for these conditions and reload manually.
* This can also be used to confirm that the user wants to leave the page
* if there are unsaved changes.
*/
beforeRouteLeave (to, from, next) {
// beforeRouteLeave is called when the static route changes.
// We have to check if the target component stays the same and reload.
// However, we should not reload if the component changes; otherwise
// reloaded data may overwrite the data loaded at the target component
// which will at the very least result in incorrect breadcrumb path!
next()
// Check if we should reload the component content
if (this.$window.shouldReloadContent(from.fullPath, to.fullPath)) {
this.setup()
}
},
beforeRouteUpdate (to, from, next) {
// beforeRouteUpdate is called when the static route stays the same
next()
// Check if we should reload the component content
if (this.$window.shouldReloadContent(from.fullPath, to.fullPath)) {
this.setup()
}
},
}
</script>
<style scoped>
.wrapper {
width: 100%;
padding: 1rem;
}
/* This is not used anywhere at the moment, but left here for future reference
form fieldset ul label input[type="checkbox"] {
margin-left: 1em;
vertical-align: middle;
cursor: pointer;
} */
</style>

Просмотреть файл

@ -0,0 +1,55 @@
<template>
<header v-if="$store.state.recipe.image" :class="{ 'collapsed': collapsed, 'printable': $store.state.recipe.printImage }">
<img :src="$store.state.recipe.imageUrl" @click="toggleCollapsed()">
</header>
</template>
<script>
export default {
name: 'RecipeImages',
data () {
return {
collapsed: true,
}
},
methods: {
toggleCollapsed: function() {
this.collapsed = !this.collapsed
},
},
mounted () {
}
}
</script>
<style scoped>
header {
display: block;
width: 100%;
text-align: center;
margin-bottom: 1rem;
}
img {
cursor: pointer;
max-width: 100%;
}
header.collapsed {
flex-basis: 100%;
height: 40vh;
overflow: hidden;
}
header.collapsed img {
margin: 0 auto;
margin-top: 20vh;
transform: translateY(-50%);
display: block;
}
@media print {
header:not(.printable) {
display: none !important;
}
}
</style>

Просмотреть файл

@ -0,0 +1,47 @@
<template>
<li :class="{ 'header': isHeader() }">{{ displayIngredient }}</li>
</template>
<script>
export default {
name: 'RecipeIngredient',
props: ['ingredient'],
data () {
return {
headerPrefix: "## ",
}
},
computed: {
displayIngredient: function() {
if (this.isHeader()) {
return this.ingredient.substring(this.headerPrefix.length)
}
return this.ingredient
},
},
methods: {
isHeader: function() {
if (this.ingredient.startsWith(this.headerPrefix)) {
return true
}
return false
}
}
}
</script>
<style scoped>
li {
margin-left: 1.25em;
}
li.header {
position: relative;
left: -1.25em;
margin-top: 0.25em;
list-style-type: none;
font-variant: small-caps;
}
</style>

Просмотреть файл

@ -0,0 +1,67 @@
<template>
<li :class="{ 'done': isDone }" @click="toggleDone">{{ instruction }}</li>
</template>
<script>
export default {
name: 'RecipeInstruction',
props: ['instruction'],
data () {
return {
isDone: false
}
},
methods: {
toggleDone: function() {
this.isDone = !this.isDone
},
},
}
</script>
<style scoped>
li {
margin-left: 1em;
cursor: pointer;
counter-increment: instruction-counter;
clear: both;
margin-bottom: 2rem;
/* I find this more convenient than a ln2br-function */
white-space: pre-line;
}
li:before {
content: counter(instruction-counter);
float: left;
margin: 0 1rem 1rem 0;
height: 36px;
width: 36px;
border-radius: 50%;
border: 1px solid var(--color-border-dark);
outline: none;
background-repeat: no-repeat;
background-position: center;
background-color: var(--color-background-dark);
line-height: 36px;
text-align: center;
margin-top: -6px;
}
li:hover::before {
border-color: var(--color-primary-element);
}
li.done::before {
content: '✔';
}
li span,
li input[type="checkbox"] {
line-height: 1rem;
margin: 0 0.5rem 0 0;
padding: 0;
height: auto;
width: 1rem;
display: inline-block;
vertical-align: middle;
}
</style>

Просмотреть файл

@ -0,0 +1,140 @@
<template>
<div class="time">
<button v-if="this.timer" type="button" :class="countdown===null ? 'icon-play' : 'icon-pause'" @click="timerToggle"></button>
<h4>{{ t(label) }}</h4>
<p>{{ displayTime }}</p>
</div>
</template>
<script>
export default {
name: 'RecipeTimer',
props: ['value', 'phase', 'label', 'timer'],
data () {
return {
countdown: null,
hours: 0,
minutes: 0,
seconds: 0,
showFullTime: false,
}
},
computed: {
displayTime: function() {
let text = ''
if (this.showFullTime) {
text += this.hours.toString().padStart(2, '0') + ':'
} else {
text += this.hours.toString() + ':'
}
text += this.minutes.toString().padStart(2, '0')
if (this.showFullTime) {
text += ':' + this.seconds.toString().padStart(2, '0')
}
return text
}
},
methods: {
onTimerEnd: function(button) {
window.clearInterval(this.countdown)
// I'll just use an alert until this functionality is finished
let $this = this
window.setTimeout(function() {
// The short timeout is needed or Vue doesn't have time to update the countdown
// display to display 00:00:00
alert($this.t('Cooking time is up!'))
//cookbook.notify($this.t('Cooking time is up!'))
$this.countdown = null
$this.showFullTime = false
$this.resetTimeDisplay()
}, 100)
},
resetTimeDisplay: function() {
if (this.value.hours) {
this.hours = parseInt(this.value.hours)
} else {
this.hours = 0
}
if (this.value.minutes) {
this.minutes = parseInt(this.value.minutes)
} else {
this.minutes = 0
}
this.seconds = 0
},
timerToggle: function() {
// We will switch to full time display the first time this method is invoked.
// There should probably also be a way to reset the timer other than by letting
// it run its course...
if (!this.showFullTime) {
this.showFullTime = true
}
if (this.countdown === null) {
// Pass this to callback function
let $this = this
this.countdown = window.setInterval(function() {
$this.seconds--
if ($this.seconds < 0) {
$this.seconds = 59
$this.minutes--
}
if ($this.minutes < 0) {
$this.minutes = 59
$this.hours--
}
if ($this.hours === 0 && $this.minutes === 0 && $this.seconds === 0) {
$this.onTimerEnd()
}
}, 1000)
} else {
window.clearInterval(this.countdown)
this.countdown = null
}
},
},
mounted () {
this.resetTimeDisplay()
},
}
</script>
<style scoped>
.time {
position: relative;
flex-grow: 1;
border: 1px solid var(--color-border-dark);
border-radius: 3px;
margin: 1rem 2rem;
text-align: center;
font-size: 1.2rem;
}
.time button {
position: absolute;
top: 0;
left: 0;
transform: translate(-50%, -50%);
height: 36px;
width: 36px;
}
.time h4 {
font-weight: bold;
border-bottom: 1px solid var(--color-border-dark);
background-color: var(--color-background-dark);
padding: 0.5rem;
}
.time p {
padding: 0.5rem;
}
@media print {
button {
display: none !important;
}
}
</style>

Просмотреть файл

@ -0,0 +1,19 @@
<template>
<li>{{ tool }}</li>
</template>
<script>
export default {
name: 'RecipeTool',
props: ['tool'],
}
</script>
<style scoped>
li {
margin-left: 1.25em;
}
</style>

Просмотреть файл

@ -0,0 +1,242 @@
<template>
<div class="wrapper">
<RecipeImages v-if="$store.state.recipe" />
<div v-if="$store.state.recipe" class="content">
<h2>{{ $store.state.recipe.name }}</h2>
<div class="details">
<p class="description">{{ $store.state.recipe.description }}</p>
<p v-if="$store.state.recipe.url">
<strong>{{ t('Source') }}: </strong><a target="_blank" :href="$store.state.recipe.url">{{ $store.state.recipe.url }}</a>
</p>
<p><strong>{{ t('Servings') }}: </strong>{{ $store.state.recipe.recipeYield }}</p>
</div>
<div class="times">
<RecipeTimer v-if="timerPrep" :value="timerPrep" :phase="'prep'" :timer="false" :label="'Preparation time'" />
<RecipeTimer v-if="timerCook" :value="timerCook" :phase="'prep'" :timer="true" :label="'Cooking time'" />
<RecipeTimer v-if="timerTotal" :value="timerTotal" :phase="'total'" :timer="false" :label="'Total time'" />
</div>
<section>
<aside>
<section>
<h3 v-if="ingredients.length">{{ t('Ingredients') }}</h3>
<ul v-if="ingredients.length">
<RecipeIngredient v-for="(ingredient,idx) in ingredients" :key="'ingr'+idx" :ingredient="ingredient" />
</ul>
</section>
<section>
<h3 v-if="tools.length">{{ t('Tools') }}</h3>
<ul v-if="tools.length">
<RecipeTool v-for="(tool,idx) in tools" :key="'tool'+idx" :tool="tool" />
</ul>
</section>
</aside>
<main v-if="instructions.length">
<h3>{{ t('Instructions') }}</h3>
<ol class="instructions">
<RecipeInstruction v-for="(instruction,idx) in instructions" :key="'instr'+idx" :instruction="instruction" />
</ol>
</main>
</section>
</div>
</div>
</template>
<script>
import RecipeImages from './RecipeImages'
import RecipeIngredient from './RecipeIngredient'
import RecipeInstruction from './RecipeInstruction'
import RecipeTimer from './RecipeTimer'
import RecipeTool from './RecipeTool'
export default {
name: 'RecipeView',
components: {
RecipeImages,
RecipeIngredient,
RecipeInstruction,
RecipeTimer,
RecipeTool,
},
data () {
return {
// Own properties
ingredients: [],
instructions: [],
timerCook: null,
timerPrep: null,
timerTotal: null,
tools: [],
}
},
methods: {
setup: function() {
if (!this.$store.state.recipe) {
// Make the control row show that a recipe is loading
this.$store.dispatch('setLoadingRecipe', { recipe: -1 })
} else if (this.$store.state.recipe.id === parseInt(this.$route.params.id)) {
// Make the control row show that the recipe is reloading
this.$store.dispatch('setReloadingRecipe', {
recipe: this.$route.params.id
})
} else {
// Make the control row show that a new recipe is loading
this.$store.dispatch('setLoadingRecipe', { recipe: this.$route.params.id })
}
let $this = this
$.ajax({
url: this.$window.baseUrl + '/api/recipes/'+this.$route.params.id,
method: 'GET',
data: null,
}).done(function (recipe) {
// Store recipe data in vuex
$this.$store.dispatch('setRecipe', { recipe: recipe })
if ($this.$store.state.recipe.recipeIngredient) {
$this.ingredients = $this.$store.state.recipe.recipeIngredient
}
if ($this.$store.state.recipe.recipeInstructions) {
$this.instructions = $this.$store.state.recipe.recipeInstructions
}
if ($this.$store.state.recipe.cookTime) {
let cookT = $this.$store.state.recipe.cookTime.match(/PT(\d+?)H(\d+?)M/)
$this.timerCook = { hours: parseInt(cookT[1]), minutes: parseInt(cookT[2]) }
}
if ($this.$store.state.recipe.prepTime) {
let prepT = $this.$store.state.recipe.prepTime.match(/PT(\d+?)H(\d+?)M/)
$this.timerPrep = { hours: parseInt(prepT[1]), minutes: parseInt(prepT[2]) }
}
if ($this.$store.state.recipe.totalTime) {
let totalT = $this.$store.state.recipe.totalTime.match(/PT(\d+?)H(\d+?)M/)
$this.timerTotal = { hours: parseInt(totalT[1]), minutes: parseInt(totalT[2]) }
}
if ($this.$store.state.recipe.tool) {
$this.tools = $this.$store.state.recipe.tool
}
// Always set the active page last!
$this.$store.dispatch('setPage', { page: 'recipe' })
}).fail(function(e) {
if ($this.$store.state.loadingRecipe) {
// Reset loading recipe
$this.$store.dispatch('setLoadingRecipe', { recipe: 0 })
}
if ($this.$store.state.reloadingRecipe) {
// Reset reloading recipe
$this.$store.dispatch('setReloadingRecipe', { recipe: 0 })
}
$this.$store.dispatch('setPage', { page: 'recipe' })
alert($this.t('Loading recipe failed'))
})
}
},
mounted () {
this.setup()
// Register data load method hook for access from the controls components
this.$root.$off('reloadRecipeView')
this.$root.$on('reloadRecipeView', () => {
this.setup()
})
},
/**
* This is one tricky feature of Vue router. If different paths lead to
* the same component (such as '/recipe/xxx' and '/recipe/yyy)',
* the component will not automatically reload. So we have to manually
* reload the page contents.
* This can also be used to confirm that the user wants to leave the page
* if there are unsaved changes.
*/
beforeRouteUpdate (to, from, next) {
// beforeRouteUpdate is called when the static route stays the same
next()
// Check if we should reload the component content
if (this.$window.shouldReloadContent(from.fullPath, to.fullPath)) {
this.setup()
}
},
}
</script>
<style scoped>
.wrapper {
width: 100%;
}
aside {
flex-basis: 20rem;
padding-right: 2rem;
}
aside ul {
list-style-type: disc;
}
.content {
width: 100%;
padding: 1rem;
flex-basis: 100%;
}
.content aside {
width: 30%;
float: left;
}
main {
flex-basis: calc(100% - 22rem);
width: 70%;
float: left;
text-align: justify;
}
@media(max-width:1199px) { main {
flex-basis: 100%;
width: 100%;
} }
.description {
font-style: italic;
}
.details p {
margin: 0.5em 0
}
section {
margin-bottom: 1rem;
}
section::after {
content: '';
display: table;
clear: both;
}
@media(max-width:1199px) { .recipe-content aside {
display: block;
width: 100%;
float: none;
}}
.instructions {
list-style: none;
padding: 0;
counter-reset: instruction-counter;
margin-top: 2rem;
}
.times {
display: flex;
margin-top: 10px;
}
@media print {
#content {
display: block !important;
padding: 0 !important;
overflow: visible !important;
}
}
</style>

Просмотреть файл

@ -0,0 +1,98 @@
<template>
<ul>
<li v-for="result in results" :key="result.recipe_id">
<router-link :to="'/recipe/'+result.recipe_id">
<img v-if="result.imageUrl" :src="result.imageUrl">
<span>{{ result.name }}</span>
</router-link>
</li>
</ul>
</template>
<script>
export default {
name: "Search",
props: ['query'],
data () {
return {
results: [],
}
},
methods: {
setup: function() {
if (this.query === 'name') {
// Search by name
console.log("Recipe name search for "+this.$route.params.value)
}
if (this.query === 'tag') {
// Search by tag
console.log("Tag search for "+this.$route.params.value)
}
if (this.query === 'cat') {
// Search by category
console.log("Category search for "+this.$route.params.value)
let $this = this
let cat = this.$route.params.value
$.get(this.$window.baseUrl + '/category/'+cat).done(function(json) {
$this.results = json
}).fail(function (jqXHR, textStatus, errorThrown) {
$this.results = []
alert($this.t('Failed to load category '+cat+' recipes'))
if (e && e instanceof Error) {
throw e
}
})
} else {
// Something else?
}
this.$store.dispatch('setPage', { page: 'search' })
},
},
mounted () {
this.setup()
},
beforeRouteUpdate (to, from, next) {
// Move to next route as expected
next()
// Reload view
this.setup()
},
}
</script>
<style scoped>
ul {
display: flex;
flex-wrap: wrap;
flex-direction: row;
width: 100%;
}
ul li {
width: 300px;
max-width: 100%;
margin: 0.5rem 1rem 1rem;
}
ul li a {
display: block;
height: 105px;
box-shadow: 0 0 3px #AAA;
border-radius: 3px;
}
ul li a:hover {
box-shadow: 0 0 5px #888;
}
ul li img {
float: left;
height: 105px;
border-radius: 3px 0 0 3px;
}
ul li span {
display: block;
padding: 0.5rem 0.5em 0.5rem calc(105px + 0.5rem);
}
</style>

189
src/main.js Normal file
Просмотреть файл

@ -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 {
'&': '&amp;',
'"': '&quot;',
"'": '&apos;',
'<': '&lt;',
'>': '&gt;'
}[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, '<br />')
}
// 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)

54
src/router/index.js Normal file
Просмотреть файл

@ -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
})

82
src/store/index.js Normal file
Просмотреть файл

@ -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 })
},
}
})

Просмотреть файл

@ -1,179 +0,0 @@
<form id="editRecipeForm" action="#" method="<?php echo $_['id'] ? 'PUT' : 'POST' ?>">
<div id="controls">
<div class="breadcrumb">
<div class="crumb svg crumbhome ui-droppable">
<a href="#" class="icon-category-organization"></a>
</div>
<div class="crumb svg">
<a href="#recipes/<?php echo $_['id']; ?>"><?php echo $_['id'] ? $_['name'] : p($l->t('New recipe')); ?></a>
</div>
<?php if($_['id']) { ?>
<div class="crumb svg">
<a href="javascript:;"><?php echo p($l->t('Edit')); ?></a>
</div>
<?php } ?>
</div>
<div class="actions">
<button type="submit">
<span class="icon icon-checkmark"></span>
<span class="hidden-visually"><?php p($l->t('Save changes')); ?></span>
</button>
</div>
<div class="actions pull-right">
<a id="edit-recipe" href="#recipes/<?php echo $_['id']; ?>" class="button svg action" title="<?php p($l->t('Cancel')); ?>">
<span class="icon icon-close"></span>
<span class="hidden-visually"><?php p($l->t('Cancel')); ?></span>
</a>
</div>
</div>
<div class="recipe-edit">
<fieldset>
<label><?php /* TRANSLATORS The name of the recipe */
echo p($l->t('Name')); ?></label>
<input required type="text" name="name" value="<?php if(isset($_['name'])) { echo $_['name']; } ?>"></h2>
</fieldset>
<fieldset>
<label><?php /* TRANSLATORS The description of the recipe */
echo p($l->t('Description')); ?></label>
<input type="text" name="description" value="<?php if(isset($_['description'])) { echo $_['description']; } ?>">
</fieldset>
<fieldset>
<label><?php p($l->t('URL')); ?></label>
<input type="url" name="url" value="<?php if(isset($_['url'])) { echo $_['url']; } ?>">
</fieldset>
<fieldset>
<label><?php p($l->t('Image')); ?></label>
<input type="text" name="image" value="<?php if(isset($_['image'])) { echo $_['image']; } ?>"><button type="button" id="pick-image" title="<?php p($l->t('Pick a local image')) ?>"><span class="icon-category-multimedia"></span></button>
</fieldset>
<fieldset class="duration">
<label><?php p($l->t('Preparation time')); ?></label>
<input type="number" min="0" name="prepTime[]" value="<?php if(isset($_['prepTime'])) { echo preg_replace_callback('/PT([0-9]+)H[0-9]+M/', function($m) { return $m[1]; }, $_['prepTime']); } ?>" placeholder="00">
<span>:</span>
<input type="number" min="0" max="59" name="prepTime[]" value="<?php if(isset($_['prepTime'])) { echo preg_replace_callback('/PT[0-9]+H([0-9]+)M/', function($m) { return $m[1]; }, $_['prepTime']); } ?>" placeholder="00">
</fieldset>
<fieldset class="duration">
<label><?php p($l->t('Cooking time')); ?></label>
<input type="number" min="0" name="cookTime[]" value="<?php if(isset($_['cookTime'])) { echo preg_replace_callback('/PT([0-9]+)H[0-9]+M/', function($m) { return $m[1]; }, $_['cookTime']); } ?>" placeholder="00">
<span>:</span>
<input type="number" min="0" max="59" name="cookTime[]" value="<?php if(isset($_['cookTime'])) { echo preg_replace_callback('/PT[0-9]+H([0-9]+)M/', function($m) { return $m[1]; }, $_['cookTime']); } ?>" placeholder="00">
</fieldset>
<fieldset class="duration">
<label><?php p($l->t('Total time')); ?></label>
<input type="number" min="0" name="totalTime[]" value="<?php if(isset($_['totalTime'])) { echo preg_replace_callback('/PT([0-9]+)H[0-9]+M/', function($m) { return $m[1]; }, $_['totalTime']); } ?>" placeholder="00">
<span>:</span>
<input type="number" min="0" max="59" name="totalTime[]" value="<?php if(isset($_['totalTime'])) { echo preg_replace_callback('/PT[0-9]+H([0-9]+)M/', function($m) { return $m[1]; }, $_['totalTime']); } ?>" placeholder="00">
</fieldset>
<fieldset>
<label><?php p($l->t('Category')); ?></label>
<input type="text" name="recipeCategory" value="<?php if(isset($_['recipeCategory'])) { echo $_['recipeCategory']; } ?>">
</fieldset>
<fieldset>
<label><?php p($l->t('Keywords (comma-separated)')); ?></label>
<input type="text" name="keywords" value="<?php if(isset($_['keywords'])) { echo $_['keywords']; } ?>">
</fieldset>
<fieldset>
<label><?php p($l->t('Servings')); ?></label>
<input type="number" name="recipeYield" value="<?php if(isset($_['recipeYield'])) { echo $_['recipeYield']; } ?>">
</fieldset>
<fieldset>
<label><?php p($l->t('Tools')); ?></label>
<ul>
<template>
<li class="input-group">
<input type="text" name="tool[]" value="">
<div class="input-group-addon">
<button class="icon-arrow-up move-list-item-up"></button>
<button class="icon-arrow-down move-list-item-down"></button>
<button class="icon-delete right remove-list-item"></button>
</div>
</li>
</template>
<?php if(isset($_['tool']) && is_array($_['tool'])) { ?>
<?php foreach ($_['tool'] as $i => $tool) { ?>
<li class="input-group">
<input type="text" name="tool[]" value="<?php echo $tool; ?>">
<div class="input-group-addon">
<button class="icon-arrow-up move-list-item-up"></button>
<button class="icon-arrow-down move-list-item-down"></button>
<button class="icon-delete right remove-list-item"></button>
</div>
</li>
<?php } ?>
<?php } ?>
</ul>
<button class="button add-list-item"><span class="icon-add"></span></button>
</fieldset>
<fieldset>
<label><?php p($l->t('Ingredients')); ?></label>
<ul>
<template>
<li class="input-group">
<input type="text" name="recipeIngredient[]" value="">
<div class="input-group-addon">
<button class="icon-arrow-up move-list-item-up"></button>
<button class="icon-arrow-down move-list-item-down"></button>
<button class="icon-delete right remove-list-item"></button>
</div>
</li>
</template>
<?php if(isset($_['recipeIngredient']) && is_array($_['recipeIngredient'])) { ?>
<?php foreach ($_['recipeIngredient'] as $i => $ingredient) { ?>
<li class="input-group">
<input type="text" name="recipeIngredient[]" value="<?php echo $ingredient; ?>">
<div class="input-group-addon">
<button class="icon-arrow-up move-list-item-up"></button>
<button class="icon-arrow-down move-list-item-down"></button>
<button class="icon-delete right remove-list-item"></button>
</div>
</li>
<?php } ?>
<?php } ?>
</ul>
<button class="button add-list-item"><span class="icon-add"></span></button>
</fieldset>
<fieldset>
<label><?php p($l->t('Instructions')); ?></label>
<ul>
<template>
<li class="textarea-group">
<div class="step-number"></div>
<div class="textarea-group-addon">
<button class="icon-arrow-up move-list-item-up"></button>
<button class="icon-arrow-down move-list-item-down"></button>
<button class="icon-delete right remove-list-item"></button>
</div>
<textarea name="recipeInstructions[]"></textarea>
</li>
</template>
<?php if(isset($_['recipeInstructions']) && is_array($_['recipeInstructions'])) { ?>
<?php foreach ($_['recipeInstructions'] as $i => $step) { ?>
<li class="textarea-group">
<div class="step-number"><?php echo ($i + 1); ?>.</div>
<div class="textarea-group-addon">
<button class="icon-arrow-up move-list-item-up"></button>
<button class="icon-arrow-down move-list-item-down"></button>
<button class="icon-delete right remove-list-item"></button>
</div>
<textarea name="recipeInstructions[]"><?php echo $step; ?></textarea>
</li>
<?php } ?>
<?php } ?>
</ul>
<button class="button add-list-item"><span class="icon-add"></span></button>
</fieldset>
</div>
</form>

Просмотреть файл

@ -1,125 +0,0 @@
<div id="controls">
<div class="breadcrumb">
<div class="crumb svg crumbhome ui-droppable">
<a href="#" class="icon-category-organization"></a>
</div>
<div class="crumb svg">
<a href="#recipes/<?php echo $_['id']; ?>"><?php echo $_['name']; ?></a>
</div>
</div>
<div class="actions">
<a id="edit-recipe" href="#recipes/<?php echo $_['id']; ?>/edit" class="button svg action" title="<?php p($l->t('Edit recipe')); ?>">
<span class="icon icon-rename"></span>
<span class="hidden-visually"><?php p($l->t('Edit recipe')); ?></span>
</a>
</div>
<div class="actions">
<button id="print-recipe" class="button svg action" title="<?php p($l->t('Print recipe')); ?>">
<span class="icon icon-category-office"></span>
<span class="hidden-visually"><?php p($l->t('Print recipe')); ?></span>
</button>
</div>
<div class="actions">
<button id="delete-recipe" class="button svg action" data-id="<?php echo $_['id']; ?>" title="<?php p($l->t('Delete recipe')); ?>">
<span class="icon icon-delete"></span>
<span class="hidden-visually"><?php p($l->t('Delete recipe')); ?></span>
</button>
</div>
</div>
<?php if(isset($_['image']) && $_['image']) { ?>
<header class="collapsed<?php if($_['print_image']) echo ' printable'; ?>">
<img src="<?php echo $_['image_url']; ?>">
</header>
<?php } ?>
<div class="recipe-content">
<h2><?php echo $_['name']; ?></h2>
<div class="recipe-details">
<p><?php echo $_['description']; ?></p>
<?php if(isset($_['url']) && $_['url']) { ?>
<p><strong><?php p($l->t('Source')); ?>: </strong><a target="_blank" href="<?php echo $_['url']; ?>"><?php echo $_['url']; ?></a></p>
<?php } ?>
<p><strong><?php p($l->t('Servings')); ?>: </strong><?php echo $_['recipeYield']; ?></p>
<div class="times">
<?php if(isset($_['prepTime']) && $_['prepTime']) {
$prep_interval = new DateInterval($_['prepTime']);
$prep_mins = $prep_interval->format('%I');
$prep_hours = $prep_interval->format('%h');
if ($prep_hours > 0 || $prep_mins > 0) {
?>
<div class="time" data-raw="<?php echo $_['prepTime']; ?>">
<h4><?php p($l->t('Preparation time')); ?></h4>
<p><?php echo $prep_hours . ':' . $prep_mins; ?></p>
</div>
<?php }} ?>
<?php if(isset($_['cookTime']) && $_['cookTime']) {
$cook_interval = new DateInterval($_['cookTime']);
$cook_mins = $cook_interval->format('%I');
$cook_hours = $cook_interval->format('%h');
if ($cook_hours > 0 || $cook_mins > 0) {
?>
<div class="time" data-raw="<?php echo $_['cookTime']; ?>">
<button type="button" class="icon-play" data-hours="<?php echo $cook_hours ?>" data-minutes="<?php echo $cook_mins ?>"></button>
<h4><?php p($l->t('Cooking time')); ?></h4>
<p><?php echo $cook_hours . ':' . $cook_mins; ?></p>
</div>
<?php }} ?>
<?php
if(isset($_['totalTime']) && $_['totalTime']) {
$total_interval = new DateInterval($_['totalTime']);
$total_mins = $total_interval->format('%I');
$total_hours = $total_interval->format('%h');
if ($total_hours > 0 || $total_mins > 0) {
?>
<div class="time" data-raw="<?php echo $_['totalTime']; ?>">
<h4><?php p($l->t('Total time')); ?></h4>
<p><?php echo $total_hours . ':' . $total_mins; ?></p>
</div>
<?php }} ?>
</div>
</div>
</div>
<section>
<aside>
<section>
<?php if(!empty($_['recipeIngredient'])) { ?>
<h3><?php p($l->t('Ingredients')); ?></h3>
<ul>
<?php foreach($_['recipeIngredient'] as $ingredient) { ?>
<li><?php echo $ingredient; ?></li>
<?php } ?>
</ul>
<?php } ?>
</section>
<section>
<?php if(!empty($_['tool'])) { ?>
<h3><?php p($l->t('Tools')); ?></h3>
<ul>
<?php foreach($_['tool'] as $tools) { ?>
<li><?php echo $tools; ?></li>
<?php } ?>
</ul>
<?php } ?>
</section>
</aside>
<?php if(!empty($_['recipeInstructions'])) { ?>
<main>
<h3><?php p($l->t('Instructions')); ?></h3>
<ol class="instructions">
<?php foreach($_['recipeInstructions'] as $step) { ?>
<li class="instruction"><?php echo nl2br($step); ?></li>
<?php } ?>
</ol>
</main>
<?php } ?>
</section>

Просмотреть файл

@ -1,33 +0,0 @@
<div class="home">
<h2>
<?php if (isset($_['query'])) { ?>
<?php p($l->t('Search')); ?> <small><?php echo $_['query']; ?></small>
<?php } elseif (isset($_['tag'])) { ?>
<?php p($l->t('Tag')); ?> <small><?php echo $_['tag']; ?></small>
<?php } elseif (isset($_['category'])) { ?>
<?php p($l->t('Category')); ?> <small><?php echo $_['category']; ?></small>
<?php } else { ?>
<?php p($l->t('All recipes')); ?>
<?php } ?>
</h2>
<?php if (empty($_['recipes'])) { ?>
<p>
<em><?php p($l->t('No results')); ?></em>
</p>
<?php } else { ?>
<ul id="search">
<?php foreach ($_['recipes'] as $i => $recipe) { ?>
<li>
<a href="#recipes/<?php echo $recipe['recipe_id']; ?>">
<?php if(isset($recipe['image_url']) && $recipe['image_url']) { ?>
<img src="<?php echo $recipe['image_url']; ?>">
<?php } ?>
<span>
<?php echo $recipe['name']; ?>
</span>
</a>
</li>
<?php } ?>
</ul>
<?php } ?>
</div>

Просмотреть файл

@ -1,20 +1,7 @@
<?php <?php
script('cookbook', 'script'); script('cookbook', 'vue');
style('cookbook', 'style'); style('cookbook', 'style');
?> ?>
<div id="app"> <div id="app">
<div id="app-navigation">
<?php print_unescaped($this->inc('navigation/index')); ?>
<?php print_unescaped($this->inc('settings/index')); ?>
</div>
<div id="app-content">
<div id="app-content-wrapper">
<div class="loader">
<span class="icon-loading"></span>
</div>
</div>
</div>
</div> </div>

Просмотреть файл

@ -1,6 +0,0 @@
<div class="home">
<h2><?php p($l->t('Error')); ?></h2>
<div class="feature icon-error">
<?php p($l->t('This page doesn\'t exist.')); ?>
</div>
</div>

Просмотреть файл

@ -1,15 +0,0 @@
<div class="app-navigation-create">
<a href="#recipes/create" title="<?php p($l->t('Create recipe')); ?>" class="button icon-add"><?php p($l->t('Create recipe')); ?></a>
</div>
<form id="import-recipe" class="app-navigation-new" method="POST">
<input name="url" placeholder="<?php p($l->t('Recipe URL')); ?>">
<button type="submit" title="<?php p($l->t('Download recipe')); ?>">
<div class="icon-download"></div>
<div class="icon-loading float-spinner"></div>
</button>
</form>
<ul id="categories">
</ul>

Просмотреть файл

@ -1,8 +0,0 @@
<?php foreach($_['recipes'] as $recipe) { ?>
<li>
<a href="#recipes/<?php echo $recipe['recipe_id']; ?>">
<img src="<?php echo $recipe['imageURL']; ?>">
<?php echo $recipe['name']; ?>
</a>
</li>
<?php } ?>

Просмотреть файл

@ -1,35 +0,0 @@
<div id="app-settings">
<div id="app-settings-header">
<button class="settings-button" data-apps-slide-toggle="#app-settings-content"><?php p($l->t('Settings')); ?></button>
</div>
<div id="app-settings-content">
<fieldset class="settings-fieldset">
<ul class="settings-fieldset-interior">
<li class="settings-fieldset-interior-item">
<button class="button icon-history" id="reindex-recipes"><?php p($l->t('Rescan library')); ?></button>
</li>
<li class="settings-fieldset-interior-item">
<label class="settings-input"><?php p($l->t('Recipe folder')); ?></label>
<input id="recipe-folder" type="text" class="input settings-input" value="<?php echo $_['folder']; ?>" placeholder="<?php p($l->t('Please pick a folder')); ?>">
</li>
<li class="settings-fieldset-interior-item">
<label class="settings-input">
<?php p($l->t('Update interval in minutes')); ?>
</label>
<div class="input-group">
<input id="recipe-update-interval" type="number" class="input settings-input" value="<?php echo $_['update_interval']; ?>" placeholder="<?php echo $_['update_interval']; ?>">
<div class="input-group-addon">
<button class="icon-info" disabled="disabled" title="<?php p($l->t('Last update:')); ?> <?php echo date('Y-m-d H:i', $_['last_update']); ?>"></button>
</div>
</div>
</li>
<li class="settings-fieldset-interior-item">
<input id="recipe-print-image" type="checkbox" class="checkbox"<?php if($_['print_image']) echo 'checked="checked"'; ?>>
<label class="settings-input" for="recipe-print-image">
<?php p($l->t('Print image with recipe')); ?>
</label>
</li>
</ul>
</fieldset>
</div>
</div>

7
webpack.build.js Normal file
Просмотреть файл

@ -0,0 +1,7 @@
const merge = require('webpack-merge')
const common = require('./webpack.config.js')
module.exports = merge(common, {
mode: 'production',
devtool: 'source-map',
})

65
webpack.config.js Normal file
Просмотреть файл

@ -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,
},
}

7
webpack.devel.js Normal file
Просмотреть файл

@ -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',
})