зеркало из https://github.com/nextcloud/cookbook.git
Merging in 0.7.0
This commit is contained in:
Коммит
43ec32917c
|
@ -4,3 +4,13 @@
|
|||
cookbook.tar.gz
|
||||
|
||||
.idea/
|
||||
|
||||
# Built js package
|
||||
js/*
|
||||
|
||||
### NPM ###
|
||||
|
||||
# Node modules
|
||||
node_modules
|
||||
# Package lock
|
||||
package-lock.json
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<name>Cookbook</name>
|
||||
<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>
|
||||
<version>0.6.5</version>
|
||||
<version>0.7.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author mail="mrzapp@users.noreply.github.com" >Jeppe Zapp</author>
|
||||
<namespace>Cookbook</namespace>
|
||||
|
|
|
@ -25,6 +25,7 @@ return [
|
|||
['name' => 'recipe#image', 'url' => '/recipes/{id}/image', 'verb' => 'GET', 'requirements' => ['id' => '\d+']],
|
||||
['name' => 'config#reindex', 'url' => '/reindex', 'verb' => 'POST'],
|
||||
['name' => 'config#config', 'url' => '/config', 'verb' => 'POST'],
|
||||
/* API routes */
|
||||
],
|
||||
'resources' => [
|
||||
'recipe' => ['url' => '/api/recipes']
|
||||
|
|
295
css/style.css
295
css/style.css
|
@ -1,82 +1,3 @@
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
|
@ -84,96 +5,22 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation
|
||||
*/
|
||||
#app-navigation {}
|
||||
#app-navigation .app-navigation-create {
|
||||
padding: 10px;
|
||||
.app-navigation-new button {
|
||||
min-height: 44px !important;
|
||||
background-image: var(--icon-add-000) !important;
|
||||
background-repeat: no-repeat !important;
|
||||
}
|
||||
|
||||
#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-entry *:not(.app-navigation-entry-icon) {
|
||||
background: initial !important;
|
||||
}
|
||||
|
||||
#app-navigation li {
|
||||
background-position: 10px center;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
.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 .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-content-wrapper {
|
||||
flex-wrap: wrap;
|
||||
.app-navigation-entry:hover,
|
||||
.app-navigation-entry.router-link-exact-active {
|
||||
opacity: 1 !important;
|
||||
box-shadow: inset 4px 0 var(--color-primary) !important;
|
||||
}
|
||||
.home {
|
||||
padding: 1rem;
|
||||
|
@ -437,122 +284,14 @@
|
|||
width: 100%;
|
||||
}}
|
||||
|
||||
#app-content-wrapper form fieldset > input[name="image"] {
|
||||
width: calc(100% - 14em);
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-right: 0;
|
||||
.app-navigation-entry:not(:hover) li.recipe {
|
||||
box-shadow: inset 4px 0 rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
@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-navigation-entry:hover li.recipe {
|
||||
box-shadow: inset 4px 0 rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > input[name="image"] + button > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > label {
|
||||
display: inline-block;
|
||||
width: 10em;
|
||||
line-height: 18px;
|
||||
font-weight: bold;
|
||||
float: left;
|
||||
}
|
||||
@media(max-width:1199px) { #app-content-wrapper form fieldset > label {
|
||||
display: block;
|
||||
float: none;
|
||||
}}
|
||||
|
||||
#app-content-wrapper form fieldset ul label input[type="checkbox"] {
|
||||
margin-left: 1em;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul {
|
||||
margin-top: 2rem;
|
||||
padding-left: 1em;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul + button {
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
float: right;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul > li {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul > li > input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul > li > button {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
margin-top: 8px;
|
||||
margin-right: 9px;
|
||||
z-index: 10;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul > li > textarea {
|
||||
min-height: 10em;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
#app-content-wrapper form fieldset > ul > li .step-number {
|
||||
padding: 6px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#app-content-wrapper form button[type="submit"] {
|
||||
margin-left: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Print
|
||||
*/
|
||||
@media print {
|
||||
#header,
|
||||
#app-navigation,
|
||||
#controls,
|
||||
.recipe-toolbar,
|
||||
#app-content header:not(.printable),
|
||||
.times button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#content,
|
||||
#app-content-wrapper {
|
||||
display: block !important;
|
||||
padding: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
#app-content {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
a:link:after,
|
||||
a:visited:after {
|
||||
content:" [" attr(href) "] ";
|
||||
|
|
747
js/script.js
747
js/script.js
|
@ -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);
|
|
@ -145,12 +145,10 @@ class MainController extends Controller
|
|||
public function category($category)
|
||||
{
|
||||
$category = urldecode($category);
|
||||
|
||||
try {
|
||||
$recipes = $this->service->getRecipesByCategory($category);
|
||||
|
||||
foreach ($recipes as $i => $recipe) {
|
||||
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute(
|
||||
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute(
|
||||
'cookbook.recipe.image',
|
||||
[
|
||||
'id' => $recipe['recipe_id'],
|
||||
|
@ -160,10 +158,7 @@ class MainController extends Controller
|
|||
);
|
||||
}
|
||||
|
||||
$response = new TemplateResponse($this->appName, 'content/search', ['recipes' => $recipes]);
|
||||
$response->renderAs('blank');
|
||||
|
||||
return $response;
|
||||
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
|
||||
} catch (\Exception $e) {
|
||||
return new DataResponse($e->getMessage(), 500);
|
||||
}
|
||||
|
@ -187,7 +182,7 @@ class MainController extends Controller
|
|||
);
|
||||
$recipe['id'] = $id;
|
||||
$recipe['print_image'] = $this->service->getPrintImage();
|
||||
$response = new TemplateResponse($this->appName, 'content/recipe', $recipe);
|
||||
$response = new TemplateResponse($this->appName, 'content/recipe_vue', $recipe);
|
||||
$response->renderAs('blank');
|
||||
|
||||
return $response;
|
||||
|
|
|
@ -46,7 +46,7 @@ class RecipeController extends Controller
|
|||
$recipes = $this->service->findRecipesInSearchIndex(isset($_GET['keywords']) ? $_GET['keywords'] : '');
|
||||
}
|
||||
foreach ($recipes as $i => $recipe) {
|
||||
$recipes[$i]['image_url'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb']);
|
||||
$recipes[$i]['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $recipe['recipe_id'], 'size' => 'thumb']);
|
||||
}
|
||||
return new DataResponse($recipes, Http::STATUS_OK, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
@ -64,6 +64,7 @@ class RecipeController extends Controller
|
|||
if (null === $json) {
|
||||
return new DataResponse($id, Http::STATUS_NOT_FOUND, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
$json['imageUrl'] = $this->urlGenerator->linkToRoute('cookbook.recipe.image', ['id' => $json['id'], 'size' => 'full']);
|
||||
return new DataResponse($json, Http::STATUS_OK, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@ use OCP\Files\NotFoundException;
|
|||
use OCP\Files\NotPermittedException;
|
||||
use OCP\Image;
|
||||
use OCP\IConfig;
|
||||
use OCP\IL10N;
|
||||
use OCP\Files\IRootFolder;
|
||||
use OCP\Files\FileInfo;
|
||||
use OCP\Files\File;
|
||||
|
@ -26,13 +27,15 @@ class RecipeService
|
|||
private $user_id;
|
||||
private $db;
|
||||
private $config;
|
||||
private $il10n;
|
||||
|
||||
public function __construct(?string $UserId, IRootFolder $root, RecipeDb $db, IConfig $config)
|
||||
public function __construct(?string $UserId, IRootFolder $root, RecipeDb $db, IConfig $config, IL10N $il10n)
|
||||
{
|
||||
$this->user_id = $UserId;
|
||||
$this->root = $root;
|
||||
$this->db = $db;
|
||||
$this->config = $config;
|
||||
$this->il10n = $il10n;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -113,8 +116,17 @@ class RecipeService
|
|||
*/
|
||||
public function checkRecipe(array $json): array
|
||||
{
|
||||
if (!$json) { throw new Exception('Recipe array was null'); }
|
||||
if (empty($json['name'])) { throw new Exception('Field "name" is required'); }
|
||||
if (!$json) {
|
||||
throw new Exception('Recipe array was null');
|
||||
}
|
||||
|
||||
if (empty($json['name'])) {
|
||||
throw new Exception('Field "name" is required');
|
||||
}
|
||||
|
||||
if (strpos(empty($json['name']), '/') !== false) {
|
||||
throw new Exception('Illegal characters in recipe name');
|
||||
}
|
||||
|
||||
// Make sure the schema.org fields are present
|
||||
$json['@context'] = 'http://schema.org';
|
||||
|
@ -165,6 +177,15 @@ class RecipeService
|
|||
$json['image'] = '';
|
||||
}
|
||||
|
||||
// The image is a URL without a scheme, fix it
|
||||
if (strpos($json['image'], '//') === 0) {
|
||||
if(isset($json['url']) && strpos($json['url'], 'https') === 0) {
|
||||
$json['image'] = 'https:' . $json['image'];
|
||||
} else {
|
||||
$json['image'] = 'http:' . $json['image'];
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up the image URL string
|
||||
$json['image'] = stripslashes($json['image']);
|
||||
|
||||
|
@ -337,6 +358,7 @@ class RecipeService
|
|||
$json['url'] = "";
|
||||
}
|
||||
|
||||
// Parse duration fields
|
||||
$durations = ['prepTime', 'cookTime', 'totalTime'];
|
||||
$duration_patterns = [
|
||||
'/P.*T(\d+H)?(\d+M)?/', // ISO 8601
|
||||
|
@ -369,6 +391,11 @@ class RecipeService
|
|||
}
|
||||
}
|
||||
|
||||
while($duration_minutes >= 60) {
|
||||
$duration_minutes -= 60;
|
||||
$duration_hours++;
|
||||
}
|
||||
|
||||
$json[$duration] = 'PT' . $duration_hours . 'H' . $duration_minutes . 'M';
|
||||
}
|
||||
|
||||
|
@ -419,6 +446,11 @@ class RecipeService
|
|||
}
|
||||
}
|
||||
|
||||
// Check if json is an array for some reason
|
||||
if($json && isset($json[0])) {
|
||||
$json = $json[0];
|
||||
}
|
||||
|
||||
if (!$json || !isset($json['@type']) || $json['@type'] !== 'Recipe') {
|
||||
continue;
|
||||
}
|
||||
|
@ -463,8 +495,19 @@ class RecipeService
|
|||
|
||||
if(!isset($json[$prop]) || !is_array($json[$prop])) { $json[$prop] = []; }
|
||||
|
||||
if(!empty($prop_element->getAttribute('src'))) {
|
||||
$src = $prop_element->getAttribute('src');
|
||||
|
||||
} else if(
|
||||
null !== $prop_element->getAttributeNode('content') &&
|
||||
!empty($prop_element->getAttributeNode('content')->value)
|
||||
) {
|
||||
$src = $prop_element->getAttributeNode('content')->value;
|
||||
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
array_push($json[$prop], $src);
|
||||
break;
|
||||
|
||||
|
@ -474,7 +517,10 @@ class RecipeService
|
|||
|
||||
if(!isset($json[$prop]) || !is_array($json[$prop])) { $json[$prop] = []; }
|
||||
|
||||
if(null !== $prop_element->getAttributeNode('content')) {
|
||||
if(
|
||||
null !== $prop_element->getAttributeNode('content') &&
|
||||
!empty($prop_element->getAttributeNode('content')->value)
|
||||
) {
|
||||
array_push($json[$prop], $prop_element->getAttributeNode('content')->value);
|
||||
} else {
|
||||
array_push($json[$prop], $prop_element->nodeValue);
|
||||
|
@ -612,6 +658,16 @@ class RecipeService
|
|||
|
||||
}
|
||||
}
|
||||
|
||||
// The image field was empty, remove images in the recipe folder
|
||||
} else {
|
||||
if($recipe_folder->nodeExists('full.jpg')) {
|
||||
$recipe_folder->get('full.jpg')->delete();
|
||||
}
|
||||
|
||||
if($recipe_folder->nodeExists('thumb.jpg')) {
|
||||
$recipe_folder->get('thumb.jpg')->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// If image data was fetched, write it to disk
|
||||
|
@ -640,6 +696,14 @@ class RecipeService
|
|||
$thumb_image_file->putContent($thumb_image->data());
|
||||
}
|
||||
|
||||
// Write .nomedia file to avoid gallery indexing
|
||||
if(!$recipe_folder->nodeExists('.nomedia')) {
|
||||
$recipe_folder->newFile('.nomedia');
|
||||
}
|
||||
|
||||
// Make sure the directory has been marked as changed
|
||||
$recipe_folder->touch();
|
||||
|
||||
return $recipe_file;
|
||||
}
|
||||
|
||||
|
@ -867,7 +931,7 @@ class RecipeService
|
|||
$path = $this->config->getUserValue($this->user_id, 'cookbook', 'folder');
|
||||
|
||||
if (!$path) {
|
||||
$path = '/Recipes';
|
||||
$path = '/' . $this->il10n->t('Recipes');
|
||||
}
|
||||
|
||||
return $path;
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,189 @@
|
|||
/**
|
||||
* Nextcloud Cookbook app
|
||||
* Vue frontend entry file
|
||||
* ---------------------------
|
||||
* @license AGPL3 or later
|
||||
*/
|
||||
|
||||
// TODO: Agree on a markdown parser
|
||||
// TODO: Remove dependency on jQuery
|
||||
|
||||
import Vue from 'vue'
|
||||
import router from './router'
|
||||
import store from './store'
|
||||
|
||||
//import AppNavi from './components/AppNavi'
|
||||
import AppMain from './components/AppMain'
|
||||
|
||||
(function (OC, window, $, undefined) {
|
||||
'use strict'
|
||||
|
||||
// Fetch Nextcloud nonce identifier for dynamic script loading
|
||||
__webpack_nonce__ = btoa(OC.requestToken)
|
||||
|
||||
window.baseUrl = OC.generateUrl('apps/cookbook')
|
||||
|
||||
// Check if two routes point to the same component but have different content
|
||||
window.shouldReloadContent = function(url1, url2) {
|
||||
if (url1 === url2) {
|
||||
return false // Obviously should not if both routes are the same
|
||||
}
|
||||
|
||||
let comps1 = url1.split('/')
|
||||
let comps2 = url2.split('/')
|
||||
|
||||
if (comps1.length < 2 || comps2.length < 2) {
|
||||
return false // Just a failsafe, this should never happen
|
||||
}
|
||||
|
||||
// The route structure is as follows:
|
||||
// - /{item}/:id View
|
||||
// - /{item}/:id/edit Edit
|
||||
// - /{item}/create Create
|
||||
// If the items are different, then the router automatically handles
|
||||
// component loading: do not manually reload
|
||||
if (comps1[1] !== comps2[1]) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If one of the routes is edit and the other is not
|
||||
if (comps1.length !== comps2.length) {
|
||||
// Only reload if changing from edit to create
|
||||
if (comps1.pop() === 'create' || comps2.pop() === 'create') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
} else if (comps1.pop() === 'create') {
|
||||
// But, if we are moving from create to view, do not reload
|
||||
// the create component
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
// Only options left are that both of the routes are edit or view,
|
||||
// but not identical, or that we're moving from view to create
|
||||
// -> reload view
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if the two urls point to the same item instance
|
||||
window.isSameItemInstance = function(url1, url2) {
|
||||
if (url1 === url2) {
|
||||
return true // Obviously true if the routes are the same
|
||||
}
|
||||
let comps1 = url1.split('/')
|
||||
let comps2 = url2.split('/')
|
||||
if (comps1.length < 2 || comps2.length < 2) {
|
||||
return false // Just a failsafe, this should never happen
|
||||
}
|
||||
// If the items are different, then the item instance cannot be
|
||||
// the same either
|
||||
if (comps1[1] !== comps2[1]) {
|
||||
return false
|
||||
}
|
||||
if (comps1.length < 3 || comps2.length < 3) {
|
||||
// ID is the third url component, so can't be the same instance if
|
||||
// either of the urls have less than three components
|
||||
return false
|
||||
}
|
||||
if (comps1[2] !== comps2[2]) {
|
||||
// Different IDs, not same instance
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// A simple function to sanitize HTML tags
|
||||
window.escapeHTML = function(text) {
|
||||
return text.replace(/[\"&'\/<>]/g, function (a) {
|
||||
return {
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'<': '<',
|
||||
'>': '>'
|
||||
}[a]
|
||||
})
|
||||
}
|
||||
|
||||
// Fix the decimal separator for languages that use a comma instead of dot
|
||||
window.fixDecimalSeparator = function(value, io) {
|
||||
// value is the string value of the number to process
|
||||
// io is either 'i' as in input or 'o' as in output
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
if (io === 'i') {
|
||||
// Check if it's an American number where a comma precedes a dot
|
||||
// e.g. 12,500.25
|
||||
if (value.indexOf('.') > value.indexOf(',')) {
|
||||
return value.replace(',', '')
|
||||
} else {
|
||||
return value.replace(',', '.')
|
||||
}
|
||||
} else if (io === 'o') {
|
||||
return value.toString().replace('.', ',')
|
||||
}
|
||||
}
|
||||
|
||||
// This will replace the PHP function nl2br in Vue components
|
||||
window.nl2br = function(text) {
|
||||
return text.replace(/\n/g, '<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)
|
|
@ -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
|
||||
})
|
|
@ -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
|
||||
script('cookbook', 'script');
|
||||
script('cookbook', 'vue');
|
||||
style('cookbook', 'style');
|
||||
?>
|
||||
|
||||
<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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,7 @@
|
|||
const merge = require('webpack-merge')
|
||||
const common = require('./webpack.config.js')
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
|
||||
}
|
|
@ -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',
|
||||
})
|
Загрузка…
Ссылка в новой задаче