/* global luxon */ // Tell ESLint to ignore undefined `luxon`.
// main.recipe.js
import { caloriesNinjasNutritions } from './recipe.api.js';
import { searchForKey, parseISO, createTagList } from './util.js';
// ----- Variables -----
/**
* Retrieve GET queries for current recipe.
* /recipe.html?source=placeholder&id=index
* See https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams
*/
const params = new URL(document.location).searchParams;
/**
* Either 'user' to specify a user recipe, or an external source
* like 'delicious.com.au' to specify a preset recipe.
*/
const source = params.get('source');
/** Unique integer identifier & index for the recipe. */
const id = parseInt(params.get('id'));
/** LD-JSON structured data storing recipe data in the document head. */
const recipeJSON = document.createElement('script');
recipeJSON.setAttribute('type', 'application/ld+json');
/** ['prepTime', 'cookTime', 'totalTime'] */
const TIME_FIELDS = ['prepTime', 'cookTime', 'totalTime'];
/**
* Container with recipe data elements.
*/
const recipeDiv = document.getElementById('recipe');
const cornerBtnsDiv = document.getElementById('cornerBtns');
// ----- Functions -----
/**
* Replaces recipe content with 404 message if there is no valid specified recipe to load.
*/
function invalidRecipe() {
recipeDiv.classList.remove('invisible');
// Replace recipe content
const errorHeading = document.createElement('h2');
errorHeading.textContent = '404: The recipe you are looking for does not exist!';
recipeDiv.replaceChildren(errorHeading);
// Remove corner action buttons
cornerBtnsDiv.replaceChildren();
}
/**
* Deletes specified corner buttons.
* @param {string[]} btnIds - IDs of buttons to delete. {'bookmarkBtn','addBtn', 'editBtn', 'deleteBtn'}
*/
function deleteCornerBtns(btnIds) {
btnIds.forEach((btnId) => {
cornerBtnsDiv.removeChild(document.getElementById(btnId));
});
}
/**
* "Bookmark recipe" button logic.
*/
function activateBookmarkBtn() {
const bookmarkBtn = document.getElementById('bookmarkBtn');
function styleBookmarkBtn() {
const userRecipes = JSON.parse(localStorage.getItem('recipes'));
const recipe = userRecipes[id];
if (recipe.bookmarked) {
bookmarkBtn.classList.remove('btn-primary');
bookmarkBtn.classList.add('btn-outline-primary');
bookmarkBtn.setAttribute('data-bs-original-title', 'Un-bookmark recipe');
} else {
bookmarkBtn.classList.add('btn-primary');
bookmarkBtn.classList.remove('btn-outline-primary');
bookmarkBtn.setAttribute('data-bs-original-title', 'Bookmark recipe');
}
}
styleBookmarkBtn();
bookmarkBtn.addEventListener('click', function () {
/**
* Toggle bookmarked status of recipe in user recipes array.
*/
// Retreive recipes array.
const userRecipes = JSON.parse(localStorage.getItem('recipes'));
const recipe = userRecipes[id];
recipe.bookmarked = !recipe.bookmarked;
localStorage.setItem('recipes', JSON.stringify(userRecipes));
recipeJSON.textContent = JSON.stringify(recipe);
styleBookmarkBtn();
});
}
/**
* "Add recipe" button logic.
*/
function activateAddBtn() {
const addBtn = document.getElementById('addBtn');
addBtn.addEventListener('click', function () {
/**
* Add recipe data to user recipes array.
*/
// Retreive recipes array and push new recipe.
const userRecipes = JSON.parse(localStorage.getItem('recipes')) || [];
const recipeData = JSON.parse(recipeJSON.textContent);
//TODO: Add additional schema fields to fetched data (URL)
recipeData.tags = createTagList(recipeData).string;
recipeData.bookmarked = false;
userRecipes.push(recipeData);
// Update recipes array in storage.
localStorage.setItem('recipes', JSON.stringify(userRecipes));
// Redirect user to their new recipe.
window.location.href = `/recipe.html?source=user&id=${userRecipes.length - 1}#new`;
});
}
/**
* "Toggle speech commands" button logic.
*/
function activateSpeechBtn() {
const initMessage =
'Hello! Speech recognition service can help you get instructions ' +
'step by step without touching your device. Use the voice commands in the top right.';
const voiceCommandsAlert = document.getElementById('voiceCommands');
const speechBtn = document.getElementById('speechBtn');
speechBtn.addEventListener('click', function () {
console.log('Speech recognition activated!');
// Change corner button style
speechBtn.classList.remove('btn-secondary');
speechBtn.classList.add('btn-outline-secondary');
// Show voice commands
voiceCommandsAlert.classList.add('show');
voiceCommandsAlert.classList.remove('hide');
let OutputMsg = new SpeechSynthesisUtterance();
// Output voice type setting
OutputMsg.lang = 'en';
OutputMsg.pitch = 1; //0-2
OutputMsg.rate = 0.8; //0.1-10
OutputMsg.volume = 1; //0-1
// An introduction for customers.
OutputMsg.text = initMessage;
window.speechSynthesis.speak(OutputMsg);
// Say step 1
const steps = document.getElementById('steps');
OutputMsg.text = `Step 1: ${steps.children[0].textContent}`;
window.speechSynthesis.speak(OutputMsg);
// Speech Recognition setting
let Recognition = new window.webkitSpeechRecognition();
Recognition.lang = 'en';
Recognition.continuous = false;
Recognition.interimResults = false;
Recognition.maxAlternative = 1;
let currentStep = 0; // it starts from -1 because we need command 'next' to start the first step.
let continueRecognition = true; //continuously run speech recognition or not
Recognition.start();
Recognition.onresult = function (event) {
var InputMsg = event.results[0][0].transcript;
console.log(`Received voice command: ${InputMsg}`);
const steps = document.getElementById('steps');
if (InputMsg.includes('next')) {
if (currentStep >= steps.children.length - 1) {
OutputMsg.text = 'We just went through the final step!';
// Response when users call 'next' when they already reach the final step.
window.speechSynthesis.speak(OutputMsg);
} else {
currentStep++;
steps.children[currentStep].click();
OutputMsg.text = steps.children[currentStep].textContent;
window.speechSynthesis.speak(OutputMsg);
}
} else if (InputMsg.includes('repeat')) {
window.speechSynthesis.speak(OutputMsg);
} else if (InputMsg.includes('back')) {
if (currentStep == 0) {
OutputMsg.text = 'We just went through the first step!';
// Response when users call 'back' when they already reach back to the first back.
window.speechSynthesis.speak(OutputMsg);
} else {
currentStep--;
steps.children[currentStep].click();
OutputMsg.text = steps.children[currentStep].textContent;
window.speechSynthesis.speak(OutputMsg);
}
} else if (InputMsg.includes('stop')) {
continueRecognition = false;
}
};
// continuously start recognition
Recognition.onend = function () {
if (continueRecognition == true) {
Recognition.start();
} else {
// Thank Users in the End.
OutputMsg.text = 'Thank you for using speech recognition service!';
window.speechSynthesis.speak(OutputMsg);
speechBtn.classList.add('btn-secondary');
speechBtn.classList.remove('btn-outline-secondary');
voiceCommandsAlert.classList.add('hide');
voiceCommandsAlert.classList.remove('show');
}
};
});
}
/**
* "Delete recipe" button logic.
*/
function activateDeleteBtn() {
const deleteBtn = document.getElementById('confirmDeleteBtn');
deleteBtn.addEventListener('click', function () {
/**
* Delete recipe data from user recipes array.
*/
// Retreive recipes array and remove recipe.
const userRecipes = JSON.parse(localStorage.getItem('recipes'));
userRecipes.splice(id, 1);
// Update recipes array in storage.
localStorage.setItem('recipes', JSON.stringify(userRecipes));
/**
* Delete grocery list from grocery list array.
*/
// Retrieve grocery list array and remove recipe.
const groceryList = JSON.parse(localStorage.getItem('grocery-list'));
groceryList.splice(id, 1);
// Update grocery list array in storage.
localStorage.setItem('grocery-list', JSON.stringify(groceryList));
// Redirect user to the recipes page.
window.location.href = `/index.html`;
});
}
function activateGroceryBtn() {
const groceryBtn = document.getElementById('groceryBtn');
groceryBtn.addEventListener('click', function () {
const recipe = JSON.parse(recipeJSON.textContent);
const ingredients = searchForKey(recipe, 'recipeIngredient');
const groceryList = JSON.parse(localStorage.getItem('grocery-list'));
groceryList[id].name = searchForKey(recipe, 'name');
groceryList[id].itemListElement = [];
ingredients.forEach((ingredient) => {
groceryList[id].itemListElement.push({
'@type': 'Thing',
name: ingredient,
checked: false
});
});
localStorage.setItem('grocery-list', JSON.stringify(groceryList));
// Redirect user to the grocery list page.
window.location.href = `/grocery-list.html`;
});
}
function fetchNutrition() {
/** {'CalorieNinjas Name': 'JSON Name'} */
const CALORIE_NINJAS_MAP = {
calories: 'calories',
carbohydrates_total_g: 'carbohydrateContent',
cholesterol_mg: 'cholesterolContent',
fiber_g: 'fiberContent',
fat_total_g: 'fatContent',
fat_saturated_g: 'saturatedFatContent',
protein_g: 'proteinContent',
sodium_mg: 'sodiumContent',
sugar_g: 'sugarContent'
};
let data = JSON.parse(recipeJSON.textContent);
// Nutrition has not been stored - store it & populate front-end
// TODO: Make API Call with current ingredients
const recipeYield = searchForKey(data, 'recipeYield') || 1;
const ingredientsString = searchForKey(data, 'recipeIngredient').join(', ');
if (!ingredientsString) {
return;
}
caloriesNinjasNutritions(ingredientsString).then((response) => {
const nutritionTotal = response.items.reduce((previousItem, currentItem) => {
for (const nutritionFact in previousItem) {
previousItem[nutritionFact] += currentItem[nutritionFact];
}
return previousItem;
});
for (const nutritionFact in nutritionTotal) {
const jsonMapping = CALORIE_NINJAS_MAP[nutritionFact];
if (jsonMapping) {
const nutritionValue = nutritionTotal[nutritionFact];
data.nutrition[jsonMapping] = Math.round(nutritionValue / recipeYield);
}
}
console.log('updating nutrition...');
console.log(data);
populateRecipe(data);
/* Edit recipe in local storage */
if (source == 'user') {
let userRecipes = JSON.parse(localStorage.getItem('recipes'));
userRecipes[id] = data;
localStorage.setItem('recipes', JSON.stringify(userRecipes));
}
});
}
/**
* Populates recipe content with recipe data.
* @param {Object} data - Recipe data.
*/
function populateRecipe(data) {
// Log recipe Object.
// console.group('Recipe data');
// console.log(data);
// console.groupEnd('Recipe data');
/**
* Populate LD-JSON in the document head.
*/
recipeJSON.textContent = JSON.stringify(data);
document.head.appendChild(recipeJSON);
/**
* Populate frontend elements.
*/
// Title
const title = document.getElementById('title');
title.textContent = searchForKey(data, 'name');
document.title = title.textContent;
// Source author or organization
const sourceWriter = document.getElementById('source');
if (searchForKey(data, 'publisher')) {
sourceWriter.textContent = searchForKey(data, 'publisher').name || 'Source N/A';
} else {
sourceWriter.textContent = searchForKey(data, 'author').name || 'Source N/A';
}
// Source link
const url = document.getElementById('url');
url.href = data.url || searchForKey(data, '@id') || '#';
if (url.href === window.location.href + '#') {
url.textContent = 'Link N/A';
} else {
url.textContent = 'Link';
}
/* Recipe facts (tags, cook time, difficulty, servings) */
// Tags
const tags = document.getElementById('tags');
tags.textContent = searchForKey(data, 'tags') || createTagList(data).string || 'N/A';
// Prep time, cook time, and total time
TIME_FIELDS.forEach((timeField) => {
const timeElement = document.getElementById(timeField);
timeElement.textContent = parseISO(searchForKey(data, timeField)) || 'N/A';
});
// Difficulty
const difficulty = document.getElementById('difficulty');
difficulty.textContent = searchForKey(data, 'difficulty') || 'N/A';
// Servings
const servings = document.getElementById('servings');
servings.textContent = searchForKey(data, 'recipeYield') || 'N/A';
// TODO: Nutrition (Make API call on ingredients)
const storedNutrition = searchForKey(data, 'nutrition');
if (storedNutrition.calories && storedNutrition.fatContent) {
// Nutrition is already stored - populate front-end
for (const nutritionFact in storedNutrition) {
const nutritionElement = document.getElementById(nutritionFact);
if (nutritionElement) {
nutritionElement.textContent = storedNutrition[nutritionFact];
}
}
} else {
fetchNutrition();
}
// Description
const description = document.getElementById('description');
description.textContent = searchForKey(data, 'description');
// Image
const image = document.getElementById('image');
image.src = searchForKey(data, 'image').url || 'https://via.placeholder.com/350x150?text=No+image+found';
image.alt = searchForKey(data, 'name');
// Ingredients - clear old & replace with new
const ingredients = document.getElementById('ingredients');
ingredients.replaceChildren();
searchForKey(data, 'recipeIngredient').forEach((ingredient) => {
const newIngredient = document.createElement('li');
newIngredient.textContent = ingredient;
ingredients.appendChild(newIngredient);
});
// TODO: Find way to make numbers bold
// Steps - clear old & replace with new
const steps = document.getElementById('steps');
steps.replaceChildren();
searchForKey(data, 'recipeInstructions').forEach((step, index) => {
const newStep = document.createElement('li');
newStep.textContent = step;
// TODO: Save index for user's recipe progress
if (source === 'user' && index === 0) {
newStep.classList.add('active');
}
// Highlight certain step upon clicking it
newStep.addEventListener('click', function () {
for (let i = 0; i < steps.children.length; i++) {
steps.children[i].classList.remove('active');
}
newStep.classList.add('active');
});
steps.appendChild(newStep);
});
// Reveal recipe after population finishes
recipeDiv.classList.remove('invisible');
cornerBtnsDiv.classList.remove('invisible');
}
/**
* Populates the editing drawer with recipe data.
*/
function populateDrawer() {
const data = JSON.parse(recipeJSON.textContent);
/**
* Populate input elements in the drawer.
*/
// Title
const title = document.getElementById('titleInput');
title.value = searchForKey(data, 'name');
/* Recipe facts (tags, cook time, difficulty, servings) */
// Tags
const tags = document.getElementById('tagsInput');
tags.value = searchForKey(data, 'tags') || createTagList(data).string;
// Prep time, cook time, and total time
TIME_FIELDS.forEach((timeField) => {
const timeElement = document.getElementById(`${timeField}Input`);
timeElement.value = luxon.Duration.fromISO(searchForKey(data, timeField)).shiftTo('minutes').get('minutes');
});
// Difficulty
const difficulty = document.getElementById('difficultyInput');
difficulty.value = searchForKey(data, 'difficulty') || '';
// Servings
const servings = document.getElementById('servingsInput');
servings.value = searchForKey(data, 'recipeYield');
// Ingredients
const ingredients = document.getElementById('ingredientsInput');
ingredients.value = searchForKey(data, 'recipeIngredient').join('\n');
// Steps
const steps = document.getElementById('stepsInput');
steps.value = searchForKey(data, 'recipeInstructions').join('\n\n');
// Image link
const imageInput = document.getElementById('imageInput');
imageInput.value = searchForKey(data, 'image').url || '';
// Description
const description = document.getElementById('descriptionInput');
description.value = searchForKey(data, 'description');
// Source author or organization
const sourceInput = document.getElementById('sourceInput');
sourceInput.value = searchForKey(data, 'author').name;
// Source link
const urlInput = document.getElementById('urlInput');
urlInput.value = data.url || '';
}
/**
* Activate editing functionality in the drawer.
*/
function activateDrawerEditing() {
// TODO: Populate edit drawer
let data = JSON.parse(recipeJSON.textContent);
const elementIds = [
'title',
'tags',
'prepTime',
'cookTime',
'totalTime',
'difficulty',
'servings',
'ingredients',
'steps',
'image',
'description',
'source',
'url'
];
/** Mappings of element id to json field name */
const jsonMappings = {
title: 'name',
servings: 'recipeYield',
image: ['image.url'],
ingredients: 'recipeIngredient',
steps: 'recipeInstructions',
source: ['author.name', 'publisher.name']
};
elementIds.forEach((elementId) => {
const elementInput = document.getElementById(`${elementId}Input`);
elementInput.addEventListener('input', function (event) {
const newValue = event.target.value;
const jsonMapping = jsonMappings[elementId];
if (!jsonMapping) {
// CASE: element id is same as json field, so there is no custom mapping
if (TIME_FIELDS.includes(elementId)) {
// CASE: Time field
data[elementId] = `PT${newValue}M`;
} else {
// CASE: Normal field like `description`
data[elementId] = newValue;
}
} else {
// CASE: element id is different than json field, so there is a custom mapping
if (Array.isArray(jsonMapping)) {
// CASE: Multiple target fields OR nested field
jsonMapping.forEach((field) => {
if (field.includes('.')) {
// CASE: Nested field
const fieldArr = field.split('.'); // [parent, child]
data[fieldArr[0]][fieldArr[1]] = newValue;
} else {
// CASE: Single field
data[jsonMapping] = newValue;
}
});
} else {
// CASE: Normal field with a mapping like `ingredients`
if (elementId === 'tags') {
data[jsonMapping] = newValue.split(',').map((tag) => tag.trim());
} else if (elementId === 'ingredients') {
data[jsonMapping] = newValue.split('\n');
} else if (elementId === 'steps') {
data[jsonMapping] = newValue.split('\n\n');
} else {
data[jsonMapping] = newValue;
}
}
}
// Log updated data
console.log(data);
// Update front-end preview
populateRecipe(data);
/* Edit recipe in local storage */
let userRecipes = JSON.parse(localStorage.getItem('recipes'));
userRecipes[id] = data;
localStorage.setItem('recipes', JSON.stringify(userRecipes));
});
});
// Update nutrition upon committing an ingredients change.
const ingredientsInput = document.getElementById('ingredientsInput');
ingredientsInput.addEventListener('change', function () {
fetchNutrition();
});
}
// ----- Page Initialization -----
/**
* Recipe query logic.
* Determining if recipe is from user recipes or preset recipes.
*/
// Log query info
console.log(`Source: ${source}`);
console.log(`ID: ${id}`);
// Log local storage
console.group('Local storage');
console.log(localStorage);
console.log('User recipes (localStorage.recipes):');
console.log(JSON.parse(localStorage.recipes || '[]'));
console.groupEnd('Local storage');
// Validate recipe source and id
if (!source || isNaN(id)) {
invalidRecipe();
} else if (source !== 'user' && source !== 'bookmark') {
/* CASE: Preset Recipe Source */
/**
* Fetch preset recipe to populate frontend.
*/
fetch('/data/recipe-data.json')
.then((response) => response.json())
.then((presetRecipes) => {
// The external recipe source must be in the JSON
if (!presetRecipes.hasOwnProperty(source) || !presetRecipes[source][id]) {
return invalidRecipe();
}
// Delete edit & delete corner buttons
deleteCornerBtns(['bookmarkBtn', 'speechBtn', 'editBtn', 'deleteBtn']);
document.getElementById('groceryBtn').remove();
// Activate add button
activateAddBtn();
const recipeData = presetRecipes[source][id];
console.group('Recipe data');
console.log(recipeData);
console.groupEnd('Recipe data');
populateRecipe(recipeData);
populateDrawer();
activateDrawerEditing();
})
.catch((err) => console.error(err));
} else if (source === 'user' || source == 'bookmark') {
/* CASE: User Recipe Source */
/**
* Access local storage to retrieve recipe data.
*/
const userRecipes = JSON.parse(localStorage.getItem('recipes')) || [];
const recipeData = userRecipes[id];
console.group('Recipe data');
console.log(recipeData);
console.groupEnd('Recipe data');
// If recipe exists, populate frontend with recipe data.
if (!recipeData) {
invalidRecipe();
} else {
// Delete add corner button
deleteCornerBtns(['addBtn']);
// Activate speech butotn
activateSpeechBtn();
// Activate delete button
activateDeleteBtn();
// Activate bookmark button
activateBookmarkBtn();
// Activate grocery button
activateGroceryBtn();
// Show edit drawer upon showing a completely new recipe
if (location.hash === '#new') {
const drawer = document.getElementById('drawer');
drawer.classList.add('show');
fetch('/data/ingredient-list-schema.json')
.then((response) => response.json())
.then((ingredientListSchema) => {
/**
* Create new empty ingredient list in grocery list.
*/
// Retreive recipes array and push new recipe.
const groceryList = JSON.parse(localStorage.getItem('grocery-list')) || [];
if (!groceryList[id]) {
ingredientListSchema.itemListElement.pop();
groceryList.push(ingredientListSchema);
// Update recipes array in storage.
localStorage.setItem('grocery-list', JSON.stringify(groceryList));
}
})
.catch((err) => console.error(err));
}
populateRecipe(recipeData);
populateDrawer();
activateDrawerEditing();
}
} else {
invalidRecipe();
}