Refactor #6.2 autoformat
This commit is contained in:
parent
28ad3a16ff
commit
f0a8adbd77
37 changed files with 2280 additions and 2007 deletions
|
|
@ -1,24 +1,29 @@
|
||||||
# doof-front
|
# doof-front
|
||||||
|
|
||||||
## Project setup
|
## Project setup
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and hot-reloads for development
|
### Compiles and hot-reloads for development
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run serve
|
npm run serve
|
||||||
```
|
```
|
||||||
|
|
||||||
### Compiles and minifies for production
|
### Compiles and minifies for production
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Lints and fixes files
|
### Lints and fixes files
|
||||||
|
|
||||||
```
|
```
|
||||||
npm run lint
|
npm run lint
|
||||||
```
|
```
|
||||||
|
|
||||||
### Customize configuration
|
### Customize configuration
|
||||||
|
|
||||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [
|
presets: ['@vue/cli-plugin-babel/preset'],
|
||||||
'@vue/cli-plugin-babel/preset'
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,8 @@
|
||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["src/*"]
|
||||||
"src/*"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"lib": [
|
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
|
||||||
"esnext",
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"scripthost"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>
|
<noscript>
|
||||||
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<strong
|
||||||
|
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without
|
||||||
|
JavaScript enabled. Please enable it to continue.</strong
|
||||||
|
>
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- built files will be auto injected -->
|
<!-- built files will be auto injected -->
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ This document outlines a pragmatic, step-by-step refactor plan to improve struct
|
||||||
Last updated: 2025-10-18
|
Last updated: 2025-10-18
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- Separate concerns (routing, HTTP/API, mapping/normalization, UI logic)
|
- Separate concerns (routing, HTTP/API, mapping/normalization, UI logic)
|
||||||
- Improve readability and testability
|
- Improve readability and testability
|
||||||
- Establish light-weight standards (naming, lint/format) without blocking development
|
- Establish light-weight standards (naming, lint/format) without blocking development
|
||||||
|
|
@ -13,6 +14,7 @@ Last updated: 2025-10-18
|
||||||
## Phases and Steps
|
## Phases and Steps
|
||||||
|
|
||||||
### Phase 1 — Routing and Auth (Foundational)
|
### Phase 1 — Routing and Auth (Foundational)
|
||||||
|
|
||||||
- [x] Extract router into `src/router/index.js` with named routes
|
- [x] Extract router into `src/router/index.js` with named routes
|
||||||
- [x] Add route meta `requiresAuth` and a global auth guard
|
- [x] Add route meta `requiresAuth` and a global auth guard
|
||||||
- [x] Remove auth-redirect from `App.vue` (handled by guard instead)
|
- [x] Remove auth-redirect from `App.vue` (handled by guard instead)
|
||||||
|
|
@ -21,6 +23,7 @@ Last updated: 2025-10-18
|
||||||
Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth.
|
Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth.
|
||||||
|
|
||||||
### Phase 2 — API Layer Split (Incremental)
|
### Phase 2 — API Layer Split (Incremental)
|
||||||
|
|
||||||
- [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL
|
- [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL
|
||||||
- [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates)
|
- [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates)
|
||||||
- [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete)
|
- [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete)
|
||||||
|
|
@ -31,6 +34,7 @@ Outcome: Routing logic is centralized and testable; pages redirect consistently
|
||||||
Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable.
|
Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable.
|
||||||
|
|
||||||
### Phase 3 — Composables (UI-Facing Logic)
|
### Phase 3 — Composables (UI-Facing Logic)
|
||||||
|
|
||||||
- [x] Add `src/composables/useAuth.js` (user ref, ensureAuth)
|
- [x] Add `src/composables/useAuth.js` (user ref, ensureAuth)
|
||||||
- [x] Add `src/composables/useMeals.js` (fetch and mutate meals)
|
- [x] Add `src/composables/useMeals.js` (fetch and mutate meals)
|
||||||
- [x] Refactor pages to use composables and `<script setup>` where appropriate
|
- [x] Refactor pages to use composables and `<script setup>` where appropriate
|
||||||
|
|
@ -40,6 +44,7 @@ Outcome: Feature modules call cohesive services; logic for mapping/normalization
|
||||||
Outcome: Components get smaller and easier to read; business logic is reusable.
|
Outcome: Components get smaller and easier to read; business logic is reusable.
|
||||||
|
|
||||||
### Phase 4 — Tooling and Standards
|
### Phase 4 — Tooling and Standards
|
||||||
|
|
||||||
- [x] Add Prettier config and .editorconfig; wire Prettier with ESLint
|
- [x] Add Prettier config and .editorconfig; wire Prettier with ESLint
|
||||||
- [ ] Upgrade ESLint (if/when convenient) and align with Vue 3 rules
|
- [ ] Upgrade ESLint (if/when convenient) and align with Vue 3 rules
|
||||||
- [x] Ensure Volar is used (dev environment) for Vue 3 type intelligence
|
- [x] Ensure Volar is used (dev environment) for Vue 3 type intelligence
|
||||||
|
|
@ -49,6 +54,7 @@ Outcome: Components get smaller and easier to read; business logic is reusable.
|
||||||
Outcome: Stable formatting and consistent linting across contributors.
|
Outcome: Stable formatting and consistent linting across contributors.
|
||||||
|
|
||||||
### Phase 5 — Tests (Targeted)
|
### Phase 5 — Tests (Targeted)
|
||||||
|
|
||||||
- [ ] Add a unit test runner (Vitest or Jest)
|
- [ ] Add a unit test runner (Vitest or Jest)
|
||||||
- [ ] Test mappers (dates/reference wiring)
|
- [ ] Test mappers (dates/reference wiring)
|
||||||
- [ ] Test `units.js` conversions and totals
|
- [ ] Test `units.js` conversions and totals
|
||||||
|
|
@ -56,9 +62,11 @@ Outcome: Stable formatting and consistent linting across contributors.
|
||||||
Outcome: Confidence in refactors and easier onboarding.
|
Outcome: Confidence in refactors and easier onboarding.
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
- Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`.
|
- Prefer `VUE_APP_API_BASE` for backend base URL (fallback to `/api` for dev). Optionally set a devServer proxy in `vue.config.js`.
|
||||||
|
|
||||||
## Progress Log
|
## Progress Log
|
||||||
|
|
||||||
- 2025-10-18:
|
- 2025-10-18:
|
||||||
- Extracted router with names and auth guard
|
- Extracted router with names and auth guard
|
||||||
- Removed `App.vue` auth redirect
|
- Removed `App.vue` auth redirect
|
||||||
|
|
|
||||||
22
src/App.vue
22
src/App.vue
|
|
@ -2,13 +2,19 @@
|
||||||
<div>
|
<div>
|
||||||
<ul class="nav">
|
<ul class="nav">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active">Recipes</router-link>
|
<router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active"
|
||||||
|
>Recipes</router-link
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active">Meal Plan</router-link>
|
<router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active"
|
||||||
|
>Meal Plan</router-link
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active">Shopping</router-link>
|
<router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active"
|
||||||
|
>Shopping</router-link
|
||||||
|
>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,18 +31,17 @@ import AlertToast from './components/AlertToast.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
components: {
|
components: {
|
||||||
'alert-toast': AlertToast
|
'alert-toast': AlertToast,
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
currentRoute() {
|
currentRoute() {
|
||||||
return this.$route.path
|
return this.$route.path
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|
@ -80,7 +85,7 @@ export default {
|
||||||
|
|
||||||
/* Have active route use different color */
|
/* Have active route use different color */
|
||||||
.nav li:has(> a.active) {
|
.nav li:has(> a.active) {
|
||||||
background-color: #4CAF50;
|
background-color: #4caf50;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,5 +93,4 @@ export default {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
11
src/alert.js
11
src/alert.js
|
|
@ -1,10 +1,11 @@
|
||||||
const subscribers = [];
|
const subscribers = []
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
subscribers.push(callback);
|
subscribers.push(callback)
|
||||||
|
},
|
||||||
|
show(message) {
|
||||||
|
// { message, heading, type: ["success", "error", "info"] }
|
||||||
|
subscribers.forEach((callback) => callback(message))
|
||||||
},
|
},
|
||||||
show(message) { // { message, heading, type: ["success", "error", "info"] }
|
|
||||||
subscribers.forEach(callback => callback(message));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -6,14 +6,19 @@ async function request(path, options = {}) {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
Accept: 'application/json',
|
||||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
let message = `${resp.status} ${resp.statusText}`
|
let message = `${resp.status} ${resp.statusText}`
|
||||||
try { const err = await resp.json(); message = err.message || message } catch (_) { /* ignore */ }
|
try {
|
||||||
|
const err = await resp.json()
|
||||||
|
message = err.message || message
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
const error = new Error(message)
|
const error = new Error(message)
|
||||||
error.status = resp.status
|
error.status = resp.status
|
||||||
throw error
|
throw error
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,14 @@ function toDate(value) {
|
||||||
return value ? new Date(value) : value
|
return value ? new Date(value) : value
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachItemRefs(items, { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup }) {
|
function attachItemRefs(
|
||||||
|
items,
|
||||||
|
{ ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup }
|
||||||
|
) {
|
||||||
if (!Array.isArray(items)) return
|
if (!Array.isArray(items)) return
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.ingredient_id && ingredients_lookup) item.ingredient = ingredients_lookup[item.ingredient_id]
|
if (item.ingredient_id && ingredients_lookup)
|
||||||
|
item.ingredient = ingredients_lookup[item.ingredient_id]
|
||||||
if (item.meal_id && meals_lookup) item.meal = meals_lookup[item.meal_id]
|
if (item.meal_id && meals_lookup) item.meal = meals_lookup[item.meal_id]
|
||||||
if (item.recipe_id && recipes_lookup) item.recipe = recipes_lookup[item.recipe_id]
|
if (item.recipe_id && recipes_lookup) item.recipe = recipes_lookup[item.recipe_id]
|
||||||
if (item.list_id && shopping_list_lookup) item.list = shopping_list_lookup[item.list_id]
|
if (item.list_id && shopping_list_lookup) item.list = shopping_list_lookup[item.list_id]
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ export async function parseRecipe(url) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function parseIngredients(lines) {
|
export async function parseIngredients(lines) {
|
||||||
const params = lines.map(line => `ingredients=${encodeURIComponent(line)}`).join('&')
|
const params = lines.map((line) => `ingredients=${encodeURIComponent(line)}`).join('&')
|
||||||
return http.get(`/recipes/ingredients/parse?${params}`)
|
return http.get(`/recipes/ingredients/parse?${params}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'ActionItem',
|
name: 'ActionItem',
|
||||||
props: ['title', 'image']
|
props: ['title', 'image'],
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -33,5 +33,4 @@ img {
|
||||||
.card:hover {
|
.card:hover {
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
|
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
|
||||||
<img v-if="icon" :src="icon" alt="Notification icon" />
|
<img v-if="icon" :src="icon" alt="Notification icon" />
|
||||||
<div class="message-container">
|
<div class="message-container">
|
||||||
|
|
@ -7,11 +6,9 @@
|
||||||
<p class="message">{{ message }}</p>
|
<p class="message">{{ message }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
/* Display as a toast, in the bottom right corner */
|
/* Display as a toast, in the bottom right corner */
|
||||||
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */
|
/* Place the icon to the left for the full height, then have the heading and message stacked to the right */
|
||||||
|
|
||||||
|
|
@ -42,24 +39,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert.success {
|
.alert.success {
|
||||||
background-color: #4CAF50;
|
background-color: #4caf50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert.info {
|
.alert.info {
|
||||||
background-color: #2196F3;
|
background-color: #2196f3;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import alert from '@/alert'
|
||||||
import alert from '@/alert';
|
|
||||||
|
|
||||||
const alertIcons = {
|
const alertIcons = {
|
||||||
error: require('@/assets/notification-error.svg'),
|
error: require('@/assets/notification-error.svg'),
|
||||||
success: require('@/assets/notification-success.svg'),
|
success: require('@/assets/notification-success.svg'),
|
||||||
info: require('@/assets/notification-info.svg')
|
info: require('@/assets/notification-info.svg'),
|
||||||
};
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AlertToast',
|
name: 'AlertToast',
|
||||||
|
|
@ -68,31 +63,30 @@ export default {
|
||||||
showAlert: false,
|
showAlert: false,
|
||||||
heading: '',
|
heading: '',
|
||||||
message: '',
|
message: '',
|
||||||
type: ''
|
type: '',
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
icon() {
|
icon() {
|
||||||
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null;
|
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
alert.subscribe(this.show);
|
alert.subscribe(this.show)
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
show({ heading, message, type }) {
|
show({ heading, message, type }) {
|
||||||
this.heading = heading;
|
this.heading = heading
|
||||||
this.message = message;
|
this.message = message
|
||||||
this.type = type;
|
this.type = type
|
||||||
this.showAlert = true;
|
this.showAlert = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.showAlert = false;
|
this.showAlert = false
|
||||||
}, 5000);
|
}, 5000)
|
||||||
},
|
},
|
||||||
dismiss() {
|
dismiss() {
|
||||||
this.showAlert = false;
|
this.showAlert = false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div class="login">
|
<div class="login">
|
||||||
<h1>Login Page</h1>
|
<h1>Login Page</h1>
|
||||||
<ul class="button-group">
|
<ul class="button-group">
|
||||||
|
|
@ -10,11 +9,9 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
/* Remove the default list styling */
|
/* Remove the default list styling */
|
||||||
ul {
|
ul {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
@ -68,20 +65,18 @@ li:nth-child(4) > button {
|
||||||
background-color: #9a1f1f;
|
background-color: #9a1f1f;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getPersonsInHome } from '@/api/persons';
|
import { getPersonsInHome } from '@/api/persons'
|
||||||
import { login } from '@/api/auth';
|
import { login } from '@/api/auth'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'LoginVue',
|
name: 'LoginVue',
|
||||||
props: {
|
props: {
|
||||||
redirect: {
|
redirect: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '/'
|
default: '/',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -90,19 +85,18 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
this.persons = await getPersonsInHome();
|
this.persons = await getPersonsInHome()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async login(selectedPerson) {
|
async login(selectedPerson) {
|
||||||
const person = await login(selectedPerson.name);
|
const person = await login(selectedPerson.name)
|
||||||
if (person?.id >= 0) {
|
if (person?.id >= 0) {
|
||||||
this.$router.push(this.redirect);
|
this.$router.push(this.redirect)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('Login failed');
|
alert('Login failed')
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -4,41 +4,55 @@
|
||||||
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
|
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
|
||||||
</p>
|
</p>
|
||||||
<p class="ingredient-details">
|
<p class="ingredient-details">
|
||||||
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ ingredient?.quantity || 'qty' }}</span>
|
<span class="parse-element quantity" :class="{ missing: !ingredient?.quantity }">{{
|
||||||
<span class="parse-element unit" :class="{ missing: !(ingredient?.unit)}">{{ ingredient?.unit || 'unit' }}</span>
|
ingredient?.quantity || 'qty'
|
||||||
|
}}</span>
|
||||||
|
<span class="parse-element unit" :class="{ missing: !ingredient?.unit }">{{
|
||||||
|
ingredient?.unit || 'unit'
|
||||||
|
}}</span>
|
||||||
<span class="parse-element helper">of</span>
|
<span class="parse-element helper">of</span>
|
||||||
<span class="parse-element name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:
|
<span class="parse-element name" :class="{ missing: !ingredient?.name }">{{
|
||||||
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
|
ingredient?.name || 'name'
|
||||||
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=”_blank”>
|
}}</span
|
||||||
( {{ ingredient?.product?.name }} <img src="@/assets/external-link.svg" style="width: 1em; height: 1em; vertical-align: middle; margin-left: 0.5em; margin-bottom: 0.2em;" /> )
|
>:
|
||||||
</a>
|
<span class="parse-element product-name" :class="{ missing: !ingredient?.product }">
|
||||||
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
|
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target="”_blank”">
|
||||||
(search?)
|
( {{ ingredient?.product?.name }}
|
||||||
</a>
|
<img
|
||||||
<a v-else>
|
src="@/assets/external-link.svg"
|
||||||
(product)
|
style="
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
)
|
||||||
</a>
|
</a>
|
||||||
|
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank"> (search?) </a>
|
||||||
|
<a v-else> (product) </a>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CompactParsedIngredient',
|
name: 'CompactParsedIngredient',
|
||||||
props: ['ingredient'],
|
props: ['ingredient'],
|
||||||
computed: {
|
computed: {
|
||||||
searchlink() {
|
searchlink() {
|
||||||
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name);
|
return (
|
||||||
|
'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
|
||||||
|
encodeURIComponent(this.ingredient.name)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.compact-parse-results {
|
.compact-parse-results {
|
||||||
flex: left;
|
flex: left;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -58,7 +72,8 @@ export default {
|
||||||
border: solid red 1px;
|
border: solid red 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parse-element.teaser-image, .parse-element.helper {
|
.parse-element.teaser-image,
|
||||||
|
.parse-element.helper {
|
||||||
border: none;
|
border: none;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
@ -89,5 +104,4 @@ export default {
|
||||||
.product-name {
|
.product-name {
|
||||||
color: purple;
|
color: purple;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<div :class="{ editing: editing }">
|
<div :class="{ editing: editing }">
|
||||||
<button v-if="editing" @click="$emit('on-add')">
|
<button v-if="editing" @click="$emit('on-add')">
|
||||||
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
|
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
|
||||||
|
|
@ -22,7 +21,8 @@
|
||||||
<ingredient-line
|
<ingredient-line
|
||||||
:ingredient="ingredient"
|
:ingredient="ingredient"
|
||||||
@update-ingredient="updateIngredient"
|
@update-ingredient="updateIngredient"
|
||||||
@update-product-link="updateProduct" />
|
@update-product-link="updateProduct"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<button @click="$emit('on-delete', ingredient)">
|
<button @click="$emit('on-delete', ingredient)">
|
||||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||||
|
|
@ -34,11 +34,9 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
|
|
@ -70,11 +68,9 @@ li > div {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import { parseProduct, parseIngredients } from '@/api/recipes'
|
import { parseProduct, parseIngredients } from '@/api/recipes'
|
||||||
import IngredientLine from './IngredientLine.vue'
|
import IngredientLine from './IngredientLine.vue'
|
||||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||||
|
|
@ -85,23 +81,22 @@ export default {
|
||||||
props: ['ingredients', 'editOnly'],
|
props: ['ingredients', 'editOnly'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
editing: this.editOnly ?? false
|
editing: this.editOnly ?? false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async updateProduct(ingredient, product_link) {
|
async updateProduct(ingredient, product_link) {
|
||||||
const product = await parseProduct(ingredient, product_link);
|
const product = await parseProduct(ingredient, product_link)
|
||||||
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product });
|
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product })
|
||||||
},
|
},
|
||||||
async updateIngredient(ingredient, line) {
|
async updateIngredient(ingredient, line) {
|
||||||
const newIngredients = await parseIngredients([line]);
|
const newIngredients = await parseIngredients([line])
|
||||||
this.$emit('on-update-ingredient', ingredient, newIngredients[0]);
|
this.$emit('on-update-ingredient', ingredient, newIngredients[0])
|
||||||
},
|
},
|
||||||
toggleEditing() {
|
toggleEditing() {
|
||||||
this.editing = !this.editing;
|
this.editing = !this.editing
|
||||||
this.$emit('on-editing', this.editing);
|
this.$emit('on-editing', this.editing)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,8 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="ingredient-item">
|
<div class="ingredient-item">
|
||||||
<p>
|
<p>
|
||||||
<input v-model="ingredientText" @keyup.enter="updateIngredient" @blur="updateIngredient" placeholder="Enter an ingredient" />
|
<input
|
||||||
<input v-model="productLink" v-if="ingredient.line" class="product-link-input" placeholder="Enter product link" @keyup.enter="updateProductLink" @blur="updateProductLink" />
|
v-model="ingredientText"
|
||||||
|
@keyup.enter="updateIngredient"
|
||||||
|
@blur="updateIngredient"
|
||||||
|
placeholder="Enter an ingredient"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="productLink"
|
||||||
|
v-if="ingredient.line"
|
||||||
|
class="product-link-input"
|
||||||
|
placeholder="Enter product link"
|
||||||
|
@keyup.enter="updateProductLink"
|
||||||
|
@blur="updateProductLink"
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="ingredient.line">
|
<p v-if="ingredient.line">
|
||||||
<!-- Single line parse results -->
|
<!-- Single line parse results -->
|
||||||
|
|
@ -12,7 +24,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CompactParsedIngredient from './CompactParsedIngredient.vue';
|
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -22,15 +34,15 @@ export default {
|
||||||
components: { CompactParsedIngredient },
|
components: { CompactParsedIngredient },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
ingredientText: this.ingredient?.line ?? "",
|
ingredientText: this.ingredient?.line ?? '',
|
||||||
productLink: this.ingredient.product?.link ?? "",
|
productLink: this.ingredient.product?.link ?? '',
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ingredient: {
|
ingredient: {
|
||||||
handler: function (newIngredient) {
|
handler: function (newIngredient) {
|
||||||
this.ingredientText = newIngredient?.line ?? "";
|
this.ingredientText = newIngredient?.line ?? ''
|
||||||
this.productLink = newIngredient.product?.link ?? "";
|
this.productLink = newIngredient.product?.link ?? ''
|
||||||
},
|
},
|
||||||
deep: true,
|
deep: true,
|
||||||
},
|
},
|
||||||
|
|
@ -38,18 +50,17 @@ export default {
|
||||||
methods: {
|
methods: {
|
||||||
updateIngredient() {
|
updateIngredient() {
|
||||||
if (this.ingredientText != this.ingredient.line)
|
if (this.ingredientText != this.ingredient.line)
|
||||||
this.$emit('update-ingredient', this.ingredient, this.ingredientText);
|
this.$emit('update-ingredient', this.ingredient, this.ingredientText)
|
||||||
},
|
},
|
||||||
updateProductLink() {
|
updateProductLink() {
|
||||||
if (this.productLink && this.productLink != this.ingredient.product?.link)
|
if (this.productLink && this.productLink != this.ingredient.product?.link)
|
||||||
this.$emit('update-product-link', this.ingredient, this.productLink);
|
this.$emit('update-product-link', this.ingredient, this.productLink)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
input {
|
input {
|
||||||
border: 0;
|
border: 0;
|
||||||
font-size: larger;
|
font-size: larger;
|
||||||
|
|
@ -63,6 +74,4 @@ input {
|
||||||
color: #777;
|
color: #777;
|
||||||
margin-top: 0.5vh;
|
margin-top: 0.5vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -9,10 +9,7 @@
|
||||||
/>
|
/>
|
||||||
<div v-if="showDatePicker" class="date-picker-dropdown">
|
<div v-if="showDatePicker" class="date-picker-dropdown">
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li v-for="(day, index) in days" :key="index" @mousedown="selectDate(day)">
|
||||||
v-for="(day, index) in days"
|
|
||||||
:key="index"
|
|
||||||
@mousedown="selectDate(day)">
|
|
||||||
{{ formatDay(day) }}
|
{{ formatDay(day) }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -32,45 +29,45 @@
|
||||||
return {
|
return {
|
||||||
selectedDate: this.formatDay(this.date),
|
selectedDate: this.formatDay(this.date),
|
||||||
showDatePicker: false,
|
showDatePicker: false,
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
date(newDate) {
|
date(newDate) {
|
||||||
this.selectedDate = this.formatDay(newDate);
|
this.selectedDate = this.formatDay(newDate)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
days() {
|
days() {
|
||||||
const today = new Date();
|
const today = new Date()
|
||||||
const days = [];
|
const days = []
|
||||||
|
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 15; i++) {
|
||||||
const date = new Date(today);
|
const date = new Date(today)
|
||||||
date.setDate(today.getDate() + i);
|
date.setDate(today.getDate() + i)
|
||||||
days.push(date);
|
days.push(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
return days;
|
return days
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatDay(date) {
|
formatDay(date) {
|
||||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
const options = { weekday: 'long', day: 'numeric', month: 'numeric' }
|
||||||
return date.toLocaleDateString("en-AU", options);
|
return date.toLocaleDateString('en-AU', options)
|
||||||
},
|
},
|
||||||
selectDate(date) {
|
selectDate(date) {
|
||||||
this.selectedDate = this.formatSelectedDate(date);
|
this.selectedDate = this.formatSelectedDate(date)
|
||||||
this.showDatePicker = false;
|
this.showDatePicker = false
|
||||||
|
|
||||||
// Emit custom event
|
// Emit custom event
|
||||||
this.$emit("date-selected", date);
|
this.$emit('date-selected', date)
|
||||||
},
|
},
|
||||||
formatSelectedDate(date) {
|
formatSelectedDate(date) {
|
||||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
const options = { weekday: 'long', day: 'numeric', month: 'numeric' }
|
||||||
return date.toLocaleDateString("en-AU", options);
|
return date.toLocaleDateString('en-AU', options)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -118,4 +115,3 @@
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -3,9 +3,24 @@
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
|
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
|
||||||
<div class="persons-list">
|
<div class="persons-list">
|
||||||
Cooked by <person-list :people="meal.chefs" @remove-person="(p) => removePerson('chefs', p)" @add-person="(p) => addPerson('chefs', p)" />
|
Cooked by
|
||||||
for <person-list :people="meal.consumers" @remove-person="(p) => removePerson('consumers', p)" @add-person="(p) => addPerson('consumers', p)" />,
|
<person-list
|
||||||
with <person-list :people="meal.cleanup" @remove-person="(p) => removePerson('cleanup', p)" @add-person="(p) => addPerson('cleanup', p)" /> on cleanup.
|
:people="meal.chefs"
|
||||||
|
@remove-person="(p) => removePerson('chefs', p)"
|
||||||
|
@add-person="(p) => addPerson('chefs', p)"
|
||||||
|
/>
|
||||||
|
for
|
||||||
|
<person-list
|
||||||
|
:people="meal.consumers"
|
||||||
|
@remove-person="(p) => removePerson('consumers', p)"
|
||||||
|
@add-person="(p) => addPerson('consumers', p)"
|
||||||
|
/>, with
|
||||||
|
<person-list
|
||||||
|
:people="meal.cleanup"
|
||||||
|
@remove-person="(p) => removePerson('cleanup', p)"
|
||||||
|
@add-person="(p) => addPerson('cleanup', p)"
|
||||||
|
/>
|
||||||
|
on cleanup.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipes">
|
<div class="recipes">
|
||||||
|
|
@ -22,8 +37,15 @@
|
||||||
<small><em>servings</em></small>
|
<small><em>servings</em></small>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<input type="checkbox" class="show-ingredient-checkbox" :checked="showIngredient(mealRecipe)" />
|
<input
|
||||||
<label for="show-ingredients" @click="showIngredient(mealRecipe, !showIngredient(mealRecipe))">
|
type="checkbox"
|
||||||
|
class="show-ingredient-checkbox"
|
||||||
|
:checked="showIngredient(mealRecipe)"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="show-ingredients"
|
||||||
|
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
|
||||||
|
>
|
||||||
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
|
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
|
||||||
</label>
|
</label>
|
||||||
<button class="icon-button" @click="removeRecipe(mealRecipe)">
|
<button class="icon-button" @click="removeRecipe(mealRecipe)">
|
||||||
|
|
@ -33,7 +55,11 @@
|
||||||
|
|
||||||
<div v-if="showIngredient(mealRecipe)">
|
<div v-if="showIngredient(mealRecipe)">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient">
|
<li
|
||||||
|
v-for="ingredient in scaleIngredients(mealRecipe)"
|
||||||
|
:key="ingredient.id"
|
||||||
|
class="saved-ingredient"
|
||||||
|
>
|
||||||
<CompactParsedIngredient :ingredient="ingredient" />
|
<CompactParsedIngredient :ingredient="ingredient" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -49,11 +75,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ingredients">
|
<div class="ingredients">
|
||||||
<h2>Sides & Additional Ingredients</h2>
|
<h2>Sides & Additional Ingredients</h2>
|
||||||
<editable-ingredients-panel :ingredients="meal.extra_ingredients" @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditAdditionalIngredients" />
|
<editable-ingredients-panel
|
||||||
|
:ingredients="meal.extra_ingredients"
|
||||||
|
@on-add="addIngredient"
|
||||||
|
@on-delete="deleteIngredient"
|
||||||
|
@on-update-ingredient="updateIngredient"
|
||||||
|
@on-editing="onEditAdditionalIngredients"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="onSaveMeal">Save</button>
|
<button @click="onSaveMeal">Save</button>
|
||||||
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
|
<p v-if="meal.purchase_date">
|
||||||
|
<em>Purchased {{ ago(meal.purchase_date) }}</em>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -63,20 +97,20 @@ import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getMeal, saveMeal } from '@/composables/useMeals'
|
import { getMeal, saveMeal } from '@/composables/useMeals'
|
||||||
import { getRecipe } from '@/api/recipes'
|
import { getRecipe } from '@/api/recipes'
|
||||||
import { currentUser } from '@/api/auth'
|
import { currentUser } from '@/api/auth'
|
||||||
import alert from '@/alert.js';
|
import alert from '@/alert.js'
|
||||||
|
|
||||||
import { ago } from '@/dateformats.js';
|
import { ago } from '@/dateformats.js'
|
||||||
|
|
||||||
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue';
|
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
|
||||||
import RecipeCard from '@/components/recipes/RecipeCard.vue';
|
import RecipeCard from '@/components/recipes/RecipeCard.vue'
|
||||||
import DatePicker from './DatePicker.vue';
|
import DatePicker from './DatePicker.vue'
|
||||||
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
|
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
|
||||||
import PersonList from './PersonList.vue';
|
import PersonList from './PersonList.vue'
|
||||||
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue';
|
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
|
||||||
|
|
||||||
function addPersonIfNotExists(list, person) {
|
function addPersonIfNotExists(list, person) {
|
||||||
if (!list.find(p => p.id === person.id)) {
|
if (!list.find((p) => p.id === person.id)) {
|
||||||
list.push(person);
|
list.push(person)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -111,7 +145,11 @@ function selectDate(date) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeRecipe(mealRecipe) {
|
function removeRecipe(mealRecipe) {
|
||||||
if (confirm(`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`)) {
|
if (
|
||||||
|
confirm(
|
||||||
|
`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
meal.recipes = meal.recipes.filter((r) => r != mealRecipe)
|
meal.recipes = meal.recipes.filter((r) => r != mealRecipe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,12 +225,15 @@ async function onSaveMeal() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
alert.show({ heading: 'Error saving meal', message: 'An error occurred while saving the meal', type: 'error' })
|
alert.show({
|
||||||
|
heading: 'Error saving meal',
|
||||||
|
message: 'An error occurred while saving the meal',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
img.icon {
|
img.icon {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
|
|
@ -245,7 +286,6 @@ li {
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.saved-recipe button:hover {
|
.saved-recipe button:hover {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
}
|
}
|
||||||
|
|
@ -303,5 +343,4 @@ li {
|
||||||
.show-ingredient-checkbox:checked + label {
|
.show-ingredient-checkbox:checked + label {
|
||||||
filter: invert(1);
|
filter: invert(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="meal-card">
|
<div class="meal-card">
|
||||||
<h3>{{ mealTitle }}</h3>
|
<h3>{{ mealTitle }}</h3>
|
||||||
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
|
<h4>
|
||||||
|
{{ dayOfWeek }} <small>{{ date }}</small>
|
||||||
|
</h4>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Cooked by
|
Cooked by
|
||||||
|
|
@ -17,40 +19,36 @@
|
||||||
</span>
|
</span>
|
||||||
<span v-if="!meal.consumers.length">somebody?</span>
|
<span v-if="!meal.consumers.length">somebody?</span>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="meal.purchase_date">
|
<p v-if="meal.purchase_date">Purchased {{ ago(meal.purchase_date) }}</p>
|
||||||
Purchased {{ ago(meal.purchase_date) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style></style>
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import { ago } from '@/dateformats.js'
|
import { ago } from '@/dateformats.js'
|
||||||
|
|
||||||
function englishSeperator(index, list) {
|
function englishSeperator(index, list) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case list.length - 1:
|
case list.length - 1:
|
||||||
return '';
|
return ''
|
||||||
case list.length - 2:
|
case list.length - 2:
|
||||||
return ' and ';
|
return ' and '
|
||||||
default:
|
default:
|
||||||
return ', ';
|
return ', '
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function englishList(list) {
|
function englishList(list) {
|
||||||
switch (list.length) {
|
switch (list.length) {
|
||||||
case 0:
|
case 0:
|
||||||
return '';
|
return ''
|
||||||
case 1:
|
case 1:
|
||||||
return list[0];
|
return list[0]
|
||||||
case 2:
|
case 2:
|
||||||
return `${list[0]} and ${list[1]}`;
|
return `${list[0]} and ${list[1]}`
|
||||||
default:
|
default:
|
||||||
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
|
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,27 +57,32 @@ export default {
|
||||||
props: ['meal'],
|
props: ['meal'],
|
||||||
computed: {
|
computed: {
|
||||||
date() {
|
date() {
|
||||||
return this.meal.suggested_date.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
|
return this.meal.suggested_date.toLocaleDateString('en-au', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
dayOfWeek() {
|
dayOfWeek() {
|
||||||
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
|
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
|
||||||
},
|
},
|
||||||
mealTitle() {
|
mealTitle() {
|
||||||
const recipesText = englishList(this.meal.recipes.map(mealRecipe => mealRecipe.recipe.name));
|
const recipesText = englishList(this.meal.recipes.map((mealRecipe) => mealRecipe.recipe.name))
|
||||||
const ingredientsText = englishList(this.meal.extra_ingredients.map(ingredient => ingredient.name));
|
const ingredientsText = englishList(
|
||||||
|
this.meal.extra_ingredients.map((ingredient) => ingredient.name)
|
||||||
|
)
|
||||||
|
|
||||||
if (recipesText && ingredientsText) {
|
if (recipesText && ingredientsText) {
|
||||||
return `${recipesText} with ${ingredientsText}`;
|
return `${recipesText} with ${ingredientsText}`
|
||||||
} else if (recipesText || ingredientsText) {
|
} else if (recipesText || ingredientsText) {
|
||||||
return recipesText || ingredientsText;
|
return recipesText || ingredientsText
|
||||||
} else {
|
} else {
|
||||||
return 'Nothing planned';
|
return 'Nothing planned'
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
englishSeperator,
|
englishSeperator,
|
||||||
ago
|
ago,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -3,12 +3,22 @@
|
||||||
<ul class="meals-list" v-if="meals.length">
|
<ul class="meals-list" v-if="meals.length">
|
||||||
<li v-for="meal in meals" :key="meal.id">
|
<li v-for="meal in meals" :key="meal.id">
|
||||||
<meal-card :meal="meal" />
|
<meal-card :meal="meal" />
|
||||||
<button class="toggle-actions" @click="selectedMeal = ((meal == selectedMeal) ? null : meal)">
|
<button class="toggle-actions" @click="selectedMeal = meal == selectedMeal ? null : meal">
|
||||||
<img :src="meal == selectedMeal ? require('@/assets/chevron-down.svg') : require('@/assets/chevron-up.svg')" />
|
<img
|
||||||
|
:src="
|
||||||
|
meal == selectedMeal
|
||||||
|
? require('@/assets/chevron-down.svg')
|
||||||
|
: require('@/assets/chevron-up.svg')
|
||||||
|
"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul class="actions" v-if="selectedMeal == meal">
|
<ul class="actions" v-if="selectedMeal == meal">
|
||||||
<li><router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active">Edit Meal</router-link></li>
|
<li>
|
||||||
|
<router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active"
|
||||||
|
>Edit Meal</router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
<li><a @click="markConsumed">Mark Consumed</a></li>
|
<li><a @click="markConsumed">Mark Consumed</a></li>
|
||||||
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
|
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -17,7 +27,11 @@
|
||||||
<div v-if="!meals.length">
|
<div v-if="!meals.length">
|
||||||
<em>No meals planned</em>
|
<em>No meals planned</em>
|
||||||
</div>
|
</div>
|
||||||
<action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" />
|
<action-item
|
||||||
|
title="Plan Meal"
|
||||||
|
:image="require('@/assets/plan-meal.svg')"
|
||||||
|
@click="() => this.$router.push('/meals/add')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -75,7 +89,6 @@ ul.actions {
|
||||||
padding-top: 2ex;
|
padding-top: 2ex;
|
||||||
padding-bottom: 2ex;
|
padding-bottom: 2ex;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -84,7 +97,6 @@ import ActionItem from '@/components/ActionItem.vue'
|
||||||
import MealCard from '@/components/meals/MealCard.vue'
|
import MealCard from '@/components/meals/MealCard.vue'
|
||||||
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals'
|
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals'
|
||||||
|
|
||||||
|
|
||||||
const from = new Date()
|
const from = new Date()
|
||||||
from.setTime(0)
|
from.setTime(0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<span class="person-list">
|
<span class="person-list">
|
||||||
<span v-for="person in people" :key="person.id">
|
<span v-for="person in people" :key="person.id">
|
||||||
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
|
<button class="person-circle remove-person" @click="$emit('remove-person', person)">
|
||||||
|
{{ person.name }}
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<button v-if="!isAddingPerson" class="person-circle add-person" @click="isAddingPerson = true">+</button>
|
<button
|
||||||
<input v-else v-model="searchName" ref="searchNameInput" @keyup.enter="addPerson" @keyup.esc="isAddingPerson = false" @blur="isAddingPerson = false" />
|
v-if="!isAddingPerson"
|
||||||
<ul class="person-droplist" ref="persondroplist" v-if="isAddingPerson && searchResults.length">
|
class="person-circle add-person"
|
||||||
|
@click="isAddingPerson = true"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="searchName"
|
||||||
|
ref="searchNameInput"
|
||||||
|
@keyup.enter="addPerson"
|
||||||
|
@keyup.esc="isAddingPerson = false"
|
||||||
|
@blur="isAddingPerson = false"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
class="person-droplist"
|
||||||
|
ref="persondroplist"
|
||||||
|
v-if="isAddingPerson && searchResults.length"
|
||||||
|
>
|
||||||
<li v-for="person in searchResults" :key="person.id">
|
<li v-for="person in searchResults" :key="person.id">
|
||||||
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
|
<button class="person-circle add-person" @mousedown="addPerson(person)">
|
||||||
|
{{ person.name }}
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -16,7 +37,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.person-list {
|
.person-list {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -53,9 +73,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add a cross to the circle */
|
/* Add a cross to the circle */
|
||||||
.remove-person:hover::before, .remove-person:hover::after {
|
.remove-person:hover::before,
|
||||||
|
.remove-person:hover::after {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
content: "X";
|
content: 'X';
|
||||||
color: white;
|
color: white;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -94,11 +115,10 @@
|
||||||
display: inline;
|
display: inline;
|
||||||
padding: 1ex 1em;
|
padding: 1ex 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue'
|
||||||
import { searchPerson } from '@/api/persons'
|
import { searchPerson } from '@/api/persons'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -106,8 +126,8 @@ export default {
|
||||||
props: {
|
props: {
|
||||||
people: {
|
people: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => [],
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -117,55 +137,51 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const searchNameInput = ref(null);
|
const searchNameInput = ref(null)
|
||||||
const persondroplist = ref(null);
|
const persondroplist = ref(null)
|
||||||
return { searchNameInput, persondroplist };
|
return { searchNameInput, persondroplist }
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
searchName: async function () {
|
searchName: async function () {
|
||||||
await this.updateSearchResults()
|
await this.updateSearchResults()
|
||||||
},
|
},
|
||||||
searchNameInput: async function () {
|
searchNameInput: async function () {
|
||||||
this.searchNameInput?.focus();
|
this.searchNameInput?.focus()
|
||||||
await this.updateSearchResults()
|
await this.updateSearchResults()
|
||||||
},
|
},
|
||||||
persondroplist: function () {
|
persondroplist: function () {
|
||||||
if (this.persondroplist && this.searchNameInput)
|
if (this.persondroplist && this.searchNameInput) {
|
||||||
{
|
|
||||||
// Align the droplist to the input field & its size
|
// Align the droplist to the input field & its size
|
||||||
const inputRect = this.searchNameInput.getBoundingClientRect();
|
const inputRect = this.searchNameInput.getBoundingClientRect()
|
||||||
this.persondroplist.style.left = `${inputRect.left}px`;
|
this.persondroplist.style.left = `${inputRect.left}px`
|
||||||
this.persondroplist.style.top = `${inputRect.bottom}px`;
|
this.persondroplist.style.top = `${inputRect.bottom}px`
|
||||||
this.persondroplist.style.width = `${inputRect.width}px`;
|
this.persondroplist.style.width = `${inputRect.width}px`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async updateSearchResults() {
|
async updateSearchResults() {
|
||||||
const results = await searchPerson(this.searchName);
|
const results = await searchPerson(this.searchName)
|
||||||
// Exclude people already in the list
|
// Exclude people already in the list
|
||||||
const idSet = new Set(this.people.map(p => p.id));
|
const idSet = new Set(this.people.map((p) => p.id))
|
||||||
this.searchResults = results.filter(p => !idSet.has(p.id));
|
this.searchResults = results.filter((p) => !idSet.has(p.id))
|
||||||
},
|
},
|
||||||
addPerson(person) {
|
addPerson(person) {
|
||||||
if (!person && this.searchResults.length > 0)
|
if (!person && this.searchResults.length > 0) {
|
||||||
{
|
person = this.searchResults[0]
|
||||||
person = this.searchResults[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (person?.id >= 0 && !this.people.find(p => p.id === person.id))
|
if (person?.id >= 0 && !this.people.find((p) => p.id === person.id)) {
|
||||||
{
|
this.$emit('add-person', person)
|
||||||
this.$emit('add-person', person);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.searchName = '';
|
this.searchName = ''
|
||||||
this.searchResults = [];
|
this.searchResults = []
|
||||||
this.isAddingPerson = false;
|
this.isAddingPerson = false
|
||||||
},
|
},
|
||||||
removePerson(person) {
|
removePerson(person) {
|
||||||
this.$emit('remove-person', person);
|
this.$emit('remove-person', person)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -20,12 +20,14 @@
|
||||||
<editable-ingredients-panel
|
<editable-ingredients-panel
|
||||||
:ingredients="recipe.ingredients"
|
:ingredients="recipe.ingredients"
|
||||||
:edit-only="true"
|
:edit-only="true"
|
||||||
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
|
@on-add="addIngredient"
|
||||||
|
@on-delete="deleteIngredient"
|
||||||
|
@on-update-ingredient="updateIngredient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
|
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
|
||||||
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button>
|
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? 'Save' : 'Create' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -56,78 +58,86 @@ input.recipe-name {
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-link {
|
.recipe-link {
|
||||||
color: #0000EE;
|
color: #0000ee;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import alert from '@/alert.js'
|
import alert from '@/alert.js'
|
||||||
import { getRecipe, parseRecipe, saveRecipe, deleteRecipe as deleteRecipeApi } from '@/api/recipes'
|
import { getRecipe, parseRecipe, saveRecipe, deleteRecipe as deleteRecipeApi } from '@/api/recipes'
|
||||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
id: { type: Number, optional: true }
|
id: { type: Number, optional: true },
|
||||||
},
|
},
|
||||||
components: { EditableIngredientsPanel },
|
components: { EditableIngredientsPanel },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
link: this.$route.query.url ?? "",
|
link: this.$route.query.url ?? '',
|
||||||
parse_failed: false,
|
parse_failed: false,
|
||||||
recipe: null,
|
recipe: null,
|
||||||
chefs: [],
|
chefs: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refreshRecipe();
|
this.refreshRecipe()
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
image_styling() {
|
image_styling() {
|
||||||
if (this.recipe?.image_urls && this.recipe.image_urls[0]) {
|
if (this.recipe?.image_urls && this.recipe.image_urls[0]) {
|
||||||
const image = this.recipe.image_urls[0];
|
const image = this.recipe.image_urls[0]
|
||||||
// linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2019/04/steamed-broccoli-4.jpg') center/cover no-repeat;
|
// linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('https://i2.wp.com/www.downshiftology.com/wp-content/uploads/2019/04/steamed-broccoli-4.jpg') center/cover no-repeat;
|
||||||
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat` }
|
return {
|
||||||
|
background: `linear-gradient(to bottom, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0) 100%), url('${image}') center/cover no-repeat`,
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
parseLink() {
|
parseLink() {
|
||||||
this.$router.push({ path: '/recipes/add', query: { url: this.link } })
|
this.$router.push({ path: '/recipes/add', query: { url: this.link } })
|
||||||
this.refreshRecipe();
|
this.refreshRecipe()
|
||||||
},
|
},
|
||||||
async refreshRecipe() {
|
async refreshRecipe() {
|
||||||
if (this.id >= 0) {
|
if (this.id >= 0) {
|
||||||
this.recipe = await getRecipe(this.id);
|
this.recipe = await getRecipe(this.id)
|
||||||
this.link = this.recipe.link;
|
this.link = this.recipe.link
|
||||||
return;
|
return
|
||||||
}
|
} else if (this.link) {
|
||||||
else if (this.link) {
|
this.recipe = await parseRecipe(this.link)
|
||||||
this.recipe = await parseRecipe(this.link);
|
this.parse_failed = !this.recipe
|
||||||
this.parse_failed = !this.recipe;
|
} else {
|
||||||
}
|
this.recipe = null
|
||||||
else {
|
|
||||||
this.recipe = null;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async updateIngredient(ingredient, newIngredient) {
|
async updateIngredient(ingredient, newIngredient) {
|
||||||
this.recipe.ingredients = this.recipe.ingredients.map(i => i == ingredient ? newIngredient : i);
|
this.recipe.ingredients = this.recipe.ingredients.map((i) =>
|
||||||
|
i == ingredient ? newIngredient : i
|
||||||
|
)
|
||||||
},
|
},
|
||||||
deleteIngredient(ingredient) {
|
deleteIngredient(ingredient) {
|
||||||
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient);
|
this.recipe.ingredients = this.recipe.ingredients.filter((i) => i != ingredient)
|
||||||
},
|
},
|
||||||
async saveRecipe() {
|
async saveRecipe() {
|
||||||
const recipe = await saveRecipe(this.recipe);
|
const recipe = await saveRecipe(this.recipe)
|
||||||
if (recipe?.id >= 0) {
|
if (recipe?.id >= 0) {
|
||||||
alert.show({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' });
|
alert.show({
|
||||||
this.$router.push(`/recipes/${recipe.id}`);
|
heading: 'Recipe saved',
|
||||||
return;
|
message: 'Your recipe has been saved',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
this.$router.push(`/recipes/${recipe.id}`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
alert.show({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' });
|
alert.show({
|
||||||
|
heading: 'Error saving recipe',
|
||||||
|
message: 'There was an error saving your recipe',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
},
|
},
|
||||||
async createFromScratch() {
|
async createFromScratch() {
|
||||||
this.recipe = {
|
this.recipe = {
|
||||||
|
|
@ -136,18 +146,18 @@ export default {
|
||||||
created_by_id: -1,
|
created_by_id: -1,
|
||||||
link: '',
|
link: '',
|
||||||
ingredients: [],
|
ingredients: [],
|
||||||
image_urls: []
|
image_urls: [],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
addIngredient() {
|
addIngredient() {
|
||||||
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients];
|
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients]
|
||||||
},
|
},
|
||||||
async deleteRecipe() {
|
async deleteRecipe() {
|
||||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||||
await deleteRecipeApi(this.recipe.id);
|
await deleteRecipeApi(this.recipe.id)
|
||||||
this.$router.push('/recipes');
|
this.$router.push('/recipes')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -11,12 +11,11 @@
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'RecipeCard',
|
name: 'RecipeCard',
|
||||||
props: ['recipe']
|
props: ['recipe'],
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.recipe-card {
|
.recipe-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
@ -42,5 +41,4 @@ li img {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="recipe-search-box" @focusout="recipes = []">
|
<div class="recipe-search-box" @focusout="recipes = []">
|
||||||
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
|
<input
|
||||||
:placeholder="placeholder" />
|
type="text"
|
||||||
|
v-model="searchTerm"
|
||||||
|
@keyup.enter="search"
|
||||||
|
@keyup.exit="clear"
|
||||||
|
@focusin="search"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
/>
|
||||||
<ul v-if="recipes?.length" class="dropdown">
|
<ul v-if="recipes?.length" class="dropdown">
|
||||||
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
|
<li
|
||||||
|
class="recipe"
|
||||||
|
v-for="recipe in recipes"
|
||||||
|
:key="recipe.id"
|
||||||
|
@mousedown="selectRecipe(recipe)"
|
||||||
|
>
|
||||||
<recipe-card :recipe="recipe" />
|
<recipe-card :recipe="recipe" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -12,13 +23,13 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { searchRecipes } from '@/api/recipes'
|
import { searchRecipes } from '@/api/recipes'
|
||||||
import RecipeCard from './RecipeCard.vue';
|
import RecipeCard from './RecipeCard.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RecipeSearchBox',
|
name: 'RecipeSearchBox',
|
||||||
components: { RecipeCard },
|
components: { RecipeCard },
|
||||||
props: {
|
props: {
|
||||||
placeholder: { type: String, default: 'Add a recipe...' }
|
placeholder: { type: String, default: 'Add a recipe...' },
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -29,26 +40,28 @@ export default {
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
searchTerm() {
|
searchTerm() {
|
||||||
const searchTerm = this.searchTerm;
|
const searchTerm = this.searchTerm
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
this.timeouts.push(setTimeout(() => {
|
this.timeouts.push(
|
||||||
|
setTimeout(() => {
|
||||||
if (searchTerm === this.searchTerm) {
|
if (searchTerm === this.searchTerm) {
|
||||||
this.search();
|
this.search()
|
||||||
}
|
|
||||||
}, 200));
|
|
||||||
}
|
}
|
||||||
|
}, 200)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async search() {
|
async search() {
|
||||||
this.recipes = await searchRecipes(this.searchTerm) ?? this.recipes;
|
this.recipes = (await searchRecipes(this.searchTerm)) ?? this.recipes
|
||||||
},
|
},
|
||||||
selectRecipe(recipe) {
|
selectRecipe(recipe) {
|
||||||
this.$emit('select-recipe', recipe);
|
this.$emit('select-recipe', recipe)
|
||||||
this.searchTerm = '';
|
this.searchTerm = ''
|
||||||
this.recipes = [];
|
this.recipes = []
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,25 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<recipe-search-box placeholder="Search for a recipe..." @select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"/>
|
<recipe-search-box
|
||||||
<action-item title="Add new Recipe" :image="require('@/assets/add-recipe.svg')" @click="() => this.$router.push('/recipes/add')" />
|
placeholder="Search for a recipe..."
|
||||||
|
@select-recipe="(r) => this.$router.push(`/recipes/${r.id}`)"
|
||||||
|
/>
|
||||||
|
<action-item
|
||||||
|
title="Add new Recipe"
|
||||||
|
:image="require('@/assets/add-recipe.svg')"
|
||||||
|
@click="() => this.$router.push('/recipes/add')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script>
|
<script>
|
||||||
import ActionItem from '@/components/ActionItem.vue'
|
import ActionItem from '@/components/ActionItem.vue'
|
||||||
import RecipeSearchBox from './RecipeSearchBox.vue';
|
import RecipeSearchBox from './RecipeSearchBox.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ActionsPage',
|
name: 'ActionsPage',
|
||||||
components: {
|
components: {
|
||||||
ActionItem,
|
ActionItem,
|
||||||
RecipeSearchBox
|
RecipeSearchBox,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -2,18 +2,27 @@
|
||||||
<h3>Full shopping list</h3>
|
<h3>Full shopping list</h3>
|
||||||
|
|
||||||
<h4>Included Meals</h4>
|
<h4>Included Meals</h4>
|
||||||
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
|
<meal-selection-list
|
||||||
|
@meal-selected="mealSelected"
|
||||||
|
@meal-unselected="mealUnselected"
|
||||||
|
:checked="includedMeals"
|
||||||
|
:meals="availableMeals"
|
||||||
|
/>
|
||||||
|
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="group in outstandingItemGroups" :key="group.id" class="selectable" :class="{ 'selected': isSelected(group) }" @click="toggleSelect(group)">
|
<li
|
||||||
|
v-for="group in outstandingItemGroups"
|
||||||
|
:key="group.id"
|
||||||
|
class="selectable"
|
||||||
|
:class="{ selected: isSelected(group) }"
|
||||||
|
@click="toggleSelect(group)"
|
||||||
|
>
|
||||||
<shopping-list-item :shopping-list-item-group="group" />
|
<shopping-list-item :shopping-list-item-group="group" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-if="outstandingItemGroups.length === 0">
|
<div v-if="outstandingItemGroups.length === 0">
|
||||||
<p>
|
<p>No items to purchase</p>
|
||||||
No items to purchase
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="purchased-slider">
|
<div class="purchased-slider">
|
||||||
|
|
@ -23,11 +32,14 @@
|
||||||
|
|
||||||
<div v-if="showPurchased && purchasedItemGroups.length > 0">
|
<div v-if="showPurchased && purchasedItemGroups.length > 0">
|
||||||
<h4>Purchased Meals</h4>
|
<h4>Purchased Meals</h4>
|
||||||
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" />
|
<meal-selection-list
|
||||||
|
@meal-selected="mealSelected"
|
||||||
|
@meal-unselected="mealUnselected"
|
||||||
|
:checked="includedMeals"
|
||||||
|
:meals="purchasedMeals"
|
||||||
|
/>
|
||||||
|
|
||||||
<h4>
|
<h4>Purchased Items</h4>
|
||||||
Purchased Items
|
|
||||||
</h4>
|
|
||||||
<ul class="full-shopping-list">
|
<ul class="full-shopping-list">
|
||||||
<li v-for="item in purchasedItemGroups" :key="item.id">
|
<li v-for="item in purchasedItemGroups" :key="item.id">
|
||||||
<shopping-list-item :shopping-list-item-group="item" />
|
<shopping-list-item :shopping-list-item-group="item" />
|
||||||
|
|
@ -36,18 +48,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spacer" v-if="selected.length">
|
<div class="spacer" v-if="selected.length"> </div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
|
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
|
||||||
<div class="footer-buttons" v-if="selected.length">
|
<div class="footer-buttons" v-if="selected.length">
|
||||||
<p v-if="selected.length === 1">
|
<p v-if="selected.length === 1">
|
||||||
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
|
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
|
||||||
</p>
|
</p>
|
||||||
<p v-else>
|
<p v-else>Mark {{ selected.length }} items as</p>
|
||||||
Mark {{ selected.length }} items as
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<button @click="markFound">
|
<button @click="markFound">
|
||||||
|
|
@ -69,7 +77,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.full-shopping-list {
|
.full-shopping-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -130,7 +137,6 @@ button img {
|
||||||
.spacer {
|
.spacer {
|
||||||
height: 12em;
|
height: 12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -162,7 +168,9 @@ const groupsMatch = (a, b) => {
|
||||||
return a.product.id === b.product.id
|
return a.product.id === b.product.id
|
||||||
}
|
}
|
||||||
|
|
||||||
const outstandingItemGroups = computed(() => itemsToGroups(shoppingList.value?.outstanding_items ?? []))
|
const outstandingItemGroups = computed(() =>
|
||||||
|
itemsToGroups(shoppingList.value?.outstanding_items ?? [])
|
||||||
|
)
|
||||||
const purchasedItemGroups = computed(() => itemsToGroups(shoppingList.value?.purchased_items ?? []))
|
const purchasedItemGroups = computed(() => itemsToGroups(shoppingList.value?.purchased_items ?? []))
|
||||||
const purchasedMeals = computed(() => uniqueMeals(shoppingList.value?.purchased_items ?? []))
|
const purchasedMeals = computed(() => uniqueMeals(shoppingList.value?.purchased_items ?? []))
|
||||||
const availableMeals = computed(() => {
|
const availableMeals = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="meal in meals" :key="meal.id">
|
<li v-for="meal in meals" :key="meal.id">
|
||||||
<!-- Have a checkbox and card for each meal, show the image and name -->
|
<!-- Have a checkbox and card for each meal, show the image and name -->
|
||||||
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
||||||
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:id="meal.id"
|
||||||
|
:checked="isChecked(meal)"
|
||||||
|
@change="mealCheckChanged"
|
||||||
|
:disabled="disabled"
|
||||||
|
/>
|
||||||
<label :for="meal.id" :style="getImageStyling(meal)">
|
<label :for="meal.id" :style="getImageStyling(meal)">
|
||||||
{{ formatDate(meal.suggested_date) }}
|
{{ formatDate(meal.suggested_date) }}
|
||||||
</label>
|
</label>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +34,7 @@ li {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide the default checkbox formatting, and format the card instead */
|
/* Hide the default checkbox formatting, and format the card instead */
|
||||||
input[type="checkbox"] {
|
input[type='checkbox'] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,12 +51,12 @@ label {
|
||||||
color: #3d5447;
|
color: #3d5447;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked + label {
|
input[type='checkbox']:checked + label {
|
||||||
border: 3px solid #3d5447;
|
border: 3px solid #3d5447;
|
||||||
text-shadow: #ccc 0 0 0.1em;
|
text-shadow: #ccc 0 0 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:disabled + label {
|
input[type='checkbox']:disabled + label {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,20 +75,22 @@ label {
|
||||||
font-size: larger;
|
font-size: larger;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
const nth = (d) => {
|
const nth = (d) => {
|
||||||
if (d > 3 && d < 21) return 'th';
|
if (d > 3 && d < 21) return 'th'
|
||||||
switch (d % 10) {
|
switch (d % 10) {
|
||||||
case 1: return "st";
|
case 1:
|
||||||
case 2: return "nd";
|
return 'st'
|
||||||
case 3: return "rd";
|
case 2:
|
||||||
default: return "th";
|
return 'nd'
|
||||||
|
case 3:
|
||||||
|
return 'rd'
|
||||||
|
default:
|
||||||
|
return 'th'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date) => {
|
const formatDate = (date) => {
|
||||||
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
||||||
|
|
@ -94,9 +99,9 @@ const formatDate = (date) => {
|
||||||
const getUrl = (meal) => {
|
const getUrl = (meal) => {
|
||||||
// First non empty value in meal.recipe/image_urls
|
// First non empty value in meal.recipe/image_urls
|
||||||
for (const mr of meal.recipes) {
|
for (const mr of meal.recipes) {
|
||||||
const recipe = mr.recipe;
|
const recipe = mr.recipe
|
||||||
if (recipe.image_urls.length && recipe.image_urls[0]) {
|
if (recipe.image_urls.length && recipe.image_urls[0]) {
|
||||||
return recipe.image_urls[0];
|
return recipe.image_urls[0]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,16 +109,16 @@ const getUrl = (meal) => {
|
||||||
for (const ingredient of meal.extra_ingredients) {
|
for (const ingredient of meal.extra_ingredients) {
|
||||||
if (ingredient.product) {
|
if (ingredient.product) {
|
||||||
if (ingredient.product.img_large) {
|
if (ingredient.product.img_large) {
|
||||||
return ingredient.product.img_large;
|
return ingredient.product.img_large
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ingredient.product.img_small) {
|
if (ingredient.product.img_small) {
|
||||||
return ingredient.product.img_small;
|
return ingredient.product.img_small
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -123,38 +128,39 @@ export default {
|
||||||
checked: Array,
|
checked: Array,
|
||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
formatDate,
|
formatDate,
|
||||||
getImageStyling(meal) {
|
getImageStyling(meal) {
|
||||||
const imageUrl = getUrl(meal);
|
const imageUrl = getUrl(meal)
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const opacity = 0.7;
|
const opacity = 0.7
|
||||||
return {background: `linear-gradient(to bottom, rgba(255, 255, 255, ${ opacity }) 0%, rgba(255, 255, 255, ${ opacity }) 100%), url('${imageUrl}') center/cover no-repeat`};
|
return {
|
||||||
|
background: `linear-gradient(to bottom, rgba(255, 255, 255, ${opacity}) 0%, rgba(255, 255, 255, ${opacity}) 100%), url('${imageUrl}') center/cover no-repeat`,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
mealCheckChanged(event) {
|
mealCheckChanged(event) {
|
||||||
const mealId = parseInt(event.target.id);
|
const mealId = parseInt(event.target.id)
|
||||||
const meal = this.meals.find(m => m.id === mealId);
|
const meal = this.meals.find((m) => m.id === mealId)
|
||||||
if (event.target.checked) {
|
if (event.target.checked) {
|
||||||
this.$emit('meal-selected', meal);
|
this.$emit('meal-selected', meal)
|
||||||
} else {
|
} else {
|
||||||
this.$emit('meal-unselected', meal);
|
this.$emit('meal-unselected', meal)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isChecked(meal) {
|
isChecked(meal) {
|
||||||
for (const checkedMeal of this.checked) {
|
for (const checkedMeal of this.checked) {
|
||||||
if (checkedMeal.id === meal.id) {
|
if (checkedMeal.id === meal.id) {
|
||||||
return true;
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
<div>
|
<div>
|
||||||
<h1>My Shopping List</h1>
|
<h1>My Shopping List</h1>
|
||||||
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
|
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
|
||||||
<editable-ingredients-panel @on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient" @on-editing="onEditing" :ingredients="ingredients" />
|
<editable-ingredients-panel
|
||||||
|
@on-add="addIngredient"
|
||||||
|
@on-delete="deleteIngredient"
|
||||||
|
@on-update-ingredient="updateIngredient"
|
||||||
|
@on-editing="onEditing"
|
||||||
|
:ingredients="ingredients"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|
@ -31,9 +37,7 @@
|
||||||
-->
|
-->
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onBeforeMount } from 'vue'
|
import { ref, onBeforeMount } from 'vue'
|
||||||
|
|
@ -42,7 +46,6 @@ import { useAuth } from '@/composables/useAuth'
|
||||||
import { useShopping } from '@/composables/useShopping'
|
import { useShopping } from '@/composables/useShopping'
|
||||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||||
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { loadUser } = useAuth()
|
const { loadUser } = useAuth()
|
||||||
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
||||||
|
|
@ -51,7 +54,9 @@ const person = ref(null)
|
||||||
const ingredients = ref([])
|
const ingredients = ref([])
|
||||||
|
|
||||||
async function updateShoppingList(save = false) {
|
async function updateShoppingList(save = false) {
|
||||||
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
|
const newIngredients = save
|
||||||
|
? await saveMyShoppingList(ingredients.value)
|
||||||
|
: await getMyShoppingList()
|
||||||
ingredients.value = newIngredients
|
ingredients.value = newIngredients
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,7 +69,9 @@ function deleteIngredient(ingredient) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateIngredient(oldIngredient, newIngredient) {
|
function updateIngredient(oldIngredient, newIngredient) {
|
||||||
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
|
ingredients.value = ingredients.value.map((source) =>
|
||||||
|
source === oldIngredient ? newIngredient : source
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onEditing(isStartingEdit) {
|
async function onEditing(isStartingEdit) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
.full-shopping-list li {
|
.full-shopping-list li {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
|
|
||||||
|
|
@ -26,7 +25,6 @@
|
||||||
.full-shopping-list {
|
.full-shopping-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
@ -41,13 +39,16 @@ import ShoppingListItem from './ShoppingListItem.vue'
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const shoppingList = ref(null)
|
const shoppingList = ref(null)
|
||||||
|
|
||||||
const includedMeals = computed(() => (shoppingList.value ? uniqueMeals(shoppingList.value.items) : []))
|
const includedMeals = computed(() =>
|
||||||
const listByProduct = computed(() => (shoppingList.value ? itemsToGroups(shoppingList.value.items) : []))
|
shoppingList.value ? uniqueMeals(shoppingList.value.items) : []
|
||||||
|
)
|
||||||
|
const listByProduct = computed(() =>
|
||||||
|
shoppingList.value ? itemsToGroups(shoppingList.value.items) : []
|
||||||
|
)
|
||||||
|
|
||||||
onBeforeMount(async () => {
|
onBeforeMount(async () => {
|
||||||
const idParam = route.params.id
|
const idParam = route.params.id
|
||||||
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
|
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
|
||||||
shoppingList.value = await getShoppingList(id)
|
shoppingList.value = await getShoppingList(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,20 +1,28 @@
|
||||||
<template>
|
<template>
|
||||||
|
|
||||||
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
|
<!-- Show a card of the product with the total, taking up the available space and have an expanding section to view sources -->
|
||||||
<div class="shopping-list-item">
|
<div class="shopping-list-item">
|
||||||
<img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
|
<img
|
||||||
|
:src="`${shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg')}`"
|
||||||
|
class="product-image"
|
||||||
|
/>
|
||||||
<div class="product-details">
|
<div class="product-details">
|
||||||
<h3 class="header">
|
<h3 class="header">
|
||||||
<strong>
|
<strong>
|
||||||
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a>
|
<a
|
||||||
<span v-else>{{ shoppingListItemGroup.name }}</span>
|
v-if="shoppingListItemGroup.product?.link"
|
||||||
</strong>,
|
:href="shoppingListItemGroup.product?.link"
|
||||||
|
>{{ shoppingListItemGroup.product?.name }}</a
|
||||||
|
>
|
||||||
|
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong
|
||||||
|
>,
|
||||||
<small>
|
<small>
|
||||||
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
|
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
|
||||||
<span v-if="index">, </span>
|
<span v-if="index">, </span>
|
||||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="found-marker partial" v-if="purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
<span class="found-marker partial" v-if="purchased.length > 0"
|
||||||
|
>✓ {{ getFriendlyDate(lastPurchased) }}</span
|
||||||
|
>
|
||||||
</small>
|
</small>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="sources" v-if="required.length > 0">
|
<p class="sources" v-if="required.length > 0">
|
||||||
|
|
@ -22,15 +30,32 @@
|
||||||
<span v-for="(source, index) in required" :key="source.id">
|
<span v-for="(source, index) in required" :key="source.id">
|
||||||
<span v-if="index">, and </span>
|
<span v-if="index">, and </span>
|
||||||
<span v-if="source.person">
|
<span v-if="source.person">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for
|
||||||
|
{{ source.person.name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.recipe">
|
<span v-else-if="source.recipe">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
||||||
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
|
<router-link :to="`/recipes/${source.recipe.id}/`">{{
|
||||||
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
source.recipe.name
|
||||||
|
}}</router-link>
|
||||||
|
for
|
||||||
|
<router-link :to="`/meals/${source.meal.id}/`">{{
|
||||||
|
source.meal.suggested_date.toLocaleDateString('en-AU', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}}</router-link>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.meal">
|
<span v-else-if="source.meal">
|
||||||
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
{{ source.ingredient.line }} for
|
||||||
|
<router-link :to="`/meals/${source.meal.id}/`">{{
|
||||||
|
source.meal.suggested_date.toLocaleDateString('en-AU', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}}</router-link>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -39,25 +64,40 @@
|
||||||
<span v-for="(source, index) in purchased" :key="source.id">
|
<span v-for="(source, index) in purchased" :key="source.id">
|
||||||
<span v-if="index">, and </span>
|
<span v-if="index">, and </span>
|
||||||
<span v-if="source.person">
|
<span v-if="source.person">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for
|
||||||
|
{{ source.person.name }}
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.recipe">
|
<span v-else-if="source.recipe">
|
||||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
||||||
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ source.recipe.name }}</router-link>
|
<router-link :to="`/recipes/${source.recipe.id}/`">{{
|
||||||
for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
source.recipe.name
|
||||||
|
}}</router-link>
|
||||||
|
for
|
||||||
|
<router-link :to="`/meals/${source.meal.id}/`">{{
|
||||||
|
source.meal.suggested_date.toLocaleDateString('en-AU', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}}</router-link>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="source.meal">
|
<span v-else-if="source.meal">
|
||||||
{{ source.ingredient.line }} for <router-link :to="`/meals/${ source.meal.id }/`">{{ source.meal.suggested_date.toLocaleDateString("en-AU", { weekday: 'long', month: 'long', day: 'numeric' }) }}</router-link>
|
{{ source.ingredient.line }} for
|
||||||
|
<router-link :to="`/meals/${source.meal.id}/`">{{
|
||||||
|
source.meal.suggested_date.toLocaleDateString('en-AU', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}}</router-link>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
||||||
/* Show the product image to the left, then the product name and size to the right */
|
/* Show the product image to the left, then the product name and size to the right */
|
||||||
|
|
||||||
.shopping-list-item {
|
.shopping-list-item {
|
||||||
|
|
@ -106,13 +146,11 @@
|
||||||
.found-marker.partial {
|
.found-marker.partial {
|
||||||
background-color: darkgoldenrod;
|
background-color: darkgoldenrod;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { ago } from '@/dateformats.js'
|
||||||
import { ago } from '@/dateformats.js';
|
import { calculateTotals } from '@/units.js'
|
||||||
import { calculateTotals } from '@/units.js';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ShoppingListItem',
|
name: 'ShoppingListItem',
|
||||||
|
|
@ -124,51 +162,53 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
remainingRequiredTotals() {
|
remainingRequiredTotals() {
|
||||||
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
|
return calculateTotals(
|
||||||
|
this.shoppingListItemGroup.shoppingListItems.map((item) => item.ingredient)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
expectedExistingTotals() {
|
expectedExistingTotals() {
|
||||||
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date));
|
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(
|
||||||
|
(item) => item.list_id && !item?.meal?.consumed_date
|
||||||
|
)
|
||||||
|
|
||||||
return calculateTotals(purchasedNotEaten.map(item => item.ingredient));
|
return calculateTotals(purchasedNotEaten.map((item) => item.ingredient))
|
||||||
},
|
},
|
||||||
required() {
|
required() {
|
||||||
return this.shoppingListItemGroup.shoppingListItems.filter(item => !item.list_id);
|
return this.shoppingListItemGroup.shoppingListItems.filter((item) => !item.list_id)
|
||||||
},
|
},
|
||||||
purchased() {
|
purchased() {
|
||||||
return this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
|
return this.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
|
||||||
},
|
},
|
||||||
lastPurchased() {
|
lastPurchased() {
|
||||||
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
|
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(
|
||||||
|
(item) => item.list_id
|
||||||
|
)
|
||||||
if (purchasedItems.length === 0) {
|
if (purchasedItems.length === 0) {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
// Find the most recently purchased item
|
// Find the most recently purchased item
|
||||||
return purchasedItems.reduce((latest, item) => {
|
return purchasedItems.reduce((latest, item) => {
|
||||||
const itemDate = item?.meal?.suggested_date || item?.created_at;
|
const itemDate = item?.meal?.suggested_date || item?.created_at
|
||||||
return (!latest || (itemDate && itemDate > latest)) ? itemDate : latest;
|
return !latest || (itemDate && itemDate > latest) ? itemDate : latest
|
||||||
}, null);
|
}, null)
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getFriendlyDate(date) {
|
getFriendlyDate(date) {
|
||||||
if (!date)
|
if (!date) return ''
|
||||||
return '';
|
|
||||||
|
|
||||||
return ago(date);
|
return ago(date)
|
||||||
},
|
},
|
||||||
formatQuantity(quantity) {
|
formatQuantity(quantity) {
|
||||||
const log10 = Math.log10(quantity);
|
const log10 = Math.log10(quantity)
|
||||||
if (log10 < 0) {
|
if (log10 < 0) {
|
||||||
return quantity.toPrecision(2);
|
return quantity.toPrecision(2)
|
||||||
|
} else if (log10 < 1) {
|
||||||
|
return quantity.toFixed(1)
|
||||||
|
} else {
|
||||||
|
return quantity.toFixed(0)
|
||||||
}
|
}
|
||||||
else if (log10 < 1) {
|
},
|
||||||
return quantity.toFixed(1);
|
},
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
return quantity.toFixed(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -1,45 +1,42 @@
|
||||||
export function groupsToItems(groups) {
|
export function groupsToItems(groups) {
|
||||||
return groups.map(group => group.shoppingListItems).flat();
|
return groups.map((group) => group.shoppingListItems).flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uniqueMeals(shoppingListItems) {
|
export function uniqueMeals(shoppingListItems) {
|
||||||
const mealsWithDuplicates = shoppingListItems
|
const mealsWithDuplicates = shoppingListItems.map((item) => item.meal).filter((m) => m)
|
||||||
.map(item => item.meal)
|
|
||||||
.filter(m => m);
|
|
||||||
|
|
||||||
const mealsLookup = mealsWithDuplicates.reduce(((acc, meal) => { acc[meal.id] ??= meal; return acc; }), {});
|
const mealsLookup = mealsWithDuplicates.reduce((acc, meal) => {
|
||||||
|
acc[meal.id] ??= meal
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
return Object.values(mealsLookup);
|
return Object.values(mealsLookup)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function itemsToGroups(shoppingListItems) {
|
export function itemsToGroups(shoppingListItems) {
|
||||||
const ingredients_by_product_id = {};
|
const ingredients_by_product_id = {}
|
||||||
const ingredients_by_name = {};
|
const ingredients_by_name = {}
|
||||||
for (const item of shoppingListItems) {
|
for (const item of shoppingListItems) {
|
||||||
if (item.ingredient.product) {
|
if (item.ingredient.product) {
|
||||||
let group = ingredients_by_product_id[item.ingredient.product.id];
|
let group = ingredients_by_product_id[item.ingredient.product.id]
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = ingredients_by_product_id[item.ingredient.product.id] = {
|
group = ingredients_by_product_id[item.ingredient.product.id] = {
|
||||||
product: item.ingredient.product,
|
product: item.ingredient.product,
|
||||||
shoppingListItems: []
|
shoppingListItems: [],
|
||||||
};
|
|
||||||
}
|
}
|
||||||
group.shoppingListItems.push(item);
|
|
||||||
}
|
}
|
||||||
else {
|
group.shoppingListItems.push(item)
|
||||||
let group = ingredients_by_name[item.ingredient.name];
|
} else {
|
||||||
|
let group = ingredients_by_name[item.ingredient.name]
|
||||||
if (!group) {
|
if (!group) {
|
||||||
group = ingredients_by_name[item.ingredient.name] = {
|
group = ingredients_by_name[item.ingredient.name] = {
|
||||||
name: item.ingredient.name,
|
name: item.ingredient.name,
|
||||||
shoppingListItems: []
|
shoppingListItems: [],
|
||||||
};
|
|
||||||
}
|
}
|
||||||
group.shoppingListItems.push(item);
|
}
|
||||||
|
group.shoppingListItems.push(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [...Object.values(ingredients_by_product_id), ...Object.values(ingredients_by_name)]
|
||||||
...Object.values(ingredients_by_product_id),
|
|
||||||
...Object.values(ingredients_by_name)
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
434
src/data.js
434
src/data.js
|
|
@ -1,30 +1,54 @@
|
||||||
const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, "$1/api");
|
const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, '$1/api')
|
||||||
|
|
||||||
const datesToFix = {
|
const datesToFix = {
|
||||||
Meal: { fields: [ "suggested_date", "purchase_date", "consumed_date" ] },
|
Meal: { fields: ['suggested_date', 'purchase_date', 'consumed_date'] },
|
||||||
CurrentShoppingList: { dependants: l => ({ ShoppingListItem: [l.outstanding_items, l.requested_meals, l.purchased_items], Meal: Object.values(l.meals_lookup), Recipe: Object.values(l.recipes_lookup), Ingredient: Object.values(l.ingredients_lookup), ShoppingList: Object.values(l.shopping_list_lookup) }) },
|
CurrentShoppingList: {
|
||||||
PurchasedShoppingList: { dependants: l => ({ ShoppingListItem: l.items, Meal: Object.values(l.meals_lookup), Recipe: Object.values(l.recipes_lookup), Ingredient: Object.values(l.ingredients_lookup), ShoppingList: l.list }) },
|
dependants: (l) => ({
|
||||||
ShoppingList: { fields: [ "created_date" ], dependants: l => ({ ShoppingListItem: l.items }) },
|
ShoppingListItem: [l.outstanding_items, l.requested_meals, l.purchased_items],
|
||||||
ShoppingListItem: { fields: [ "created_date" ], dependants: r => ({ Meal: r.meal, Recipe: r.recipe, Ingredient: r.ingredient, ShoppingList: r.list }) },
|
Meal: Object.values(l.meals_lookup),
|
||||||
Recipe: { fields: [ "date_created", "date_hidden" ], },
|
Recipe: Object.values(l.recipes_lookup),
|
||||||
};
|
Ingredient: Object.values(l.ingredients_lookup),
|
||||||
|
ShoppingList: Object.values(l.shopping_list_lookup),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
PurchasedShoppingList: {
|
||||||
|
dependants: (l) => ({
|
||||||
|
ShoppingListItem: l.items,
|
||||||
|
Meal: Object.values(l.meals_lookup),
|
||||||
|
Recipe: Object.values(l.recipes_lookup),
|
||||||
|
Ingredient: Object.values(l.ingredients_lookup),
|
||||||
|
ShoppingList: l.list,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ShoppingList: { fields: ['created_date'], dependants: (l) => ({ ShoppingListItem: l.items }) },
|
||||||
|
ShoppingListItem: {
|
||||||
|
fields: ['created_date'],
|
||||||
|
dependants: (r) => ({
|
||||||
|
Meal: r.meal,
|
||||||
|
Recipe: r.recipe,
|
||||||
|
Ingredient: r.ingredient,
|
||||||
|
ShoppingList: r.list,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Recipe: { fields: ['date_created', 'date_hidden'] },
|
||||||
|
}
|
||||||
|
|
||||||
const fixDates = (obj, type) => {
|
const fixDates = (obj, type) => {
|
||||||
if (!obj) return;
|
if (!obj) return
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
for (const item of obj) {
|
for (const item of obj) {
|
||||||
fixDates(item, type);
|
fixDates(item, type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toFix = datesToFix[type];
|
const toFix = datesToFix[type]
|
||||||
if (!toFix) return;
|
if (!toFix) return
|
||||||
|
|
||||||
if (toFix.fields) {
|
if (toFix.fields) {
|
||||||
for (const field of toFix.fields) {
|
for (const field of toFix.fields) {
|
||||||
if (obj[field]) {
|
if (obj[field]) {
|
||||||
obj[field] = new Date(obj[field]);
|
obj[field] = new Date(obj[field])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -33,300 +57,334 @@ const fixDates = (obj, type) => {
|
||||||
for (const [key, value] of Object.entries(toFix.dependants(obj))) {
|
for (const [key, value] of Object.entries(toFix.dependants(obj))) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
fixDates(item, key);
|
fixDates(item, key)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
fixDates(value, key)
|
||||||
fixDates(value, key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setPurchasedShoppingListReferences = (purchasedShoppingList) => {
|
const setPurchasedShoppingListReferences = (purchasedShoppingList) => {
|
||||||
if (!purchasedShoppingList) return;
|
if (!purchasedShoppingList) return
|
||||||
const { ingredients_lookup, meals_lookup, recipes_lookup, } = purchasedShoppingList;
|
const { ingredients_lookup, meals_lookup, recipes_lookup } = purchasedShoppingList
|
||||||
const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list };
|
const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list }
|
||||||
setShoppingListItemReferences(purchasedShoppingList.list.items, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
|
setShoppingListItemReferences(
|
||||||
|
purchasedShoppingList.list.items,
|
||||||
|
ingredients_lookup,
|
||||||
|
meals_lookup,
|
||||||
|
recipes_lookup,
|
||||||
|
shopping_list_lookup
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setCurrentShoppingListReferences = (currentShoppingList) => {
|
const setCurrentShoppingListReferences = (currentShoppingList) => {
|
||||||
if (!currentShoppingList) return;
|
if (!currentShoppingList) return
|
||||||
const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } = currentShoppingList;
|
const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } =
|
||||||
const allShoppingListItems = [...currentShoppingList.outstanding_items, ...currentShoppingList.requested_meals, ...currentShoppingList.purchased_items];
|
currentShoppingList
|
||||||
|
const allShoppingListItems = [
|
||||||
|
...currentShoppingList.outstanding_items,
|
||||||
|
...currentShoppingList.requested_meals,
|
||||||
|
...currentShoppingList.purchased_items,
|
||||||
|
]
|
||||||
|
|
||||||
setShoppingListItemReferences(allShoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
|
setShoppingListItemReferences(
|
||||||
|
allShoppingListItems,
|
||||||
|
ingredients_lookup,
|
||||||
|
meals_lookup,
|
||||||
|
recipes_lookup,
|
||||||
|
shopping_list_lookup
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const setShoppingListItemReferences = (shoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup) => {
|
const setShoppingListItemReferences = (
|
||||||
if (!shoppingListItems) return;
|
shoppingListItems,
|
||||||
|
ingredients_lookup,
|
||||||
|
meals_lookup,
|
||||||
|
recipes_lookup,
|
||||||
|
shopping_list_lookup
|
||||||
|
) => {
|
||||||
|
if (!shoppingListItems) return
|
||||||
|
|
||||||
for (const item of shoppingListItems) {
|
for (const item of shoppingListItems) {
|
||||||
if (item.ingredient_id) {
|
if (item.ingredient_id) {
|
||||||
item.ingredient = ingredients_lookup[item.ingredient_id];
|
item.ingredient = ingredients_lookup[item.ingredient_id]
|
||||||
}
|
}
|
||||||
if (item.meal_id) {
|
if (item.meal_id) {
|
||||||
item.meal = meals_lookup[item.meal_id];
|
item.meal = meals_lookup[item.meal_id]
|
||||||
}
|
}
|
||||||
if (item.list_id) {
|
if (item.list_id) {
|
||||||
item.list = shopping_list_lookup[item.list_id];
|
item.list = shopping_list_lookup[item.list_id]
|
||||||
}
|
}
|
||||||
if (item.recipe_id) {
|
if (item.recipe_id) {
|
||||||
item.recipe = recipes_lookup[item.recipe_id];
|
item.recipe = recipes_lookup[item.recipe_id]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let user = null;
|
let user = null
|
||||||
export default {
|
export default {
|
||||||
async markMealConsumed(meal_id) {
|
async markMealConsumed(meal_id) {
|
||||||
const response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal_id)}/consumed`, {
|
const response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal_id)}/consumed`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
});
|
})
|
||||||
|
|
||||||
const meal = await response.json();
|
const meal = await response.json()
|
||||||
fixDates(meal, "Meal");
|
fixDates(meal, 'Meal')
|
||||||
|
|
||||||
return meal;
|
return meal
|
||||||
},
|
},
|
||||||
async getUpcomingMeals(from, to) {
|
async getUpcomingMeals(from, to) {
|
||||||
const response = await fetch(BASE_URL + "/meals/upcoming?from=" + from.toISOString() + "&to=" + to.toISOString());
|
const response = await fetch(
|
||||||
const meals = await response.json();
|
BASE_URL + '/meals/upcoming?from=' + from.toISOString() + '&to=' + to.toISOString()
|
||||||
fixDates(meals, "Meal");
|
)
|
||||||
|
const meals = await response.json()
|
||||||
|
fixDates(meals, 'Meal')
|
||||||
|
|
||||||
return meals.sort((a, b) => a.suggested_date - b.suggested_date);
|
return meals.sort((a, b) => a.suggested_date - b.suggested_date)
|
||||||
},
|
},
|
||||||
async getMeal(id) {
|
async getMeal(id) {
|
||||||
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`);
|
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`)
|
||||||
const meal = await response.json();
|
const meal = await response.json()
|
||||||
fixDates(meal, "Meal");
|
fixDates(meal, 'Meal')
|
||||||
|
|
||||||
return meal;
|
return meal
|
||||||
},
|
},
|
||||||
async deleteMeal(id) {
|
async deleteMeal(id) {
|
||||||
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`, {
|
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`, {
|
||||||
method: "DELETE",
|
method: 'DELETE',
|
||||||
});
|
})
|
||||||
|
|
||||||
return await response.json();
|
return await response.json()
|
||||||
},
|
},
|
||||||
async searchRecipes(query) {
|
async searchRecipes(query) {
|
||||||
const response = await fetch(BASE_URL + "/recipes?q=" + encodeURIComponent(query));
|
const response = await fetch(BASE_URL + '/recipes?q=' + encodeURIComponent(query))
|
||||||
const recipes = await response.json();
|
const recipes = await response.json()
|
||||||
fixDates(recipes, "Recipe");
|
fixDates(recipes, 'Recipe')
|
||||||
|
|
||||||
return recipes;
|
return recipes
|
||||||
},
|
},
|
||||||
async parseRecipe(url) {
|
async parseRecipe(url) {
|
||||||
const response = await fetch(BASE_URL + `/recipes/parse?url=${encodeURIComponent(url)}`, { credentials: "include" });
|
const response = await fetch(BASE_URL + `/recipes/parse?url=${encodeURIComponent(url)}`, {
|
||||||
const recipe = await response.json();
|
credentials: 'include',
|
||||||
fixDates(recipe, "Recipe");
|
})
|
||||||
|
const recipe = await response.json()
|
||||||
|
fixDates(recipe, 'Recipe')
|
||||||
|
|
||||||
return recipe;
|
return recipe
|
||||||
},
|
},
|
||||||
async getRecipe(id) {
|
async getRecipe(id) {
|
||||||
const response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`);
|
const response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`)
|
||||||
const recipe = await response.json();
|
const recipe = await response.json()
|
||||||
fixDates(recipe, "Recipe");
|
fixDates(recipe, 'Recipe')
|
||||||
|
|
||||||
return recipe;
|
return recipe
|
||||||
},
|
},
|
||||||
async parseProduct(ingredient, url) {
|
async parseProduct(ingredient, url) {
|
||||||
const body = {
|
const body = {
|
||||||
url, tags: [ingredient.name, ingredient.line],
|
url,
|
||||||
};
|
tags: [ingredient.name, ingredient.line],
|
||||||
|
|
||||||
const response = await fetch(BASE_URL + "/products", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
},
|
|
||||||
async parseIngredients(lines) {
|
|
||||||
const params = lines.map(line => "ingredients=" + encodeURIComponent(line)).join("&");
|
|
||||||
const response = await fetch(BASE_URL + "/recipes/ingredients/parse?" + params);
|
|
||||||
return await response.json();
|
|
||||||
},
|
|
||||||
async saveRecipe(recipe) {
|
|
||||||
const response = await fetch(BASE_URL + "/recipes", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(recipe),
|
|
||||||
});
|
|
||||||
|
|
||||||
const saved = await response.json();
|
|
||||||
fixDates(saved, "Recipe");
|
|
||||||
|
|
||||||
return saved;
|
|
||||||
},
|
|
||||||
async saveMeal(meal) {
|
|
||||||
let response = null;
|
|
||||||
if (meal.id >= 0) {
|
|
||||||
response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal.id)}`, {
|
|
||||||
method: "PUT",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(meal),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
response = await fetch(BASE_URL + "/meals", {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(meal),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const saved = await response.json();
|
const response = await fetch(BASE_URL + '/products', {
|
||||||
fixDates(saved, "Meal");
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
return saved;
|
return await response.json()
|
||||||
|
},
|
||||||
|
async parseIngredients(lines) {
|
||||||
|
const params = lines.map((line) => 'ingredients=' + encodeURIComponent(line)).join('&')
|
||||||
|
const response = await fetch(BASE_URL + '/recipes/ingredients/parse?' + params)
|
||||||
|
return await response.json()
|
||||||
|
},
|
||||||
|
async saveRecipe(recipe) {
|
||||||
|
const response = await fetch(BASE_URL + '/recipes', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(recipe),
|
||||||
|
})
|
||||||
|
|
||||||
|
const saved = await response.json()
|
||||||
|
fixDates(saved, 'Recipe')
|
||||||
|
|
||||||
|
return saved
|
||||||
|
},
|
||||||
|
async saveMeal(meal) {
|
||||||
|
let response = null
|
||||||
|
if (meal.id >= 0) {
|
||||||
|
response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal.id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(meal),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
response = await fetch(BASE_URL + '/meals', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(meal),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await response.json()
|
||||||
|
fixDates(saved, 'Meal')
|
||||||
|
|
||||||
|
return saved
|
||||||
},
|
},
|
||||||
async currentUser() {
|
async currentUser() {
|
||||||
if (user) {
|
if (user) {
|
||||||
return user;
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
var cookie = decodeURIComponent(document.cookie).split(";").find(cookie => cookie.trimStart().startsWith("user_id="));
|
var cookie = decodeURIComponent(document.cookie)
|
||||||
|
.split(';')
|
||||||
|
.find((cookie) => cookie.trimStart().startsWith('user_id='))
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
const response = await fetch(BASE_URL + "/auth/refresh", {
|
const response = await fetch(BASE_URL + '/auth/refresh', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
});
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
user = await response.json();
|
user = await response.json()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user
|
||||||
},
|
},
|
||||||
async login(username) {
|
async login(username) {
|
||||||
const response = await fetch(BASE_URL + "/auth/login", {
|
const response = await fetch(BASE_URL + '/auth/login', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ username }),
|
body: JSON.stringify({ username }),
|
||||||
});
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
user = await response.json();
|
user = await response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user
|
||||||
},
|
},
|
||||||
async deleteRecipe(id) {
|
async deleteRecipe(id) {
|
||||||
var response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`, {
|
var response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`, {
|
||||||
method: "DELETE",
|
method: 'DELETE',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
});
|
})
|
||||||
|
|
||||||
const recipe = await response.json();
|
const recipe = await response.json()
|
||||||
fixDates(recipe, "Recipe");
|
fixDates(recipe, 'Recipe')
|
||||||
return recipe;
|
return recipe
|
||||||
},
|
},
|
||||||
async searchPerson(name) {
|
async searchPerson(name) {
|
||||||
const response = await fetch(BASE_URL + "/persons?q=" + encodeURIComponent(name));
|
const response = await fetch(BASE_URL + '/persons?q=' + encodeURIComponent(name))
|
||||||
return await response.json();
|
return await response.json()
|
||||||
},
|
},
|
||||||
async getMyShoppingList() {
|
async getMyShoppingList() {
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" });
|
const response = await fetch(BASE_URL + '/shopping/current/me/ingredients', {
|
||||||
const ingredients = await response.json();
|
credentials: 'include',
|
||||||
fixDates(ingredients, 'Ingredient');
|
})
|
||||||
|
const ingredients = await response.json()
|
||||||
|
fixDates(ingredients, 'Ingredient')
|
||||||
|
|
||||||
return ingredients;
|
return ingredients
|
||||||
},
|
},
|
||||||
async saveMyShoppingList(list) {
|
async saveMyShoppingList(list) {
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", {
|
const response = await fetch(BASE_URL + '/shopping/current/me/ingredients', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(list),
|
body: JSON.stringify(list),
|
||||||
});
|
})
|
||||||
|
|
||||||
const ingredients = await response.json();
|
const ingredients = await response.json()
|
||||||
fixDates(ingredients, 'Ingredient');
|
fixDates(ingredients, 'Ingredient')
|
||||||
|
|
||||||
return ingredients;
|
return ingredients
|
||||||
},
|
},
|
||||||
async getShoppingList(id) {
|
async getShoppingList(id) {
|
||||||
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
|
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`)
|
||||||
|
|
||||||
const purchasedShoppingList = await response.json();
|
const purchasedShoppingList = await response.json()
|
||||||
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
fixDates(purchasedShoppingList, 'PurchasedShoppingList')
|
||||||
setPurchasedShoppingListReferences(purchasedShoppingList);
|
setPurchasedShoppingListReferences(purchasedShoppingList)
|
||||||
|
|
||||||
return purchasedShoppingList.list;
|
return purchasedShoppingList.list
|
||||||
},
|
},
|
||||||
async getCurrentShoppingList() {
|
async getCurrentShoppingList() {
|
||||||
const response = await fetch(BASE_URL + "/shopping/current");
|
const response = await fetch(BASE_URL + '/shopping/current')
|
||||||
|
|
||||||
const lst = await response.json();
|
const lst = await response.json()
|
||||||
fixDates(lst, "CurrentShoppingList");
|
fixDates(lst, 'CurrentShoppingList')
|
||||||
setCurrentShoppingListReferences(lst);
|
setCurrentShoppingListReferences(lst)
|
||||||
|
|
||||||
return lst;
|
return lst
|
||||||
},
|
},
|
||||||
async purchaseShoppingList(completed_requests) {
|
async purchaseShoppingList(completed_requests) {
|
||||||
const response = await fetch(BASE_URL + "/shopping/", {
|
const response = await fetch(BASE_URL + '/shopping/', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ items: completed_requests }),
|
body: JSON.stringify({ items: completed_requests }),
|
||||||
});
|
})
|
||||||
|
|
||||||
const purchasedShoppingList = await response.json();
|
const purchasedShoppingList = await response.json()
|
||||||
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
fixDates(purchasedShoppingList, 'PurchasedShoppingList')
|
||||||
setPurchasedShoppingListReferences(purchasedShoppingList);
|
setPurchasedShoppingListReferences(purchasedShoppingList)
|
||||||
|
|
||||||
return purchasedShoppingList.list;
|
return purchasedShoppingList.list
|
||||||
},
|
},
|
||||||
async requestMeal(meal_id) {
|
async requestMeal(meal_id) {
|
||||||
const response = await fetch(BASE_URL + "/shopping/current/meals/me", {
|
const response = await fetch(BASE_URL + '/shopping/current/meals/me', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json"
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ meal_id }),
|
body: JSON.stringify({ meal_id }),
|
||||||
});
|
})
|
||||||
|
|
||||||
const requests = await response.json();
|
const requests = await response.json()
|
||||||
fixDates(requests, "ShoppingListItem");
|
fixDates(requests, 'ShoppingListItem')
|
||||||
|
|
||||||
return requests;
|
return requests
|
||||||
},
|
},
|
||||||
async unrequestMeal(meal_id) {
|
async unrequestMeal(meal_id) {
|
||||||
const response = await fetch(BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`, {
|
const response = await fetch(
|
||||||
method: "DELETE",
|
BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`,
|
||||||
credentials: "include",
|
{
|
||||||
});
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Failed to unrequest meal");
|
throw new Error('Failed to unrequest meal')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getPersonsInHome() {
|
async getPersonsInHome() {
|
||||||
const response = await fetch(BASE_URL + "/persons");
|
const response = await fetch(BASE_URL + '/persons')
|
||||||
return await response.json();
|
return await response.json()
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -1,25 +1,25 @@
|
||||||
function plural(num, unit) {
|
function plural(num, unit) {
|
||||||
num = Math.floor(num);
|
num = Math.floor(num)
|
||||||
return num + " " + unit + (num === 1 ? "" : "s");
|
return num + ' ' + unit + (num === 1 ? '' : 's')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ago(date) {
|
export function ago(date) {
|
||||||
const now = new Date();
|
const now = new Date()
|
||||||
const diff = now - date;
|
const diff = now - date
|
||||||
if (diff < 1000) {
|
if (diff < 1000) {
|
||||||
return "just now";
|
return 'just now'
|
||||||
}
|
}
|
||||||
if (diff < 60 * 1000) {
|
if (diff < 60 * 1000) {
|
||||||
return plural(diff / 1000, "second") + " ago";
|
return plural(diff / 1000, 'second') + ' ago'
|
||||||
}
|
}
|
||||||
if (diff < 60 * 60 * 1000) {
|
if (diff < 60 * 60 * 1000) {
|
||||||
return plural(diff / (60 * 1000), "minute") + " ago";
|
return plural(diff / (60 * 1000), 'minute') + ' ago'
|
||||||
}
|
}
|
||||||
if (diff < 24 * 60 * 60 * 1000) {
|
if (diff < 24 * 60 * 60 * 1000) {
|
||||||
return plural(diff / (60 * 60 * 1000), "hour") + " ago";
|
return plural(diff / (60 * 60 * 1000), 'hour') + ' ago'
|
||||||
}
|
}
|
||||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||||
return plural(diff / (24 * 60 * 60 * 1000), "day") + " ago";
|
return plural(diff / (24 * 60 * 60 * 1000), 'day') + ' ago'
|
||||||
}
|
}
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ const LoginPage = () => import('@/components/LoginPage.vue')
|
||||||
const RecipesPage = () => import('@/components/recipes/RecipesPage.vue')
|
const RecipesPage = () => import('@/components/recipes/RecipesPage.vue')
|
||||||
const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue')
|
const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue')
|
||||||
const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue')
|
const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue')
|
||||||
const PurchasedShoppingListPage = () => import('@/components/shopping/PurchasedShoppingListPage.vue')
|
const PurchasedShoppingListPage = () =>
|
||||||
|
import('@/components/shopping/PurchasedShoppingListPage.vue')
|
||||||
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
||||||
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
||||||
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
|
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
|
||||||
|
|
@ -15,14 +16,47 @@ export function createAppRouter(getCurrentUser) {
|
||||||
{ path: '/', redirect: { name: 'mealplan' } },
|
{ path: '/', redirect: { name: 'mealplan' } },
|
||||||
{ path: '/login', name: 'login', component: LoginPage },
|
{ path: '/login', name: 'login', component: LoginPage },
|
||||||
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
{ path: '/mealplan', name: 'mealplan', component: MealPlanPage, meta: { requiresAuth: true } },
|
||||||
{ path: '/shopping', name: 'shopping', component: MyShoppingPage, meta: { requiresAuth: true } },
|
{
|
||||||
{ path: '/shopping/current', name: 'shopping-current', component: CurrentShoppingListPage, meta: { requiresAuth: true } },
|
path: '/shopping',
|
||||||
{ path: '/shopping/:id', name: 'shopping-list', component: PurchasedShoppingListPage, props: true, meta: { requiresAuth: true } },
|
name: 'shopping',
|
||||||
|
component: MyShoppingPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/shopping/current',
|
||||||
|
name: 'shopping-current',
|
||||||
|
component: CurrentShoppingListPage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/shopping/:id',
|
||||||
|
name: 'shopping-list',
|
||||||
|
component: PurchasedShoppingListPage,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
{ path: '/recipes', name: 'recipes', component: RecipesPage, meta: { requiresAuth: true } },
|
||||||
{ path: '/recipes/add', name: 'recipe-add', component: EditRecipePage, meta: { requiresAuth: true } },
|
{
|
||||||
{ path: '/recipes/:id', name: 'recipe-edit', component: EditRecipePage, props: true, meta: { requiresAuth: true } },
|
path: '/recipes/add',
|
||||||
|
name: 'recipe-add',
|
||||||
|
component: EditRecipePage,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/recipes/:id',
|
||||||
|
name: 'recipe-edit',
|
||||||
|
component: EditRecipePage,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
{ path: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
||||||
{ path: '/meals/:id', name: 'meal-edit', component: EditMealPage, props: true, meta: { requiresAuth: true } },
|
{
|
||||||
|
path: '/meals/:id',
|
||||||
|
name: 'meal-edit',
|
||||||
|
component: EditMealPage,
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
@ -36,7 +70,9 @@ export function createAppRouter(getCurrentUser) {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
if (user) return true
|
if (user) return true
|
||||||
} catch (_) { /* ignore */ }
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
168
src/units.js
168
src/units.js
|
|
@ -1,142 +1,142 @@
|
||||||
export const equivalentUnits = {
|
export const equivalentUnits = {
|
||||||
'kg': {
|
kg: {
|
||||||
'kgs': 1,
|
kgs: 1,
|
||||||
'kilograms': 1,
|
kilograms: 1,
|
||||||
'kilogram': 1,
|
kilogram: 1,
|
||||||
|
|
||||||
'g': 1000,
|
g: 1000,
|
||||||
'gram': 1000,
|
gram: 1000,
|
||||||
'grams': 1000,
|
grams: 1000,
|
||||||
|
|
||||||
'lb': 2.20462,
|
lb: 2.20462,
|
||||||
'lbs': 2.20462,
|
lbs: 2.20462,
|
||||||
'pound': 2.20462,
|
pound: 2.20462,
|
||||||
'pounds': 2.20462,
|
pounds: 2.20462,
|
||||||
},
|
},
|
||||||
'litres': {
|
litres: {
|
||||||
'l': 1,
|
l: 1,
|
||||||
'liter': 1,
|
liter: 1,
|
||||||
'litre': 1,
|
litre: 1,
|
||||||
|
|
||||||
'ml': 1000,
|
ml: 1000,
|
||||||
'milliliters': 1000,
|
milliliters: 1000,
|
||||||
'milliliter': 1000,
|
milliliter: 1000,
|
||||||
|
|
||||||
'fl oz': 33.814,
|
'fl oz': 33.814,
|
||||||
'fluid ounce': 33.814,
|
'fluid ounce': 33.814,
|
||||||
'fluid ounces': 33.814,
|
'fluid ounces': 33.814,
|
||||||
|
|
||||||
'cup': 4.22675,
|
cup: 4.22675,
|
||||||
'cups': 4.22675,
|
cups: 4.22675,
|
||||||
|
|
||||||
'tbsp': 67.628,
|
tbsp: 67.628,
|
||||||
'tablespoon': 67.628,
|
tablespoon: 67.628,
|
||||||
'tablespoons': 67.628,
|
tablespoons: 67.628,
|
||||||
|
|
||||||
'tsp': 202.884,
|
tsp: 202.884,
|
||||||
'teaspoon': 202.884,
|
teaspoon: 202.884,
|
||||||
'teaspoons': 202.884,
|
teaspoons: 202.884,
|
||||||
|
|
||||||
'pt': 2.11338,
|
pt: 2.11338,
|
||||||
'pint': 2.11338,
|
pint: 2.11338,
|
||||||
'pints': 2.11338,
|
pints: 2.11338,
|
||||||
|
|
||||||
'qt': 1.05669,
|
qt: 1.05669,
|
||||||
'quart': 1.05669,
|
quart: 1.05669,
|
||||||
'quarts': 1.05669,
|
quarts: 1.05669,
|
||||||
|
|
||||||
'gal': 0.264172,
|
gal: 0.264172,
|
||||||
'gallon': 0.264172,
|
gallon: 0.264172,
|
||||||
'gallons': 0.264172,
|
gallons: 0.264172,
|
||||||
|
|
||||||
'oz': 35.1951,
|
oz: 35.1951,
|
||||||
'ounce': 35.1951,
|
ounce: 35.1951,
|
||||||
},
|
},
|
||||||
'items': {
|
items: {
|
||||||
'item': 1,
|
item: 1,
|
||||||
'items': 1,
|
items: 1,
|
||||||
'pcs': 1,
|
pcs: 1,
|
||||||
'piece': 1,
|
piece: 1,
|
||||||
'pieces': 1,
|
pieces: 1,
|
||||||
'florets': 8, // Broccoli
|
florets: 8, // Broccoli
|
||||||
'head': 1, // Broccoli
|
head: 1, // Broccoli
|
||||||
'heads': 1, // Broccoli
|
heads: 1, // Broccoli
|
||||||
'slice': 10, // Bread
|
slice: 10, // Bread
|
||||||
'slices': 10, // Bread
|
slices: 10, // Bread
|
||||||
'loaf': 1, // Bread
|
loaf: 1, // Bread
|
||||||
'loaves': 1, // Bread
|
loaves: 1, // Bread
|
||||||
'cloves': 8, // Garlic
|
cloves: 8, // Garlic
|
||||||
'bulb': 1, // Garlic
|
bulb: 1, // Garlic
|
||||||
'bulbs': 1, // Garlic
|
bulbs: 1, // Garlic
|
||||||
'stalk': 1, // Celery
|
stalk: 1, // Celery
|
||||||
'stalks': 1, // Celery
|
stalks: 1, // Celery
|
||||||
'bunch': 1, // Cilantro
|
bunch: 1, // Cilantro
|
||||||
'bunches': 1, // Cilantro
|
bunches: 1, // Cilantro
|
||||||
'sprig': 1, // Cilantro
|
sprig: 1, // Cilantro
|
||||||
'sprigs': 1, // Cilantro
|
sprigs: 1, // Cilantro
|
||||||
'cans': 1, // Canned goods
|
cans: 1, // Canned goods
|
||||||
'can': 1, // Canned goods
|
can: 1, // Canned goods
|
||||||
'pack': 1, // Packaged goods
|
pack: 1, // Packaged goods
|
||||||
'packs': 1, // Packaged goods
|
packs: 1, // Packaged goods
|
||||||
'package': 1, // Packaged goods
|
package: 1, // Packaged goods
|
||||||
'packages': 1, // Packaged goods
|
packages: 1, // Packaged goods
|
||||||
'container': 1, // Packaged goods
|
container: 1, // Packaged goods
|
||||||
'containers': 1, // Packaged goods
|
containers: 1, // Packaged goods
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBaseUnit(unit) {
|
function getBaseUnit(unit) {
|
||||||
for (const unitType in equivalentUnits) {
|
for (const unitType in equivalentUnits) {
|
||||||
if (unit in equivalentUnits[unitType]) {
|
if (unit in equivalentUnits[unitType]) {
|
||||||
return unitType;
|
return unitType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getConversionFactor(unit) {
|
export function getConversionFactor(unit) {
|
||||||
if (unit in equivalentUnits) {
|
if (unit in equivalentUnits) {
|
||||||
return { unit, factor: 1 };
|
return { unit, factor: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const unitLower = unit.toLowerCase();
|
const unitLower = unit.toLowerCase()
|
||||||
if (unitLower in equivalentUnits) {
|
if (unitLower in equivalentUnits) {
|
||||||
return { unit: unitLower, factor: 1 };
|
return { unit: unitLower, factor: 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUnit = getBaseUnit(unit);
|
const baseUnit = getBaseUnit(unit)
|
||||||
if (baseUnit) {
|
if (baseUnit) {
|
||||||
return {
|
return {
|
||||||
unit: baseUnit,
|
unit: baseUnit,
|
||||||
factor: equivalentUnits[baseUnit][unit],
|
factor: equivalentUnits[baseUnit][unit],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUnitLower = getBaseUnit(unitLower);
|
const baseUnitLower = getBaseUnit(unitLower)
|
||||||
if (baseUnitLower) {
|
if (baseUnitLower) {
|
||||||
return {
|
return {
|
||||||
unit: baseUnitLower,
|
unit: baseUnitLower,
|
||||||
factor: equivalentUnits[baseUnitLower][unitLower],
|
factor: equivalentUnits[baseUnitLower][unitLower],
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateTotals(quantityList) {
|
export function calculateTotals(quantityList) {
|
||||||
const totals = {};
|
const totals = {}
|
||||||
for (const quantity of quantityList) {
|
for (const quantity of quantityList) {
|
||||||
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 };
|
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 }
|
||||||
const factor = baseUnit.factor;
|
const factor = baseUnit.factor
|
||||||
const unit = baseUnit.unit;
|
const unit = baseUnit.unit
|
||||||
|
|
||||||
if (!totals[unit]) {
|
if (!totals[unit]) {
|
||||||
totals[unit] = 0;
|
totals[unit] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
totals[unit] += quantity.quantity / factor;
|
totals[unit] += quantity.quantity / factor
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] }));
|
return Object.keys(totals).map((unit) => ({ unit, quantity: totals[unit] }))
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const { defineConfig } = require('@vue/cli-service')
|
const { defineConfig } = require('@vue/cli-service')
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
transpileDependencies: true
|
transpileDependencies: true,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue