Compare commits
16 commits
59e3d8ab16
...
bd99d90c09
| Author | SHA1 | Date | |
|---|---|---|---|
| bd99d90c09 | |||
| 72fccdd8a3 | |||
| d1534934c0 | |||
| 68e82f1fa2 | |||
| d31182dff0 | |||
| beab02200c | |||
| 61ca41d25a | |||
| 3be1027154 | |||
| 661d5f4840 | |||
| f0a8adbd77 | |||
| 28ad3a16ff | |||
| 2cc87e1891 | |||
| 8797a5489c | |||
| a6904524db | |||
| ec28531df9 | |||
| ee2f08cbfc |
58 changed files with 6658 additions and 2524 deletions
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Base URL for the backend API
|
||||
# Example: http://localhost:8081
|
||||
VUE_APP_API_BASE=
|
||||
4
.husky/pre-commit
Normal file
4
.husky/pre-commit
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always"
|
||||
}
|
||||
92
README.md
92
README.md
|
|
@ -1,24 +1,92 @@
|
|||
# doof-front
|
||||
## Munch Ease — Plan, Cook, Shop, Repeat
|
||||
|
||||
## Project setup
|
||||
```
|
||||
Munch Ease is a snappy Vue 3 app that helps you plan meals, manage recipes, and turn plans into stress-free shopping lists. Search and save recipes, build your weekly meal plan, and seamlessly check items off your shopping list—everything stays in sync so you can focus on what’s cooking.
|
||||
|
||||
Built with modern Vue patterns, a clean API layer, and lightweight tests, the project is easy to extend and fun to work on.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
1) Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
```
|
||||
2) Run the dev server
|
||||
|
||||
```bash
|
||||
npm run serve
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
3) Run unit tests (Vitest)
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
4) Build for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lints and fixes files
|
||||
```
|
||||
npm run lint
|
||||
```
|
||||
Environment
|
||||
- API base URL: set VUE_APP_API_BASE (e.g. http://localhost:8081)
|
||||
|
||||
### Customize configuration
|
||||
See [Configuration Reference](https://cli.vuejs.org/config/).
|
||||
---
|
||||
|
||||
## Architecture and conventions
|
||||
|
||||
The codebase follows clear boundaries and Vue 3 Composition API throughout.
|
||||
|
||||
- Routing and auth
|
||||
- Centralized in `src/router/index.js` with named routes and an auth guard via route meta `requiresAuth`.
|
||||
- Components use `useRouter/useRoute` for navigation and route access.
|
||||
|
||||
- API layer and mappers
|
||||
- `src/api/http.js` is a tiny JSON fetch wrapper that honors `VUE_APP_API_BASE`.
|
||||
- Feature services live in `src/api/*` (meals, recipes, shopping, auth, persons).
|
||||
- Normalization lives in `src/api/mappers/*` (e.g., date parsing, shape cleanup).
|
||||
|
||||
- Composables (UI-facing logic)
|
||||
- Reusable logic in `src/composables/*` (useAuth, useMeals, useShopping).
|
||||
- Components stay thin: data via refs/reactive, effects via computed/watch.
|
||||
|
||||
- Components
|
||||
- All Single File Components use `<script setup>`.
|
||||
- Props via `defineProps`, events via `defineEmits`, routing via `useRouter`.
|
||||
- Event bus for alerts is in `src/alert.js` with `AlertToast` subscribing; can evolve into a `useAlert` composable.
|
||||
|
||||
- Testing
|
||||
- Vitest configured (`vitest.config.js`) with `@` alias to `src`.
|
||||
- Targeted unit tests cover units and API mappers under `tests/`.
|
||||
|
||||
- Formatting and linting
|
||||
- Prettier is the source of truth; Husky + lint-staged auto-format on commit.
|
||||
- ESLint configured for Vue 3 and Composition API macros.
|
||||
- Recommended: use Volar in your editor for Vue intelligence.
|
||||
|
||||
Folder highlights
|
||||
- `src/api/` — HTTP wrapper, feature services, and data mappers
|
||||
- `src/composables/` — Reusable app logic (auth, meals, shopping)
|
||||
- `src/components/` — UI components and pages (all in `<script setup>`)
|
||||
- `src/router/` — Route definitions and auth guard
|
||||
|
||||
---
|
||||
|
||||
## Development tips
|
||||
|
||||
- Prefer composables for shared logic; keep components presentational where possible.
|
||||
- Use computed for derived values; avoid mutating props directly.
|
||||
- When navigating, prefer named routes for stability.
|
||||
- Keep tests small and fast; add a test when you add a new mapper or unit.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- API errors: verify `VUE_APP_API_BASE` is set and reachable.
|
||||
- Type/IDE help: ensure Volar is enabled and ESLint is not conflicting with Prettier.
|
||||
- Build issues: this project uses Vue CLI 5. If migrating to Vite, update scripts and configs accordingly.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
presets: ['@vue/cli-plugin-babel/preset'],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,8 @@
|
|||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
"lib": ["esnext", "dom", "dom.iterable", "scripthost"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3615
package-lock.json
generated
3615
package-lock.json
generated
File diff suppressed because it is too large
Load diff
28
package.json
28
package.json
|
|
@ -5,7 +5,12 @@
|
|||
"scripts": {
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "vue-cli-service lint"
|
||||
"lint": "vue-cli-service lint",
|
||||
"format": "prettier --write .",
|
||||
"prepare": "husky install",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.8.3",
|
||||
|
|
@ -19,21 +24,34 @@
|
|||
"@vue/cli-plugin-eslint": "~5.0.0",
|
||||
"@vue/cli-service": "~5.0.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-vue": "^8.0.3"
|
||||
"eslint-plugin-vue": "^8.0.3",
|
||||
"husky": "^8.0.0",
|
||||
"lint-staged": "^13.3.0",
|
||||
"prettier": "^3.3.3",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue,css,scss,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"node": true
|
||||
"node": true,
|
||||
"vue/setup-compiler-macros": true
|
||||
},
|
||||
"extends": [
|
||||
"plugin:vue/vue3-essential",
|
||||
"plugin:vue/vue3-recommended",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"parser": "@babel/eslint-parser"
|
||||
},
|
||||
"rules": {}
|
||||
"rules": {
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-mutating-props": "error"
|
||||
}
|
||||
},
|
||||
"browserslist": [
|
||||
"> 1%",
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="icon" href="<%= BASE_URL %>favicon.ico" />
|
||||
<title><%= htmlWebpackPlugin.options.title %></title>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<div id="app"></div>
|
||||
<!-- built files will be auto injected -->
|
||||
|
|
|
|||
108
src/App.vue
108
src/App.vue
|
|
@ -1,48 +1,49 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" to="/recipes" active-class="active">Recipes</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" to="/mealplan" active-class="active">Meal Plan</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{ name: 'recipes' }"
|
||||
active-class="active"
|
||||
>
|
||||
Recipes
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{ name: 'mealplan' }"
|
||||
active-class="active"
|
||||
>
|
||||
Meal Plan
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{ name: 'shopping' }"
|
||||
active-class="active"
|
||||
>
|
||||
Shopping
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="viewport">
|
||||
<router-view/>
|
||||
<router-view />
|
||||
</div>
|
||||
|
||||
<alert-toast />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import data from './data.js'
|
||||
<script setup>
|
||||
import AlertToast from './components/AlertToast.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
'alert-toast': AlertToast
|
||||
},
|
||||
computed: {
|
||||
currentRoute() {
|
||||
return this.$route.path
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!await data.currentUser()) {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
// components in <script setup> are auto-registered by import + usage
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
#app {
|
||||
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
|
@ -54,45 +55,44 @@ export default {
|
|||
|
||||
/* Make nav bar links buttons across top of screen */
|
||||
.nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background-color: #333;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.nav li {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Style the links inside the navigation bar */
|
||||
.nav a {
|
||||
display: inline-block;
|
||||
color: #f2f2f2;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
color: #f2f2f2;
|
||||
text-align: center;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Have active route use different color */
|
||||
.nav li:has(> a.active) {
|
||||
background-color: #4CAF50;
|
||||
color: white;
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
15
src/alert.js
15
src/alert.js
|
|
@ -1,10 +1,11 @@
|
|||
const subscribers = [];
|
||||
const subscribers = []
|
||||
|
||||
export default {
|
||||
subscribe(callback) {
|
||||
subscribers.push(callback);
|
||||
},
|
||||
show(message) { // { message, heading, type: ["success", "error", "info"] }
|
||||
subscribers.forEach(callback => callback(message));
|
||||
}
|
||||
subscribe(callback) {
|
||||
subscribers.push(callback)
|
||||
},
|
||||
show(message) {
|
||||
// { message, heading, type: ["success", "error", "info"] }
|
||||
subscribers.forEach((callback) => callback(message))
|
||||
},
|
||||
}
|
||||
21
src/api/auth.js
Normal file
21
src/api/auth.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { http } from './http'
|
||||
|
||||
let cachedUser = null
|
||||
|
||||
export async function currentUser() {
|
||||
if (cachedUser) return cachedUser
|
||||
// Try a refresh if a cookie exists (browser will send it automatically)
|
||||
try {
|
||||
const user = await http.post('/auth/refresh')
|
||||
cachedUser = user
|
||||
} catch (_) {
|
||||
cachedUser = null
|
||||
}
|
||||
return cachedUser
|
||||
}
|
||||
|
||||
export async function login(username) {
|
||||
const user = await http.post('/auth/login', { username })
|
||||
cachedUser = user
|
||||
return user
|
||||
}
|
||||
35
src/api/http.js
Normal file
35
src/api/http.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
const BASE = process.env.VUE_APP_API_BASE || '/api'
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const url = path.startsWith('http') ? path : `${BASE}${path}`
|
||||
const resp = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!resp.ok) {
|
||||
let message = `${resp.status} ${resp.statusText}`
|
||||
try {
|
||||
const err = await resp.json()
|
||||
message = err.message || message
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
const error = new Error(message)
|
||||
error.status = resp.status
|
||||
throw error
|
||||
}
|
||||
if (resp.status === 204) return null
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: (p) => request(p),
|
||||
post: (p, body) => request(p, { method: 'POST', body: JSON.stringify(body) }),
|
||||
put: (p, body) => request(p, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
del: (p) => request(p, { method: 'DELETE' }),
|
||||
}
|
||||
16
src/api/mappers/mealMapper.js
Normal file
16
src/api/mappers/mealMapper.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
function toDate(value) {
|
||||
return value ? new Date(value) : value
|
||||
}
|
||||
|
||||
export function mapMeal(meal) {
|
||||
if (!meal) return meal
|
||||
meal.suggested_date = toDate(meal.suggested_date)
|
||||
meal.purchase_date = toDate(meal.purchase_date)
|
||||
meal.consumed_date = toDate(meal.consumed_date)
|
||||
return meal
|
||||
}
|
||||
|
||||
export function mapMeals(list) {
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.map(mapMeal)
|
||||
}
|
||||
15
src/api/mappers/recipeMapper.js
Normal file
15
src/api/mappers/recipeMapper.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
function toDate(value) {
|
||||
return value ? new Date(value) : value
|
||||
}
|
||||
|
||||
export function mapRecipe(recipe) {
|
||||
if (!recipe) return recipe
|
||||
recipe.date_created = toDate(recipe.date_created)
|
||||
recipe.date_hidden = toDate(recipe.date_hidden)
|
||||
return recipe
|
||||
}
|
||||
|
||||
export function mapRecipes(list) {
|
||||
if (!Array.isArray(list)) return []
|
||||
return list.map(mapRecipe)
|
||||
}
|
||||
73
src/api/mappers/shoppingListMapper.js
Normal file
73
src/api/mappers/shoppingListMapper.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { mapMeal } from './mealMapper'
|
||||
import { mapRecipe } from './recipeMapper'
|
||||
|
||||
function toDate(value) {
|
||||
return value ? new Date(value) : value
|
||||
}
|
||||
|
||||
function attachItemRefs(
|
||||
items,
|
||||
{ ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup }
|
||||
) {
|
||||
if (!Array.isArray(items)) return
|
||||
for (const item of items) {
|
||||
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.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.created_date) item.created_date = toDate(item.created_date)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapPurchasedShoppingList(dto) {
|
||||
if (!dto) return dto
|
||||
// Map lookups
|
||||
if (dto.meals_lookup) {
|
||||
for (const [k, v] of Object.entries(dto.meals_lookup)) dto.meals_lookup[k] = mapMeal(v)
|
||||
}
|
||||
if (dto.recipes_lookup) {
|
||||
for (const [k, v] of Object.entries(dto.recipes_lookup)) dto.recipes_lookup[k] = mapRecipe(v)
|
||||
}
|
||||
if (dto.ingredients_lookup) {
|
||||
// no date fields expected on ingredient based on current usage
|
||||
}
|
||||
if (dto.list) {
|
||||
if (dto.list.created_date) dto.list.created_date = toDate(dto.list.created_date)
|
||||
if (Array.isArray(dto.list.items)) {
|
||||
attachItemRefs(dto.list.items, {
|
||||
ingredients_lookup: dto.ingredients_lookup,
|
||||
meals_lookup: dto.meals_lookup,
|
||||
recipes_lookup: dto.recipes_lookup,
|
||||
shopping_list_lookup: { [dto.list.id]: dto.list },
|
||||
})
|
||||
}
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
export function mapCurrentShoppingList(dto) {
|
||||
if (!dto) return dto
|
||||
if (dto.meals_lookup) {
|
||||
for (const [k, v] of Object.entries(dto.meals_lookup)) dto.meals_lookup[k] = mapMeal(v)
|
||||
}
|
||||
if (dto.recipes_lookup) {
|
||||
for (const [k, v] of Object.entries(dto.recipes_lookup)) dto.recipes_lookup[k] = mapRecipe(v)
|
||||
}
|
||||
if (dto.shopping_list_lookup) {
|
||||
for (const [, v] of Object.entries(dto.shopping_list_lookup)) {
|
||||
if (v?.created_date) v.created_date = toDate(v.created_date)
|
||||
}
|
||||
}
|
||||
|
||||
const lookups = {
|
||||
ingredients_lookup: dto.ingredients_lookup,
|
||||
meals_lookup: dto.meals_lookup,
|
||||
recipes_lookup: dto.recipes_lookup,
|
||||
shopping_list_lookup: dto.shopping_list_lookup,
|
||||
}
|
||||
attachItemRefs(dto.outstanding_items, lookups)
|
||||
attachItemRefs(dto.requested_meals, lookups)
|
||||
attachItemRefs(dto.purchased_items, lookups)
|
||||
return dto
|
||||
}
|
||||
31
src/api/meals.js
Normal file
31
src/api/meals.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { http } from './http'
|
||||
import { mapMeal, mapMeals } from './mappers/mealMapper'
|
||||
|
||||
export async function getUpcomingMeals(from, to) {
|
||||
const params = `?from=${encodeURIComponent(from.toISOString())}&to=${encodeURIComponent(to.toISOString())}`
|
||||
const meals = await http.get(`/meals/upcoming${params}`)
|
||||
const normalized = mapMeals(meals)
|
||||
return normalized.sort((a, b) => a.suggested_date - b.suggested_date)
|
||||
}
|
||||
|
||||
export async function markMealConsumed(mealId) {
|
||||
const meal = await http.post(`/meals/${encodeURIComponent(mealId)}/consumed`)
|
||||
return mapMeal(meal)
|
||||
}
|
||||
|
||||
export async function deleteMeal(mealId) {
|
||||
return http.del(`/meals/${encodeURIComponent(mealId)}`)
|
||||
}
|
||||
|
||||
export async function getMeal(id) {
|
||||
const meal = await http.get(`/meals/${encodeURIComponent(id)}`)
|
||||
return mapMeal(meal)
|
||||
}
|
||||
|
||||
export async function saveMeal(meal) {
|
||||
const hasId = meal.id >= 0
|
||||
const path = hasId ? `/meals/${encodeURIComponent(meal.id)}` : '/meals'
|
||||
const method = hasId ? http.put : http.post
|
||||
const saved = await method(path, meal)
|
||||
return mapMeal(saved)
|
||||
}
|
||||
9
src/api/persons.js
Normal file
9
src/api/persons.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { http } from './http'
|
||||
|
||||
export async function getPersonsInHome() {
|
||||
return http.get('/persons')
|
||||
}
|
||||
|
||||
export async function searchPerson(name) {
|
||||
return http.get(`/persons?q=${encodeURIComponent(name)}`)
|
||||
}
|
||||
36
src/api/recipes.js
Normal file
36
src/api/recipes.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { http } from './http'
|
||||
import { mapRecipe, mapRecipes } from './mappers/recipeMapper'
|
||||
|
||||
export async function searchRecipes(query) {
|
||||
const recipes = await http.get(`/recipes?q=${encodeURIComponent(query)}`)
|
||||
return mapRecipes(recipes)
|
||||
}
|
||||
|
||||
export async function getRecipe(id) {
|
||||
const recipe = await http.get(`/recipes/${encodeURIComponent(id)}`)
|
||||
return mapRecipe(recipe)
|
||||
}
|
||||
|
||||
export async function saveRecipe(recipe) {
|
||||
const saved = await http.post('/recipes', recipe)
|
||||
return mapRecipe(saved)
|
||||
}
|
||||
|
||||
export async function deleteRecipe(id) {
|
||||
return http.del(`/recipes/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export async function parseRecipe(url) {
|
||||
const recipe = await http.get(`/recipes/parse?url=${encodeURIComponent(url)}`)
|
||||
return mapRecipe(recipe)
|
||||
}
|
||||
|
||||
export async function parseIngredients(lines) {
|
||||
const params = lines.map((line) => `ingredients=${encodeURIComponent(line)}`).join('&')
|
||||
return http.get(`/recipes/ingredients/parse?${params}`)
|
||||
}
|
||||
|
||||
export async function parseProduct(ingredient, url) {
|
||||
const body = { url, tags: [ingredient.name, ingredient.line] }
|
||||
return http.post('/products', body)
|
||||
}
|
||||
38
src/api/shopping.js
Normal file
38
src/api/shopping.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { http } from './http'
|
||||
import { mapPurchasedShoppingList, mapCurrentShoppingList } from './mappers/shoppingListMapper'
|
||||
|
||||
export async function getMyShoppingList() {
|
||||
const ingredients = await http.get('/shopping/current/me/ingredients')
|
||||
return ingredients
|
||||
}
|
||||
|
||||
export async function saveMyShoppingList(list) {
|
||||
const ingredients = await http.post('/shopping/current/me/ingredients', list)
|
||||
return ingredients
|
||||
}
|
||||
|
||||
export async function getShoppingList(id) {
|
||||
const dto = await http.get(`/shopping/${encodeURIComponent(id)}`)
|
||||
const mapped = mapPurchasedShoppingList(dto)
|
||||
return mapped.list
|
||||
}
|
||||
|
||||
export async function getCurrentShoppingList() {
|
||||
const dto = await http.get('/shopping/current')
|
||||
return mapCurrentShoppingList(dto)
|
||||
}
|
||||
|
||||
export async function purchaseShoppingList(completedRequests) {
|
||||
const dto = await http.post('/shopping/', { items: completedRequests })
|
||||
const mapped = mapPurchasedShoppingList(dto)
|
||||
return mapped.list
|
||||
}
|
||||
|
||||
export async function requestMeal(mealId) {
|
||||
const items = await http.post('/shopping/current/meals/me', { meal_id: mealId })
|
||||
return items
|
||||
}
|
||||
|
||||
export async function unrequestMeal(mealId) {
|
||||
await http.del(`/shopping/current/meals/${encodeURIComponent(mealId)}`)
|
||||
}
|
||||
|
|
@ -1,37 +1,40 @@
|
|||
<template>
|
||||
<div class="card">
|
||||
<a @click="$emit('click')">
|
||||
<h2>{{ title }}</h2>
|
||||
<img :src="image" :alt="name" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<a @click="emit('click')">
|
||||
<h2>{{ title }}</h2>
|
||||
<img
|
||||
:src="image"
|
||||
:alt="title"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ActionItem',
|
||||
props: ['title', 'image']
|
||||
}
|
||||
<script setup>
|
||||
const emit = defineEmits(['click'])
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
image: { type: String, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: inline-block;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
width: 20em;
|
||||
margin: 1em 1ex;
|
||||
display: inline-block;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
width: 20em;
|
||||
margin: 1em 1ex;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
background-color: #ccc;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,98 +1,90 @@
|
|||
<template>
|
||||
|
||||
<div v-if="showAlert" :class="['alert', type]" @click="dismiss">
|
||||
<img v-if="icon" :src="icon" alt="Notification icon" />
|
||||
<div
|
||||
v-if="showAlert"
|
||||
:class="['alert', type]"
|
||||
@click="dismiss"
|
||||
>
|
||||
<img
|
||||
v-if="icon"
|
||||
:src="icon"
|
||||
alt="Notification icon"
|
||||
>
|
||||
<div class="message-container">
|
||||
<h4 class="heading">{{ heading }}</h4>
|
||||
<p class="message">{{ message }}</p>
|
||||
<h4 class="heading">
|
||||
{{ heading }}
|
||||
</h4>
|
||||
<p class="message">
|
||||
{{ message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
const alertIcons = {
|
||||
error: require('@/assets/notification-error.svg'),
|
||||
success: require('@/assets/notification-success.svg'),
|
||||
info: require('@/assets/notification-info.svg'),
|
||||
}
|
||||
|
||||
const { current, clear, scheduleAutoDismiss } = useAlert()
|
||||
|
||||
const showAlert = computed(() => !!current.value)
|
||||
const heading = computed(() => current.value?.heading ?? '')
|
||||
const message = computed(() => current.value?.message ?? '')
|
||||
const type = computed(() => current.value?.type ?? '')
|
||||
const icon = computed(() => (type.value && alertIcons[type.value] ? alertIcons[type.value] : null))
|
||||
|
||||
function dismiss() {
|
||||
clear()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => current.value?._ts,
|
||||
(ts) => {
|
||||
if (ts) scheduleAutoDismiss(5000)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 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 */
|
||||
|
||||
.alert {
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
right: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
text-align: left;
|
||||
position: fixed;
|
||||
bottom: 1em;
|
||||
right: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.alert img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
margin-right: 1em;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
margin-right: 1em;
|
||||
|
||||
/* Invert svg colors */
|
||||
filter: invert(1);
|
||||
/* Invert svg colors */
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background-color: #f44336;
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background-color: #4CAF50;
|
||||
background-color: #4caf50;
|
||||
}
|
||||
|
||||
.alert.info {
|
||||
background-color: #2196F3;
|
||||
background-color: #2196f3;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import alert from '@/alert';
|
||||
|
||||
const alertIcons = {
|
||||
error: require('@/assets/notification-error.svg'),
|
||||
success: require('@/assets/notification-success.svg'),
|
||||
info: require('@/assets/notification-info.svg')
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'AlertToast',
|
||||
data() {
|
||||
return {
|
||||
showAlert: false,
|
||||
heading: '',
|
||||
message: '',
|
||||
type: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
icon() {
|
||||
return this.type && alertIcons[this.type] ? alertIcons[this.type] : null;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
alert.subscribe(this.show);
|
||||
},
|
||||
methods: {
|
||||
show({ heading, message, type }) {
|
||||
this.heading = heading;
|
||||
this.message = message;
|
||||
this.type = type;
|
||||
this.showAlert = true;
|
||||
setTimeout(() => {
|
||||
this.showAlert = false;
|
||||
}, 5000);
|
||||
},
|
||||
dismiss() {
|
||||
this.showAlert = false;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,107 +1,102 @@
|
|||
<template>
|
||||
|
||||
<div class="login">
|
||||
<h1>Login Page</h1>
|
||||
<ul class="button-group">
|
||||
<li v-for="person in persons" :key="person.id">
|
||||
<button type="button" class="btn btn-primary" @click="login(person)">
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="login">
|
||||
<h1>Login Page</h1>
|
||||
<ul class="button-group">
|
||||
<li
|
||||
v-for="person in persons"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="onLogin(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getPersonsInHome } from '@/api/persons'
|
||||
import { login as loginApi } from '@/api/auth'
|
||||
|
||||
const props = defineProps({
|
||||
redirect: { type: String, default: '/' },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const persons = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
persons.value = await getPersonsInHome()
|
||||
})
|
||||
|
||||
async function onLogin(selectedPerson) {
|
||||
const person = await loginApi(selectedPerson.name)
|
||||
if (person?.id >= 0) {
|
||||
router.push(props.redirect)
|
||||
return
|
||||
}
|
||||
alert('Login failed')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove the default list styling */
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Center the buttons in the middle of the page, and let them wrap */
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Display each button as a large round circle */
|
||||
|
||||
button {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
font-size: 1.5em;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
margin: 10px;
|
||||
font-size: 1.5em;
|
||||
|
||||
/* Center the text in the middle of the button */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* Center the text in the middle of the button */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
/* Add a shadow to make the buttons look like they are floating */
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
/* Add a shadow to make the buttons look like they are floating */
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Some nice flat nuetral shades for the user buttons */
|
||||
|
||||
li:nth-child(1) > button {
|
||||
background-color: #3939ff;
|
||||
color: white;
|
||||
background-color: #3939ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
li:nth-child(2) > button {
|
||||
background-color: #156a14;
|
||||
color: white;
|
||||
background-color: #156a14;
|
||||
color: white;
|
||||
}
|
||||
|
||||
li:nth-child(3) > button {
|
||||
background-color: #325293;
|
||||
color: white;
|
||||
background-color: #325293;
|
||||
color: white;
|
||||
}
|
||||
|
||||
li:nth-child(4) > button {
|
||||
background-color: #9a1f1f;
|
||||
color: white;
|
||||
background-color: #9a1f1f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import data from '@/data';
|
||||
|
||||
export default {
|
||||
name: 'LoginVue',
|
||||
props: {
|
||||
redirect: {
|
||||
type: String,
|
||||
default: '/'
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
persons: [],
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
this.persons = await data.getPersonsInHome();
|
||||
},
|
||||
methods: {
|
||||
async login(selectedPerson) {
|
||||
const person = await data.login(selectedPerson.name);
|
||||
if (person?.id >= 0) {
|
||||
this.$router.push(this.redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Login failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,56 +1,89 @@
|
|||
<template>
|
||||
<div class="compact-parse-results">
|
||||
<div class="compact-parse-results">
|
||||
<p class="parse-element teaser-image">
|
||||
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')" />
|
||||
<img :src="ingredient.product?.img_small ?? require('@/assets/missing-product.svg')">
|
||||
</p>
|
||||
<p class="ingredient-details">
|
||||
<span class="parse-element quantity" :class="{ missing: !(ingredient?.quantity)}">{{ 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 name" :class="{ missing: !(ingredient?.name)}">{{ ingredient?.name || 'name' }}</span>:
|
||||
<span class="parse-element product-name" :class="{missing: !(ingredient?.product)}">
|
||||
<a :href="ingredient?.product?.link" v-if="ingredient?.product?.link" target=”_blank”>
|
||||
( {{ 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>
|
||||
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank">
|
||||
(search?)
|
||||
</a>
|
||||
<a v-else>
|
||||
(product)
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
class="parse-element quantity"
|
||||
:class="{ missing: !ingredient?.quantity }"
|
||||
>{{
|
||||
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 name"
|
||||
:class="{ missing: !ingredient?.name }"
|
||||
>{{
|
||||
ingredient?.name || 'name'
|
||||
}}</span>:
|
||||
<span
|
||||
class="parse-element product-name"
|
||||
:class="{ missing: !ingredient?.product }"
|
||||
>
|
||||
<a
|
||||
v-if="ingredient?.product?.link"
|
||||
:href="ingredient?.product?.link"
|
||||
target="”_blank”"
|
||||
>
|
||||
( {{ 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>
|
||||
<a
|
||||
v-else-if="ingredient?.name"
|
||||
:href="searchlink"
|
||||
target="_blank"
|
||||
> (search?) </a>
|
||||
<a v-else> (product) </a>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'CompactParsedIngredient',
|
||||
props: ['ingredient'],
|
||||
computed: {
|
||||
searchlink() {
|
||||
return 'https://www.woolworths.com.au/shop/search/products?searchTerm=' + encodeURIComponent(this.ingredient.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
const props = defineProps({
|
||||
ingredient: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const searchlink = computed(() =>
|
||||
props.ingredient?.name
|
||||
? 'https://www.woolworths.com.au/shop/search/products?searchTerm=' +
|
||||
encodeURIComponent(props.ingredient.name)
|
||||
: ''
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.compact-parse-results {
|
||||
flex: left;
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
text-align: left;
|
||||
flex: left;
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.parse-element {
|
||||
margin: auto 0;
|
||||
margin-right: 1em;
|
||||
border-bottom: solid 1px #ccc;
|
||||
font-weight: bold;
|
||||
margin: auto 0;
|
||||
margin-right: 1em;
|
||||
border-bottom: solid 1px #ccc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.parse-element.missing {
|
||||
|
|
@ -58,20 +91,21 @@ export default {
|
|||
border: solid red 1px;
|
||||
}
|
||||
|
||||
.parse-element.teaser-image, .parse-element.helper {
|
||||
border: none;
|
||||
font-weight: normal;
|
||||
.parse-element.teaser-image,
|
||||
.parse-element.helper {
|
||||
border: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.parse-element.teaser-image img {
|
||||
padding: 0;
|
||||
border: none;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
padding: 0;
|
||||
border: none;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.ingredient-details {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
|
|
@ -89,5 +123,4 @@ export default {
|
|||
.product-name {
|
||||
color: purple;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,107 +1,122 @@
|
|||
<template>
|
||||
|
||||
<div :class="{ editing: editing }">
|
||||
<button v-if="editing" @click="$emit('on-add')">
|
||||
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
|
||||
Add Ingredient
|
||||
<div :class="{ editing: editing }">
|
||||
<button
|
||||
v-if="editing"
|
||||
@click="emit('on-add')"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/add-cart.svg')"
|
||||
> <br>
|
||||
Add Ingredient
|
||||
</button>
|
||||
<button @click="toggleEditing" v-if="!editOnly">
|
||||
<span v-if="editing">
|
||||
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
|
||||
Done Editing
|
||||
</span>
|
||||
<span v-else>
|
||||
<img class="icon" :src="require('@/assets/edit.svg')" /> <br />
|
||||
Edit My List
|
||||
</span>
|
||||
<button
|
||||
v-if="!editOnly"
|
||||
@click="toggleEditing"
|
||||
>
|
||||
<span v-if="editing">
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/edit-off.svg')"
|
||||
> <br>
|
||||
Done Editing
|
||||
</span>
|
||||
<span v-else>
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/edit.svg')"
|
||||
> <br>
|
||||
Edit My List
|
||||
</span>
|
||||
</button>
|
||||
<ul>
|
||||
<li v-for="ingredient in ingredients" :key="ingredient">
|
||||
<div v-if="editing">
|
||||
<p class="ingredient-line">
|
||||
<ingredient-line
|
||||
:ingredient="ingredient"
|
||||
@update-ingredient="updateIngredient"
|
||||
@update-product-link="updateProduct" />
|
||||
</p>
|
||||
<button @click="$emit('on-delete', ingredient)">
|
||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<compact-parsed-ingredient :ingredient="ingredient" />
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
v-for="ingredient in ingredients"
|
||||
:key="ingredient"
|
||||
>
|
||||
<div v-if="editing">
|
||||
<p class="ingredient-line">
|
||||
<ingredient-line
|
||||
:ingredient="ingredient"
|
||||
@update-ingredient="updateIngredient"
|
||||
@update-product-link="updateProduct"
|
||||
/>
|
||||
</p>
|
||||
<button @click="emit('on-delete', ingredient)">
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/trash.svg')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<compact-parsed-ingredient :ingredient="ingredient" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li > div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editing li {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.ingredient-line {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import data from '@/data.js'
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { parseProduct, parseIngredients } from '@/api/recipes'
|
||||
import IngredientLine from './IngredientLine.vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
|
||||
export default {
|
||||
name: 'EditableIngredientsPanel',
|
||||
components: { IngredientLine, CompactParsedIngredient },
|
||||
props: ['ingredients', 'editOnly'],
|
||||
data() {
|
||||
return {
|
||||
editing: this.editOnly ?? false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async updateProduct(ingredient, product_link) {
|
||||
const product = await data.parseProduct(ingredient, product_link);
|
||||
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product });
|
||||
},
|
||||
async updateIngredient(ingredient, line) {
|
||||
const newIngredients = await data.parseIngredients([line]);
|
||||
this.$emit('on-update-ingredient', ingredient, newIngredients[0]);
|
||||
},
|
||||
toggleEditing() {
|
||||
this.editing = !this.editing;
|
||||
this.$emit('on-editing', this.editing);
|
||||
}
|
||||
}
|
||||
const props = defineProps({
|
||||
ingredients: { type: Array, required: true },
|
||||
editOnly: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
|
||||
|
||||
const editing = ref(props.editOnly ?? false)
|
||||
|
||||
async function updateProduct(ingredient, product_link) {
|
||||
const product = await parseProduct(ingredient, product_link)
|
||||
emit('on-update-ingredient', ingredient, { ...ingredient, product })
|
||||
}
|
||||
|
||||
async function updateIngredient(ingredient, line) {
|
||||
const newIngredients = await parseIngredients([line])
|
||||
emit('on-update-ingredient', ingredient, newIngredients[0])
|
||||
}
|
||||
|
||||
function toggleEditing() {
|
||||
editing.value = !editing.value
|
||||
emit('on-editing', editing.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li > div {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editing li {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.ingredient-line {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
margin-right: 1em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,68 +1,74 @@
|
|||
<template>
|
||||
<div class="ingredient-item">
|
||||
<p>
|
||||
<input 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 v-if="ingredient.line">
|
||||
<!-- Single line parse results -->
|
||||
<compact-parsed-ingredient :ingredient="ingredient" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="ingredient-item">
|
||||
<p>
|
||||
<input
|
||||
v-model="ingredientText"
|
||||
placeholder="Enter an ingredient"
|
||||
@keyup.enter="updateIngredient"
|
||||
@blur="updateIngredient"
|
||||
>
|
||||
<input
|
||||
v-if="ingredient.line"
|
||||
v-model="productLink"
|
||||
class="product-link-input"
|
||||
placeholder="Enter product link"
|
||||
@keyup.enter="updateProductLink"
|
||||
@blur="updateProductLink"
|
||||
>
|
||||
</p>
|
||||
<p v-if="ingredient.line">
|
||||
<!-- Single line parse results -->
|
||||
<compact-parsed-ingredient :ingredient="ingredient" />
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue';
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
ingredient: { type: Object },
|
||||
},
|
||||
events: ['update-ingredient', 'update-product-link'],
|
||||
components: { CompactParsedIngredient },
|
||||
data() {
|
||||
return {
|
||||
ingredientText: this.ingredient?.line ?? "",
|
||||
productLink: this.ingredient.product?.link ?? "",
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
ingredient: {
|
||||
handler: function (newIngredient) {
|
||||
this.ingredientText = newIngredient?.line ?? "";
|
||||
this.productLink = newIngredient.product?.link ?? "";
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
updateIngredient() {
|
||||
if (this.ingredientText != this.ingredient.line)
|
||||
this.$emit('update-ingredient', this.ingredient, this.ingredientText);
|
||||
},
|
||||
updateProductLink() {
|
||||
if (this.productLink && this.productLink != this.ingredient.product?.link)
|
||||
this.$emit('update-product-link', this.ingredient, this.productLink);
|
||||
}
|
||||
}
|
||||
};
|
||||
const props = defineProps({
|
||||
ingredient: { type: Object, required: true },
|
||||
})
|
||||
const emit = defineEmits(['update-ingredient', 'update-product-link'])
|
||||
|
||||
const ingredientText = ref(props.ingredient?.line ?? '')
|
||||
const productLink = ref(props.ingredient.product?.link ?? '')
|
||||
|
||||
watch(
|
||||
() => props.ingredient,
|
||||
(newIngredient) => {
|
||||
ingredientText.value = newIngredient?.line ?? ''
|
||||
productLink.value = newIngredient?.product?.link ?? ''
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function updateIngredient() {
|
||||
if (ingredientText.value != props.ingredient.line) {
|
||||
emit('update-ingredient', props.ingredient, ingredientText.value)
|
||||
}
|
||||
}
|
||||
|
||||
function updateProductLink() {
|
||||
if (productLink.value && productLink.value != props.ingredient.product?.link) {
|
||||
emit('update-product-link', props.ingredient, productLink.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
input {
|
||||
border: 0;
|
||||
font-size: larger;
|
||||
border-bottom: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
width: 100%;
|
||||
padding: 0.5vh;
|
||||
border: 0;
|
||||
font-size: larger;
|
||||
border-bottom: 1px solid #ccc;
|
||||
border-left: 1px solid #ccc;
|
||||
width: 100%;
|
||||
padding: 0.5vh;
|
||||
}
|
||||
|
||||
.product-link-input {
|
||||
color: #777;
|
||||
margin-top: 0.5vh;
|
||||
color: #777;
|
||||
margin-top: 0.5vh;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
|
@ -1,121 +1,112 @@
|
|||
<template>
|
||||
<div class="date-picker">
|
||||
<input
|
||||
type="text"
|
||||
v-model="selectedDate"
|
||||
@focus="showDatePicker = true"
|
||||
@blur="showDatePicker = false"
|
||||
placeholder="Select a date"
|
||||
/>
|
||||
<div v-if="showDatePicker" class="date-picker-dropdown">
|
||||
<ul>
|
||||
<li
|
||||
v-for="(day, index) in days"
|
||||
:key="index"
|
||||
@mousedown="selectDate(day)">
|
||||
{{ formatDay(day) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="date-picker">
|
||||
<input
|
||||
v-model="selectedDate"
|
||||
type="text"
|
||||
placeholder="Select a date"
|
||||
@focus="showDatePicker = true"
|
||||
@blur="showDatePicker = false"
|
||||
>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="date-picker-dropdown"
|
||||
>
|
||||
<ul>
|
||||
<li
|
||||
v-for="(day, index) in days"
|
||||
:key="index"
|
||||
@mousedown="selectDate(day)"
|
||||
>
|
||||
{{ formatDay(day) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
date: {
|
||||
type: Date,
|
||||
default: new Date(),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedDate: this.formatDay(this.date),
|
||||
showDatePicker: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
date(newDate) {
|
||||
this.selectedDate = this.formatDay(newDate);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
days() {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
days.push(date);
|
||||
}
|
||||
const props = defineProps({
|
||||
date: { type: Date, default: () => new Date() },
|
||||
})
|
||||
const emit = defineEmits(['date-selected'])
|
||||
|
||||
return days;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
formatDay(date) {
|
||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
||||
return date.toLocaleDateString("en-AU", options);
|
||||
},
|
||||
selectDate(date) {
|
||||
this.selectedDate = this.formatSelectedDate(date);
|
||||
this.showDatePicker = false;
|
||||
function formatDay(date) {
|
||||
const options = { weekday: 'long', day: 'numeric', month: 'numeric' }
|
||||
return date.toLocaleDateString('en-AU', options)
|
||||
}
|
||||
|
||||
// Emit custom event
|
||||
this.$emit("date-selected", date);
|
||||
},
|
||||
formatSelectedDate(date) {
|
||||
const options = { weekday: "long", day: "numeric", month: "numeric" };
|
||||
return date.toLocaleDateString("en-AU", options);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
const selectedDate = ref(formatDay(props.date))
|
||||
const showDatePicker = ref(false)
|
||||
|
||||
<style scoped>
|
||||
.date-picker {
|
||||
position: relative;
|
||||
watch(
|
||||
() => props.date,
|
||||
(newDate) => {
|
||||
selectedDate.value = formatDay(newDate)
|
||||
}
|
||||
)
|
||||
|
||||
.date-picker input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
const days = computed(() => {
|
||||
const today = new Date()
|
||||
const result = []
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const d = new Date(today)
|
||||
d.setDate(today.getDate() + i)
|
||||
result.push(d)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
.date-picker-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
function selectDate(date) {
|
||||
selectedDate.value = formatDay(date)
|
||||
showDatePicker.value = false
|
||||
emit('date-selected', date)
|
||||
}
|
||||
</script>
|
||||
|
||||
.date-picker-dropdown ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
<style scoped>
|
||||
.date-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.date-picker-dropdown li {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.date-picker input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.date-picker-dropdown li:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
.date-picker-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.date-picker-dropdown li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
.date-picker-dropdown ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.date-picker-dropdown li {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.date-picker-dropdown li:hover {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.date-picker-dropdown li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,300 +1,368 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<div class="fields">
|
||||
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
|
||||
<div class="persons-list">
|
||||
Cooked by <person-list :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 class="recipes">
|
||||
<h2>Recipes</h2>
|
||||
<ul v-if="meal.recipes && meal.recipes.length">
|
||||
<li v-for="mealRecipe in meal.recipes" :key="mealRecipe.recipe.id">
|
||||
<div class="saved-recipe">
|
||||
<p class="recipe-card">
|
||||
<recipe-card :recipe="mealRecipe.recipe" />
|
||||
</p>
|
||||
|
||||
<p class="servings">
|
||||
<input type="number" v-model="mealRecipe.servings" min="1" />
|
||||
<small><em>servings</em></small>
|
||||
</p>
|
||||
|
||||
<input 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')" />
|
||||
</label>
|
||||
<button class="icon-button" @click="removeRecipe(mealRecipe)">
|
||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showIngredient(mealRecipe)">
|
||||
<ul>
|
||||
<li v-for="ingredient in scaleIngredients(mealRecipe)" :key="ingredient.id" class="saved-ingredient">
|
||||
<CompactParsedIngredient :ingredient="ingredient" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else>
|
||||
<p>Add some recipes using the search box</p>
|
||||
</div>
|
||||
<div class="fields">
|
||||
<recipe-search-box @select-recipe="selectRecipe" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ingredients">
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<button @click="saveMeal">Save</button>
|
||||
<p v-if="meal.purchase_date"><em>Purchased {{ ago(meal.purchase_date) }}</em></p>
|
||||
<div class="container">
|
||||
<div class="fields">
|
||||
<date-picker
|
||||
:date="meal.suggested_date"
|
||||
@date-selected="selectDate"
|
||||
/>
|
||||
<div class="persons-list">
|
||||
Cooked by
|
||||
<person-list
|
||||
: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 class="recipes">
|
||||
<h2>Recipes</h2>
|
||||
<ul v-if="meal.recipes && meal.recipes.length">
|
||||
<li
|
||||
v-for="mealRecipe in meal.recipes"
|
||||
:key="mealRecipe.recipe.id"
|
||||
>
|
||||
<div class="saved-recipe">
|
||||
<p class="recipe-card">
|
||||
<recipe-card :recipe="mealRecipe.recipe" />
|
||||
</p>
|
||||
|
||||
<p class="servings">
|
||||
<input
|
||||
v-model="mealRecipe.servings"
|
||||
type="number"
|
||||
min="1"
|
||||
>
|
||||
<small><em>servings</em></small>
|
||||
</p>
|
||||
|
||||
<input
|
||||
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')"
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
class="icon-button"
|
||||
@click="removeRecipe(mealRecipe)"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/trash.svg')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showIngredient(mealRecipe)">
|
||||
<ul>
|
||||
<li
|
||||
v-for="ingredient in scaleIngredients(mealRecipe)"
|
||||
:key="ingredient.id"
|
||||
class="saved-ingredient"
|
||||
>
|
||||
<CompactParsedIngredient :ingredient="ingredient" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else>
|
||||
<p>Add some recipes using the search box</p>
|
||||
</div>
|
||||
<div class="fields">
|
||||
<recipe-search-box @select-recipe="selectRecipe" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ingredients">
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button @click="onSaveMeal">
|
||||
Save
|
||||
</button>
|
||||
<p v-if="meal.purchase_date">
|
||||
<em>Purchased {{ ago(meal.purchase_date) }}</em>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { reactive, onBeforeMount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getMeal, saveMeal } from '@/composables/useMeals'
|
||||
import { getRecipe } from '@/api/recipes'
|
||||
import { currentUser } from '@/api/auth'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
import data from '@/data.js';
|
||||
import alert from '@/alert.js';
|
||||
import { ago } from '@/dateformats.js'
|
||||
|
||||
import { ago } from '@/dateformats.js';
|
||||
|
||||
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue';
|
||||
import RecipeCard from '@/components/recipes/RecipeCard.vue';
|
||||
import DatePicker from './DatePicker.vue';
|
||||
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue';
|
||||
import PersonList from './PersonList.vue';
|
||||
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue';
|
||||
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
|
||||
import RecipeCard from '@/components/recipes/RecipeCard.vue'
|
||||
import DatePicker from './DatePicker.vue'
|
||||
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
|
||||
import PersonList from './PersonList.vue'
|
||||
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
|
||||
|
||||
function addPersonIfNotExists(list, person) {
|
||||
if (!list.find(p => p.id === person.id)) {
|
||||
list.push(person);
|
||||
}
|
||||
if (!list.find((p) => p.id === person.id)) {
|
||||
list.push(person)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: { RecipeSearchBox, DatePicker, RecipeCard, EditableIngredientsPanel, PersonList, CompactParsedIngredient },
|
||||
data() {
|
||||
return {
|
||||
showIngredients: {},
|
||||
meal: {
|
||||
id: -1,
|
||||
suggested_date: new Date(),
|
||||
recipes: [],
|
||||
extra_ingredients: [],
|
||||
chefs: [],
|
||||
consumers: [],
|
||||
cleanup: []
|
||||
}
|
||||
};
|
||||
},
|
||||
async beforeMount() {
|
||||
if (this.id >= 0) {
|
||||
this.meal = await data.getMeal(this.id);
|
||||
}
|
||||
else {
|
||||
const self = await data.currentUser();
|
||||
this.meal = {...this.meal, chefs: [self], consumers: [self], cleanup: [self], };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
ago,
|
||||
async selectRecipe(recipe) {
|
||||
// Refetch to get additional details
|
||||
recipe = await data.getRecipe(recipe.id);
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { show: showAlert } = useAlert()
|
||||
|
||||
if (recipe.created_by) {
|
||||
addPersonIfNotExists(this.meal.chefs, recipe.created_by);
|
||||
addPersonIfNotExists(this.meal.consumers, recipe.created_by);
|
||||
const showIngredients = reactive({})
|
||||
const meal = reactive({
|
||||
id: -1,
|
||||
suggested_date: new Date(),
|
||||
recipes: [],
|
||||
extra_ingredients: [],
|
||||
chefs: [],
|
||||
consumers: [],
|
||||
cleanup: [],
|
||||
})
|
||||
|
||||
if (this.meal.cleanup.length === 0) {
|
||||
addPersonIfNotExists(this.meal.cleanup, recipe.created_by);
|
||||
}
|
||||
}
|
||||
onBeforeMount(async () => {
|
||||
const idParam = route.params.id
|
||||
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
|
||||
if (id >= 0) {
|
||||
const loaded = await getMeal(id)
|
||||
Object.assign(meal, loaded)
|
||||
} else {
|
||||
const self = await currentUser()
|
||||
Object.assign(meal, { chefs: [self], consumers: [self], cleanup: [self] })
|
||||
}
|
||||
})
|
||||
|
||||
this.meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: this.meal.id, servings: recipe.serves });
|
||||
},
|
||||
selectDate(date) {
|
||||
this.meal.suggested_date = date;
|
||||
},
|
||||
removeRecipe(mealRecipe) {
|
||||
if (confirm(`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe.name} from this meal?`)) {
|
||||
this.meal.recipes = this.meal.recipes.filter(r => r != mealRecipe);
|
||||
}
|
||||
},
|
||||
addIngredient() {
|
||||
this.meal.extra_ingredients = [{ line: '', product: null }, ...this.meal.extra_ingredients];
|
||||
},
|
||||
deleteIngredient(ingredient) {
|
||||
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i != ingredient);
|
||||
},
|
||||
updateIngredient(ingredient, newIngredient) {
|
||||
this.meal.extra_ingredients = this.meal.extra_ingredients.map(i => i == ingredient ? newIngredient : i);
|
||||
},
|
||||
removePerson(list, person) {
|
||||
this.meal[list] = this.meal[list].filter(p => p.id !== person.id);
|
||||
},
|
||||
addPerson(list, person) {
|
||||
addPersonIfNotExists(this.meal[list], person);
|
||||
},
|
||||
async saveMeal() {
|
||||
const meal = await data.saveMeal(this.meal);
|
||||
if (meal?.id >= 0) {
|
||||
this.meal = meal;
|
||||
function selectDate(date) {
|
||||
meal.suggested_date = date
|
||||
}
|
||||
|
||||
this.$router.push(`/meals/${meal.id}`);
|
||||
alert.show({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' });
|
||||
return;
|
||||
}
|
||||
function removeRecipe(mealRecipe) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
alert.show({ heading: 'Error saving meal', message: 'An error occurred while saving the meal', type: 'error' });
|
||||
},
|
||||
onEditAdditionalIngredients(editing) {
|
||||
if (editing && this.meal.extra_ingredients.length === 0) {
|
||||
this.addIngredient();
|
||||
}
|
||||
else {
|
||||
this.meal.extra_ingredients = this.meal.extra_ingredients.filter(i => i.line);
|
||||
}
|
||||
},
|
||||
showIngredient(mealRecipe, value) {
|
||||
const index = this.meal.recipes.indexOf(mealRecipe);
|
||||
const key = `${mealRecipe.recipe.id}-${index}`;
|
||||
if (value === undefined) {
|
||||
return this.showIngredients[key];
|
||||
}
|
||||
function addIngredient() {
|
||||
meal.extra_ingredients = [{ line: '', product: null }, ...meal.extra_ingredients]
|
||||
}
|
||||
|
||||
return this.showIngredients[key] = value;
|
||||
},
|
||||
scaleIngredients(mealRecipe) {
|
||||
return mealRecipe.recipe.ingredients.map(i => {
|
||||
return {
|
||||
...i,
|
||||
quantity: i.quantity * mealRecipe.servings / mealRecipe.recipe.serves
|
||||
};
|
||||
});
|
||||
},
|
||||
function deleteIngredient(ingredient) {
|
||||
meal.extra_ingredients = meal.extra_ingredients.filter((i) => i != ingredient)
|
||||
}
|
||||
|
||||
function updateIngredient(ingredient, newIngredient) {
|
||||
meal.extra_ingredients = meal.extra_ingredients.map((i) => (i == ingredient ? newIngredient : i))
|
||||
}
|
||||
|
||||
function removePerson(list, person) {
|
||||
meal[list] = meal[list].filter((p) => p.id !== person.id)
|
||||
}
|
||||
|
||||
function addPerson(list, person) {
|
||||
addPersonIfNotExists(meal[list], person)
|
||||
}
|
||||
|
||||
async function selectRecipe(recipe) {
|
||||
// Refetch to get additional details
|
||||
recipe = await getRecipe(recipe.id)
|
||||
|
||||
if (recipe.created_by) {
|
||||
addPersonIfNotExists(meal.chefs, recipe.created_by)
|
||||
addPersonIfNotExists(meal.consumers, recipe.created_by)
|
||||
|
||||
if (meal.cleanup.length === 0) {
|
||||
addPersonIfNotExists(meal.cleanup, recipe.created_by)
|
||||
}
|
||||
}
|
||||
|
||||
meal.recipes.push({ recipe, recipe_id: recipe.id, meal_id: meal.id, servings: recipe.serves })
|
||||
}
|
||||
|
||||
async function onEditAdditionalIngredients(editing) {
|
||||
if (editing && meal.extra_ingredients.length === 0) {
|
||||
addIngredient()
|
||||
} else {
|
||||
meal.extra_ingredients = meal.extra_ingredients.filter((i) => i.line)
|
||||
}
|
||||
}
|
||||
|
||||
function showIngredient(mealRecipe, value) {
|
||||
const index = meal.recipes.indexOf(mealRecipe)
|
||||
const key = `${mealRecipe.recipe.id}-${index}`
|
||||
if (value === undefined) {
|
||||
return showIngredients[key]
|
||||
}
|
||||
return (showIngredients[key] = value)
|
||||
}
|
||||
|
||||
function scaleIngredients(mealRecipe) {
|
||||
return mealRecipe.recipe.ingredients.map((i) => {
|
||||
return {
|
||||
...i,
|
||||
quantity: (i.quantity * mealRecipe.servings) / mealRecipe.recipe.serves,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function onSaveMeal() {
|
||||
const saved = await saveMeal(meal)
|
||||
if (saved?.id >= 0) {
|
||||
Object.assign(meal, saved)
|
||||
router.push(`/meals/${saved.id}`)
|
||||
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
|
||||
return
|
||||
}
|
||||
|
||||
showAlert({
|
||||
heading: 'Error saving meal',
|
||||
message: 'An error occurred while saving the meal',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
img.icon {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.persons-list {
|
||||
text-align: left;
|
||||
padding: 1ex 2em;
|
||||
text-align: left;
|
||||
padding: 1ex 2em;
|
||||
}
|
||||
|
||||
.persons-list p {
|
||||
margin: 0;
|
||||
padding-bottom: 1em;
|
||||
margin: 0;
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
|
||||
.person-list li {
|
||||
display: inline-block;
|
||||
padding-right: 1em;
|
||||
display: inline-block;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 1em auto;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-bottom: 0.5vh;
|
||||
padding-bottom: 0.5vh;
|
||||
}
|
||||
|
||||
.saved-ingredient {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved-recipe {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.saved-ingredient p {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
margin-right: 1em;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
|
||||
.saved-recipe button:hover {
|
||||
background: #eee;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.saved-recipe .recipe-card {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipe-card {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.servings {
|
||||
display: inline-block;
|
||||
margin-right: 1em;
|
||||
display: inline-block;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.servings input {
|
||||
width: 3em;
|
||||
border: none;
|
||||
border-bottom: 1px solid #000;
|
||||
text-align: center;
|
||||
font-size: large;
|
||||
font-style: italic;
|
||||
width: 3em;
|
||||
border: none;
|
||||
border-bottom: 1px solid #000;
|
||||
text-align: center;
|
||||
font-size: large;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 1em;
|
||||
padding: 0.5em;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 1em;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
/* Invert the colors of the trash icon */
|
||||
filter: invert(1);
|
||||
/* Invert the colors of the trash icon */
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.show-ingredient-checkbox {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.show-ingredient-checkbox + label {
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
padding: 0.5em;
|
||||
border-radius: 1em;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
padding: 0.5em;
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.show-ingredient-checkbox + label:hover {
|
||||
background-color: #eee;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
.show-ingredient-checkbox:checked + label {
|
||||
filter: invert(1);
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,85 +1,87 @@
|
|||
<template>
|
||||
<div class="meal-card">
|
||||
<h3>{{ mealTitle }}</h3>
|
||||
<h4>{{ dayOfWeek }} <small>{{ date }}</small></h4>
|
||||
<div class="meal-card">
|
||||
<h3>{{ mealTitle }}</h3>
|
||||
<h4>
|
||||
{{ dayOfWeek }} <small>{{ date }}</small>
|
||||
</h4>
|
||||
|
||||
<p>
|
||||
Cooked by
|
||||
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
|
||||
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
|
||||
</span>
|
||||
<span v-if="!meal.chefs.length">somebody?</span>
|
||||
</p>
|
||||
<p>
|
||||
For
|
||||
<span v-for="(consumer, index) in meal.consumers" :key="consumer.id">
|
||||
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
|
||||
</span>
|
||||
<span v-if="!meal.consumers.length">somebody?</span>
|
||||
</p>
|
||||
<p v-if="meal.purchase_date">
|
||||
Purchased {{ ago(meal.purchase_date) }}
|
||||
</p>
|
||||
</div>
|
||||
<p>
|
||||
Cooked by
|
||||
<span
|
||||
v-for="(chef, index) in meal.chefs"
|
||||
:key="chef.id"
|
||||
>
|
||||
{{ chef.name }}{{ englishSeperator(index, meal.chefs) }}
|
||||
</span>
|
||||
<span v-if="!meal.chefs.length">somebody?</span>
|
||||
</p>
|
||||
<p>
|
||||
For
|
||||
<span
|
||||
v-for="(consumer, index) in meal.consumers"
|
||||
:key="consumer.id"
|
||||
>
|
||||
{{ consumer.name }}{{ englishSeperator(index, meal.consumers) }}
|
||||
</span>
|
||||
<span v-if="!meal.consumers.length">somebody?</span>
|
||||
</p>
|
||||
<p v-if="meal.purchase_date">
|
||||
Purchased {{ ago(meal.purchase_date) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ago } from '@/dateformats.js'
|
||||
|
||||
const props = defineProps({
|
||||
meal: { type: Object, required: true },
|
||||
})
|
||||
|
||||
function englishSeperator(index, list) {
|
||||
switch (index) {
|
||||
case list.length - 1:
|
||||
return '';
|
||||
case list.length - 2:
|
||||
return ' and ';
|
||||
default:
|
||||
return ', ';
|
||||
}
|
||||
switch (index) {
|
||||
case list.length - 1:
|
||||
return ''
|
||||
case list.length - 2:
|
||||
return ' and '
|
||||
default:
|
||||
return ', '
|
||||
}
|
||||
}
|
||||
|
||||
function englishList(list) {
|
||||
switch (list.length) {
|
||||
case 0:
|
||||
return '';
|
||||
case 1:
|
||||
return list[0];
|
||||
case 2:
|
||||
return `${list[0]} and ${list[1]}`;
|
||||
default:
|
||||
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`;
|
||||
}
|
||||
switch (list.length) {
|
||||
case 0:
|
||||
return ''
|
||||
case 1:
|
||||
return list[0]
|
||||
case 2:
|
||||
return `${list[0]} and ${list[1]}`
|
||||
default:
|
||||
return `${list.slice(0, -1).join(', ')}, and ${list.slice(-1)}`
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MealCard',
|
||||
props: ['meal'],
|
||||
computed: {
|
||||
date() {
|
||||
return this.meal.suggested_date.toLocaleDateString('en-au', { month: 'numeric', day: 'numeric' })
|
||||
},
|
||||
dayOfWeek() {
|
||||
return this.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
|
||||
},
|
||||
mealTitle() {
|
||||
const recipesText = englishList(this.meal.recipes.map(mealRecipe => mealRecipe.recipe.name));
|
||||
const ingredientsText = englishList(this.meal.extra_ingredients.map(ingredient => ingredient.name));
|
||||
const date = computed(() =>
|
||||
props.meal.suggested_date.toLocaleDateString('en-au', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
})
|
||||
)
|
||||
|
||||
if (recipesText && ingredientsText) {
|
||||
return `${recipesText} with ${ingredientsText}`;
|
||||
} else if (recipesText || ingredientsText) {
|
||||
return recipesText || ingredientsText;
|
||||
} else {
|
||||
return 'Nothing planned';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
englishSeperator,
|
||||
ago
|
||||
}
|
||||
}
|
||||
const dayOfWeek = computed(() =>
|
||||
props.meal.suggested_date.toLocaleDateString('en-au', { weekday: 'long' })
|
||||
)
|
||||
|
||||
const mealTitle = computed(() => {
|
||||
const recipesText = englishList(props.meal.recipes.map((mr) => mr.recipe.name))
|
||||
const ingredientsText = englishList(props.meal.extra_ingredients.map((i) => i.name))
|
||||
|
||||
if (recipesText && ingredientsText) return `${recipesText} with ${ingredientsText}`
|
||||
if (recipesText || ingredientsText) return recipesText || ingredientsText
|
||||
return 'Nothing planned'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
|
|
|||
|
|
@ -1,118 +1,146 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul class="meals-list" v-if="meals.length">
|
||||
<li v-for="meal in meals" :key="meal.id">
|
||||
<meal-card :meal="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')" />
|
||||
</button>
|
||||
<div>
|
||||
<ul
|
||||
v-if="meals.length"
|
||||
class="meals-list"
|
||||
>
|
||||
<li
|
||||
v-for="meal in meals"
|
||||
:key="meal.id"
|
||||
>
|
||||
<meal-card :meal="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')
|
||||
"
|
||||
>
|
||||
</button>
|
||||
|
||||
<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><a @click="markConsumed">Mark Consumed</a></li>
|
||||
<li><a @click="deleteSelectedMeal" class="button">Remove</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<ul
|
||||
v-if="selectedMeal == meal"
|
||||
class="actions"
|
||||
>
|
||||
<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
|
||||
class="button"
|
||||
@click="deleteSelectedMeal"
|
||||
>Remove</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!meals.length">
|
||||
<em>No meals planned</em>
|
||||
</div>
|
||||
<action-item title="Plan Meal" :image="require('@/assets/plan-meal.svg')" @click="() => this.$router.push('/meals/add')" />
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!meals.length">
|
||||
<em>No meals planned</em>
|
||||
</div>
|
||||
<action-item
|
||||
title="Plan Meal"
|
||||
:image="require('@/assets/plan-meal.svg')"
|
||||
@click="() => $router.push('/meals/add')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import MealCard from '@/components/meals/MealCard.vue'
|
||||
import { getUpcomingMeals, markMealConsumed, deleteMeal } from '@/composables/useMeals'
|
||||
|
||||
const from = new Date()
|
||||
from.setTime(0)
|
||||
|
||||
const to = new Date()
|
||||
to.setDate(to.getDate() + 7)
|
||||
|
||||
const meals = ref([])
|
||||
const selectedMeal = ref(null)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
meals.value = await getUpcomingMeals(from, to)
|
||||
})
|
||||
|
||||
async function deleteSelectedMeal() {
|
||||
if (!selectedMeal.value) return
|
||||
await deleteMeal(selectedMeal.value.id)
|
||||
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
|
||||
selectedMeal.value = null
|
||||
}
|
||||
|
||||
async function markConsumed() {
|
||||
if (!selectedMeal.value) return
|
||||
await markMealConsumed(selectedMeal.value.id)
|
||||
meals.value = meals.value.filter((m) => m.id !== selectedMeal.value.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
li {
|
||||
list-style-type: none;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
ul.meals-list {
|
||||
padding: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toggle-actions {
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border: none;
|
||||
border-bottom: solid 1px #ccc;
|
||||
padding: 0 2em;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border: none;
|
||||
border-bottom: solid 1px #ccc;
|
||||
padding: 0 2em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toggle-actions img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.meals-list > li {
|
||||
margin-bottom: 1em;
|
||||
padding: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin-bottom: 1em;
|
||||
padding: 0;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
ul.actions {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.actions li {
|
||||
display: block;
|
||||
border: 1px solid #ccc;
|
||||
display: block;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.actions li:hover {
|
||||
background-color: #ccc;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.actions li a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding-top: 2ex;
|
||||
padding-bottom: 2ex;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: #000;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
padding-top: 2ex;
|
||||
padding-bottom: 2ex;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import MealCard from '@/components/meals/MealCard.vue'
|
||||
import data from '@/data.js'
|
||||
|
||||
export default {
|
||||
name: 'MealPlanPage',
|
||||
components: { MealCard, ActionItem },
|
||||
data() {
|
||||
const from = new Date();
|
||||
from.setTime(0);
|
||||
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + 7);
|
||||
|
||||
return {
|
||||
from, to,
|
||||
meals: [],
|
||||
selectedMeal: null
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
const meals = await data.getUpcomingMeals(this.from, this.to)
|
||||
this.meals = meals;
|
||||
},
|
||||
methods: {
|
||||
async deleteSelectedMeal() {
|
||||
await data.deleteMeal(this.selectedMeal.id);
|
||||
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
|
||||
this.selectedMeal = null;
|
||||
},
|
||||
async markConsumed() {
|
||||
await data.markMealConsumed(this.selectedMeal.id);
|
||||
this.meals = this.meals.filter(m => m.id !== this.selectedMeal.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,171 +1,191 @@
|
|||
<template>
|
||||
<span class="person-list">
|
||||
<span v-for="person in people" :key="person.id">
|
||||
<button class="person-circle remove-person" @click="$emit('remove-person', person)" >{{ person.name }}</button>
|
||||
<span class="person-list">
|
||||
<span
|
||||
v-for="person in people"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
class="person-circle remove-person"
|
||||
@click="removePerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</span>
|
||||
<span>
|
||||
<button v-if="!isAddingPerson" 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">
|
||||
<button class="person-circle add-person" @mousedown="addPerson(person)">{{ person.name }}</button>
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
v-if="!isAddingPerson"
|
||||
class="person-circle add-person"
|
||||
@click="isAddingPerson = true"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
v-else
|
||||
ref="searchNameInput"
|
||||
v-model="searchName"
|
||||
@keyup.enter="addPerson"
|
||||
@keyup.esc="isAddingPerson = false"
|
||||
@blur="isAddingPerson = false"
|
||||
>
|
||||
<ul
|
||||
v-if="isAddingPerson && searchResults.length"
|
||||
ref="persondroplist"
|
||||
class="person-droplist"
|
||||
>
|
||||
<li
|
||||
v-for="person in searchResults"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
class="person-circle add-person"
|
||||
@mousedown="addPerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { searchPerson } from '@/api/persons'
|
||||
|
||||
const props = defineProps({
|
||||
people: { type: Array, default: () => [] },
|
||||
})
|
||||
const emit = defineEmits(['add-person', 'remove-person'])
|
||||
|
||||
const isAddingPerson = ref(false)
|
||||
const searchName = ref('')
|
||||
const searchResults = ref([])
|
||||
|
||||
// Template refs for DOM elements
|
||||
const searchNameInput = ref(null)
|
||||
const persondroplist = ref(null)
|
||||
|
||||
async function updateSearchResults() {
|
||||
const results = await searchPerson(searchName.value)
|
||||
const idSet = new Set(props.people.map((p) => p.id))
|
||||
searchResults.value = results.filter((p) => !idSet.has(p.id))
|
||||
}
|
||||
|
||||
function addPerson(person) {
|
||||
if (!person && searchResults.value.length > 0) {
|
||||
person = searchResults.value[0]
|
||||
}
|
||||
if (person?.id >= 0 && !props.people.find((p) => p.id === person.id)) {
|
||||
emit('add-person', person)
|
||||
}
|
||||
searchName.value = ''
|
||||
searchResults.value = []
|
||||
isAddingPerson.value = false
|
||||
}
|
||||
|
||||
function removePerson(person) {
|
||||
emit('remove-person', person)
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(searchName, async () => {
|
||||
await updateSearchResults()
|
||||
})
|
||||
|
||||
watch(searchNameInput, async (el) => {
|
||||
if (el) {
|
||||
el.focus()
|
||||
await updateSearchResults()
|
||||
}
|
||||
})
|
||||
|
||||
watch([persondroplist, searchNameInput], ([drop, input]) => {
|
||||
if (drop && input) {
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
drop.style.left = `${inputRect.left}px`
|
||||
drop.style.top = `${inputRect.bottom}px`
|
||||
drop.style.width = `${inputRect.width}px`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.person-list {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
min-height: 50px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
min-height: 50px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Remove all the button styling */
|
||||
.person-circle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Show the initials of the person in a circle */
|
||||
.person-circle {
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #eee;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
/* On hover, prompt the user to click to remove the person */
|
||||
/* change opacity of circle, and use css to add a large cross over the circle */
|
||||
.remove-person:hover {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background-color: lightcoral;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background-color: lightcoral;
|
||||
}
|
||||
|
||||
/* Add a cross to the circle */
|
||||
.remove-person:hover::before, .remove-person:hover::after {
|
||||
pointer-events: none;
|
||||
content: "X";
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
.remove-person:hover::before,
|
||||
.remove-person:hover::after {
|
||||
pointer-events: none;
|
||||
content: 'X';
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.add-person {
|
||||
background-color: lightgreen;
|
||||
background-color: lightgreen;
|
||||
}
|
||||
|
||||
.add-person:hover {
|
||||
cursor: pointer;
|
||||
font-weight: bolder;
|
||||
color: white;
|
||||
background-color: green;
|
||||
cursor: pointer;
|
||||
font-weight: bolder;
|
||||
color: white;
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.person-droplist {
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: scroll;
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: scroll;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.person-droplist li {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: inline;
|
||||
padding: 1ex 1em;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: inline;
|
||||
padding: 1ex 1em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import data from '@/data.js'
|
||||
|
||||
export default {
|
||||
name: 'PersonList',
|
||||
props: {
|
||||
people: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isAddingPerson: false,
|
||||
searchName: '',
|
||||
searchResults: [],
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const searchNameInput = ref(null);
|
||||
const persondroplist = ref(null);
|
||||
return { searchNameInput, persondroplist };
|
||||
},
|
||||
watch: {
|
||||
searchName: async function() {
|
||||
await this.updateSearchResults()
|
||||
},
|
||||
searchNameInput: async function() {
|
||||
this.searchNameInput?.focus();
|
||||
await this.updateSearchResults()
|
||||
},
|
||||
persondroplist: function() {
|
||||
if (this.persondroplist && this.searchNameInput)
|
||||
{
|
||||
// Align the droplist to the input field & its size
|
||||
const inputRect = this.searchNameInput.getBoundingClientRect();
|
||||
this.persondroplist.style.left = `${inputRect.left}px`;
|
||||
this.persondroplist.style.top = `${inputRect.bottom}px`;
|
||||
this.persondroplist.style.width = `${inputRect.width}px`;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async updateSearchResults() {
|
||||
const results = await data.searchPerson(this.searchName);
|
||||
// Exclude people already in the list
|
||||
const idSet = new Set(this.people.map(p => p.id));
|
||||
this.searchResults = results.filter(p => !idSet.has(p.id));
|
||||
},
|
||||
addPerson(person) {
|
||||
if (!person && this.searchResults.length > 0)
|
||||
{
|
||||
person = this.searchResults[0];
|
||||
}
|
||||
|
||||
if (person?.id >= 0 && !this.people.find(p => p.id === person.id))
|
||||
{
|
||||
this.$emit('add-person', person);
|
||||
}
|
||||
|
||||
this.searchName = '';
|
||||
this.searchResults = [];
|
||||
this.isAddingPerson = false;
|
||||
},
|
||||
removePerson(person) {
|
||||
this.$emit('remove-person', person);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,153 +1,206 @@
|
|||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div v-if="!id && !recipe">
|
||||
<input class="recipe-link" type="text" v-model="link" placeholder="Link to Recipe" /> <br />
|
||||
<button @click="parseLink">Parse</button>
|
||||
<button @click="createFromScratch">Create from Scratch</button>
|
||||
<input
|
||||
v-model="link"
|
||||
class="recipe-link"
|
||||
type="text"
|
||||
placeholder="Link to Recipe"
|
||||
> <br>
|
||||
<button @click="parseLink">
|
||||
Parse
|
||||
</button>
|
||||
<button @click="createFromScratch">
|
||||
Create from Scratch
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="parse_failed">
|
||||
<p>Recipe not found</p>
|
||||
<p>Recipe not found</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!parse_failed && recipe">
|
||||
<div class="image-container" v-if="image_styling" :style="image_styling" ></div>
|
||||
<h1><input class="recipe-name" type="text" v-model="recipe.name" /></h1>
|
||||
<label for="recipe-serves">Number of serves: </label>
|
||||
<input type="number" v-model="recipe.serves" />
|
||||
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
|
||||
<h2>Ingredients</h2>
|
||||
<editable-ingredients-panel
|
||||
:ingredients="recipe.ingredients"
|
||||
:edit-only="true"
|
||||
@on-add="addIngredient" @on-delete="deleteIngredient" @on-update-ingredient="updateIngredient"
|
||||
/>
|
||||
<div
|
||||
v-if="image_styling"
|
||||
class="image-container"
|
||||
:style="image_styling"
|
||||
/>
|
||||
<h1>
|
||||
<input
|
||||
v-model="recipe.name"
|
||||
class="recipe-name"
|
||||
type="text"
|
||||
>
|
||||
</h1>
|
||||
<label for="recipe-serves">Number of serves: </label>
|
||||
<input
|
||||
v-model="recipe.serves"
|
||||
type="number"
|
||||
>
|
||||
<h3 class="recipe-link">
|
||||
<a :href="recipe.link">View Recipe</a>
|
||||
</h3>
|
||||
<h2>Ingredients</h2>
|
||||
<editable-ingredients-panel
|
||||
:ingredients="recipe.ingredients"
|
||||
:edit-only="true"
|
||||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
|
||||
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? "Save" : "Create" }}</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
v-if="recipe.id"
|
||||
class="delete-btn"
|
||||
@click="deleteRecipe"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
class="submit-btn"
|
||||
@click="saveRecipe"
|
||||
>
|
||||
{{ recipe.id ? 'Save' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { getRecipe, parseRecipe, saveRecipe as saveRecipeApi, deleteRecipe as deleteRecipeApi } from '@/api/recipes'
|
||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: false },
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { show: showAlert } = useAlert()
|
||||
|
||||
const link = ref(route.query.url ?? '')
|
||||
const parse_failed = ref(false)
|
||||
const recipe = ref(null)
|
||||
|
||||
const image_styling = computed(() => {
|
||||
if (recipe.value?.image_urls && recipe.value.image_urls[0]) {
|
||||
const image = recipe.value.image_urls[0]
|
||||
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
|
||||
})
|
||||
|
||||
function parseLink() {
|
||||
router.push({ path: '/recipes/add', query: { url: link.value } })
|
||||
refreshRecipe()
|
||||
}
|
||||
|
||||
async function refreshRecipe() {
|
||||
if (props.id >= 0) {
|
||||
recipe.value = await getRecipe(props.id)
|
||||
link.value = recipe.value.link
|
||||
return
|
||||
} else if (link.value) {
|
||||
recipe.value = await parseRecipe(link.value)
|
||||
parse_failed.value = !recipe.value
|
||||
} else {
|
||||
recipe.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIngredient(ingredient, newIngredient) {
|
||||
recipe.value.ingredients = recipe.value.ingredients.map((i) =>
|
||||
i == ingredient ? newIngredient : i
|
||||
)
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient) {
|
||||
recipe.value.ingredients = recipe.value.ingredients.filter((i) => i != ingredient)
|
||||
}
|
||||
|
||||
async function saveRecipe() {
|
||||
const saved = await saveRecipeApi(recipe.value)
|
||||
if (saved?.id >= 0) {
|
||||
showAlert({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' })
|
||||
router.push(`/recipes/${saved.id}`)
|
||||
return
|
||||
}
|
||||
showAlert({ heading: 'Error saving recipe', message: 'There was an error saving your recipe', type: 'error' })
|
||||
}
|
||||
|
||||
function createFromScratch() {
|
||||
recipe.value = {
|
||||
id: -1,
|
||||
name: 'My new recipe',
|
||||
created_by_id: -1,
|
||||
link: '',
|
||||
ingredients: [],
|
||||
image_urls: [],
|
||||
}
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
recipe.value.ingredients = [{ line: '', product: null }, ...recipe.value.ingredients]
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||
await deleteRecipeApi(recipe.value.id)
|
||||
router.push('/recipes')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshRecipe()
|
||||
})
|
||||
|
||||
// Keep recipe in sync if link query changes while on page
|
||||
watch(
|
||||
() => route.query.url,
|
||||
(newUrl) => {
|
||||
if (typeof newUrl === 'string') {
|
||||
link.value = newUrl
|
||||
refreshRecipe()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// expose functions for template binding names (automatic in <script setup>)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: large;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
input.recipe-link {
|
||||
width: 80%;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
max-height: 20vh;
|
||||
min-height: 20vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 20vh;
|
||||
min-height: 20vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input.recipe-name {
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.recipe-link {
|
||||
color: #0000EE;
|
||||
text-decoration: none;
|
||||
color: #0000ee;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import alert from '@/alert.js'
|
||||
import data from '@/data.js'
|
||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
id: { type: Number, optional: true }
|
||||
},
|
||||
components: { EditableIngredientsPanel },
|
||||
data() {
|
||||
return {
|
||||
link: this.$route.query.url ?? "",
|
||||
parse_failed: false,
|
||||
recipe: null,
|
||||
chefs: [],
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.refreshRecipe();
|
||||
},
|
||||
computed: {
|
||||
image_styling() {
|
||||
if (this.recipe?.image_urls && 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;
|
||||
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;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
parseLink() {
|
||||
this.$router.push({ path: '/recipes/add', query: { url: this.link }})
|
||||
this.refreshRecipe();
|
||||
},
|
||||
async refreshRecipe() {
|
||||
if (this.id >= 0) {
|
||||
this.recipe = await data.getRecipe(this.id);
|
||||
this.link = this.recipe.link;
|
||||
return;
|
||||
}
|
||||
else if (this.link) {
|
||||
this.recipe = await data.parseRecipe(this.link);
|
||||
this.parse_failed = !this.recipe;
|
||||
}
|
||||
else {
|
||||
this.recipe = null;
|
||||
}
|
||||
},
|
||||
async updateIngredient(ingredient, newIngredient) {
|
||||
this.recipe.ingredients = this.recipe.ingredients.map(i => i == ingredient ? newIngredient : i);
|
||||
},
|
||||
deleteIngredient(ingredient) {
|
||||
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient);
|
||||
},
|
||||
async saveRecipe() {
|
||||
const recipe = await data.saveRecipe(this.recipe);
|
||||
if (recipe?.id >= 0) {
|
||||
alert.show({ heading: 'Recipe saved', 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' });
|
||||
},
|
||||
async createFromScratch() {
|
||||
this.recipe = {
|
||||
id: -1,
|
||||
name: 'My new recipe',
|
||||
created_by_id: -1,
|
||||
link: '',
|
||||
ingredients: [],
|
||||
image_urls: []
|
||||
}
|
||||
},
|
||||
addIngredient() {
|
||||
this.recipe.ingredients = [{ line: '', product: null }, ...this.recipe.ingredients];
|
||||
},
|
||||
async deleteRecipe() {
|
||||
if (confirm('Are you sure you want to delete this recipe?')) {
|
||||
await data.deleteRecipe(this.recipe.id);
|
||||
this.$router.push('/recipes');
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,46 +1,51 @@
|
|||
<template>
|
||||
<div class="recipe-card">
|
||||
<p>
|
||||
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
|
||||
<img v-else src="@/assets/egg.svg" />
|
||||
</p>
|
||||
<p class="recipe-name">{{ recipe.name }}</p>
|
||||
</div>
|
||||
<div class="recipe-card">
|
||||
<p>
|
||||
<img
|
||||
v-if="recipe.image_urls"
|
||||
:src="recipe.image_urls[0]"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
src="@/assets/egg.svg"
|
||||
>
|
||||
</p>
|
||||
<p class="recipe-name">
|
||||
{{ recipe.name }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RecipeCard',
|
||||
props: ['recipe']
|
||||
}
|
||||
<script setup>
|
||||
defineProps({
|
||||
recipe: { type: Object, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.recipe-card {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
max-height: 10em;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
max-height: 10em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recipe-card p {
|
||||
margin: auto 0;
|
||||
margin-right: 1em;
|
||||
margin: auto 0;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
li img {
|
||||
display: block;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.recipe-name {
|
||||
flex: 1;
|
||||
margin: auto;
|
||||
flex: 1;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,99 +1,132 @@
|
|||
<template>
|
||||
<div class="recipe-search-box" @focusout="recipes = []">
|
||||
<input type="text" v-model="searchTerm" @keyup.enter="search" @keyup.exit="clear" @focusin="search"
|
||||
:placeholder="placeholder" />
|
||||
<ul v-if="recipes?.length" class="dropdown">
|
||||
<li class="recipe" v-for="recipe in recipes" :key="recipe.id" @mousedown="selectRecipe(recipe)">
|
||||
<recipe-card :recipe="recipe" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div
|
||||
class="recipe-search-box"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
@keyup.enter="search"
|
||||
@keyup.esc="clear"
|
||||
@focusin="search"
|
||||
>
|
||||
<ul
|
||||
v-if="recipes?.length"
|
||||
class="dropdown"
|
||||
>
|
||||
<li
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.id"
|
||||
class="recipe"
|
||||
@mousedown="selectRecipe(recipe)"
|
||||
>
|
||||
<recipe-card :recipe="recipe" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import data from '@/data.js'
|
||||
import RecipeCard from './RecipeCard.vue';
|
||||
<script setup>
|
||||
import { ref, watch, onBeforeUnmount } from 'vue'
|
||||
import { searchRecipes } from '@/api/recipes'
|
||||
import RecipeCard from './RecipeCard.vue'
|
||||
|
||||
export default {
|
||||
name: 'RecipeSearchBox',
|
||||
components: { RecipeCard },
|
||||
props: {
|
||||
placeholder: { type: String, default: 'Add a recipe...' }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searchTerm: '',
|
||||
recipes: [],
|
||||
timeouts: [],
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm() {
|
||||
const searchTerm = this.searchTerm;
|
||||
if (searchTerm) {
|
||||
this.timeouts.push(setTimeout(() => {
|
||||
if (searchTerm === this.searchTerm) {
|
||||
this.search();
|
||||
}
|
||||
}, 200));
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async search() {
|
||||
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes;
|
||||
},
|
||||
selectRecipe(recipe) {
|
||||
this.$emit('select-recipe', recipe);
|
||||
this.searchTerm = '';
|
||||
this.recipes = [];
|
||||
}
|
||||
defineProps({
|
||||
placeholder: { type: String, default: 'Add a recipe...' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select-recipe'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const recipes = ref([])
|
||||
|
||||
let debounceId = null
|
||||
|
||||
watch(
|
||||
searchTerm,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
recipes.value = []
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
return
|
||||
}
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
debounceId = setTimeout(() => {
|
||||
if (newVal === searchTerm.value) {
|
||||
search()
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
)
|
||||
|
||||
async function search() {
|
||||
const result = await searchRecipes(searchTerm.value)
|
||||
recipes.value = result ?? recipes.value
|
||||
}
|
||||
|
||||
function selectRecipe(recipe) {
|
||||
emit('select-recipe', recipe)
|
||||
searchTerm.value = ''
|
||||
recipes.value = []
|
||||
}
|
||||
|
||||
function clear() {
|
||||
searchTerm.value = ''
|
||||
recipes.value = []
|
||||
}
|
||||
|
||||
function onFocusOut() {
|
||||
recipes.value = []
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceId) clearTimeout(debounceId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recipe-search-box {
|
||||
position: relative;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recipe-search-box input {
|
||||
width: calc(100% - 2em);
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
width: calc(100% - 2em);
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.recipe-search-box .dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: scroll;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.recipe-search-box .dropdown li {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.recipe-search-box .dropdown li:last-child {
|
||||
border-bottom: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recipe-search-box .dropdown li:hover {
|
||||
background-color: #eee;
|
||||
background-color: #eee;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,18 +1,28 @@
|
|||
<template>
|
||||
<div>
|
||||
<recipe-search-box 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>
|
||||
<recipe-search-box
|
||||
placeholder="Search for a recipe..."
|
||||
@select-recipe="onSelectRecipe"
|
||||
/>
|
||||
<action-item
|
||||
title="Add new Recipe"
|
||||
:image="require('@/assets/add-recipe.svg')"
|
||||
@click="onAddRecipe"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import ActionItem from '@/components/ActionItem.vue'
|
||||
import RecipeSearchBox from './RecipeSearchBox.vue';
|
||||
import RecipeSearchBox from './RecipeSearchBox.vue'
|
||||
|
||||
export default {
|
||||
name: 'ActionsPage',
|
||||
components: {
|
||||
ActionItem,
|
||||
RecipeSearchBox
|
||||
}
|
||||
const router = useRouter()
|
||||
|
||||
function onSelectRecipe(r) {
|
||||
router.push(`/recipes/${r.id}`)
|
||||
}
|
||||
|
||||
function onAddRecipe() {
|
||||
router.push('/recipes/add')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,250 +1,257 @@
|
|||
<template>
|
||||
<h3>Full shopping list</h3>
|
||||
<h3>Full shopping list</h3>
|
||||
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="availableMeals" />
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list
|
||||
:checked="includedMeals"
|
||||
:meals="availableMeals"
|
||||
@meal-selected="mealSelected"
|
||||
@meal-unselected="mealUnselected"
|
||||
/>
|
||||
|
||||
<ul class="full-shopping-list">
|
||||
<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" />
|
||||
<ul class="full-shopping-list">
|
||||
<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" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="outstandingItemGroups.length === 0">
|
||||
<p>No items to purchase</p>
|
||||
</div>
|
||||
|
||||
<div class="purchased-slider">
|
||||
<span v-if="purchasedItemGroups.length === 0" />
|
||||
<button
|
||||
v-else-if="showPurchased"
|
||||
@click="showPurchased = false"
|
||||
>
|
||||
⏶ Hide Purchased ⏶
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="showPurchased = true"
|
||||
>
|
||||
⏷ Show Purchased ⏷
|
||||
</button>
|
||||
|
||||
<div v-if="showPurchased && purchasedItemGroups.length > 0">
|
||||
<h4>Purchased Meals</h4>
|
||||
<meal-selection-list
|
||||
:checked="includedMeals"
|
||||
:meals="purchasedMeals"
|
||||
@meal-selected="mealSelected"
|
||||
@meal-unselected="mealUnselected"
|
||||
/>
|
||||
|
||||
<h4>Purchased Items</h4>
|
||||
<ul class="full-shopping-list">
|
||||
<li
|
||||
v-for="item in purchasedItemGroups"
|
||||
:key="item.id"
|
||||
>
|
||||
<shopping-list-item :shopping-list-item-group="item" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="outstandingItemGroups.length === 0">
|
||||
<p>
|
||||
No items to purchase
|
||||
</p>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="purchased-slider">
|
||||
<span v-if="purchasedItemGroups.length === 0"></span>
|
||||
<button v-else-if="showPurchased" @click="showPurchased=false" >⏶ Hide Purchased ⏶</button>
|
||||
<button v-else @click="showPurchased=true">⏷ Show Purchased ⏷</button>
|
||||
<div
|
||||
v-if="selected.length"
|
||||
class="spacer"
|
||||
>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="showPurchased && purchasedItemGroups.length > 0">
|
||||
<h4>Purchased Meals</h4>
|
||||
<meal-selection-list @meal-selected="mealSelected" @meal-unselected="mealUnselected" :checked="includedMeals" :meals="purchasedMeals" />
|
||||
<!-- Display 'Stocked', 'Purchased' and 'Cancel' buttons in a vertical stack fixed to the bottom of the screen when any elements are selected -->
|
||||
<div
|
||||
v-if="selected.length"
|
||||
class="footer-buttons"
|
||||
>
|
||||
<p v-if="selected.length === 1">
|
||||
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
|
||||
</p>
|
||||
<p v-else>
|
||||
Mark {{ selected.length }} items as
|
||||
</p>
|
||||
|
||||
<h4>
|
||||
Purchased Items
|
||||
</h4>
|
||||
<ul class="full-shopping-list">
|
||||
<li v-for="item in purchasedItemGroups" :key="item.id">
|
||||
<shopping-list-item :shopping-list-item-group="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="spacer" v-if="selected.length">
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<p v-if="selected.length === 1">
|
||||
Mark '{{ selected[0].product?.name ?? selected[0].name }}' as
|
||||
</p>
|
||||
<p v-else>
|
||||
Mark {{ selected.length }} items as
|
||||
</p>
|
||||
|
||||
<div class="button-group">
|
||||
<button @click="markFound">
|
||||
<img src="@/assets/house-check.svg" /><br />
|
||||
Found
|
||||
</button>
|
||||
|
||||
<button @click="markPurchased">
|
||||
<img src="@/assets/shopping-cart.svg" /><br />
|
||||
Purchased
|
||||
</button>
|
||||
|
||||
<button @click="selected = []">
|
||||
<img src="@/assets/close.svg" /><br />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button @click="markFound">
|
||||
<img src="@/assets/house-check.svg"><br>
|
||||
Found
|
||||
</button>
|
||||
|
||||
<button @click="markPurchased">
|
||||
<img src="@/assets/shopping-cart.svg"><br>
|
||||
Purchased
|
||||
</button>
|
||||
|
||||
<button @click="selected = []">
|
||||
<img src="@/assets/close.svg"><br>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.full-shopping-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.full-shopping-list li {
|
||||
list-style-type: none;
|
||||
|
||||
outline: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.full-shopping-list li.selectable {
|
||||
cursor: pointer;
|
||||
outline: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.full-shopping-list li.selected {
|
||||
background-color: #f0f0f0;
|
||||
outline-width: 3px;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1ex;
|
||||
background-color: #f0f0f0;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.footer-buttons p {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
/* Display as vertical fixed to the bottom of the screen */
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
flex: 1;
|
||||
padding: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
background-color: #f0f0f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 12em;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import alert from '@/alert.js'
|
||||
|
||||
import data from '@/data.js'
|
||||
import { itemsToGroups, groupsToItems, uniqueMeals } from './shopping.js'
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onBeforeMount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
import { useShopping } from '@/composables/useShopping'
|
||||
import { getUpcomingMeals } from '@/api/meals'
|
||||
import { itemsToGroups, uniqueMeals } from './shopping.js'
|
||||
import MealSelectionList from './MealSelectionList.vue'
|
||||
import ShoppingListItem from './ShoppingListItem.vue'
|
||||
|
||||
async function saveShoppingList(outstandingItemGroups) {
|
||||
const items = groupsToItems(outstandingItemGroups);
|
||||
if (items.length === 0) {
|
||||
alert.show({ type: 'error', message: 'No items selected.' });
|
||||
return;
|
||||
}
|
||||
const router = useRouter()
|
||||
const { show: showAlert } = useAlert()
|
||||
const { getCurrentShoppingList, requestMeal, unrequestMeal, purchaseFromGroups } = useShopping()
|
||||
|
||||
return await data.purchaseShoppingList(items);
|
||||
}
|
||||
const from = new Date()
|
||||
from.setTime(0)
|
||||
const to = new Date()
|
||||
to.setDate(to.getDate() + 7)
|
||||
|
||||
const shoppingList = ref(null)
|
||||
const upcomingMeals = ref([])
|
||||
const selected = ref([])
|
||||
const showPurchased = ref(false)
|
||||
|
||||
const groupsMatch = (a, b) => {
|
||||
if (!!a.product != !!b.product) return false;
|
||||
if (a.name) return a.name === b.name;
|
||||
return a.product.id === b.product.id;
|
||||
if (!!a.product != !!b.product) return false
|
||||
if (a.name) return a.name === b.name
|
||||
return a.product.id === b.product.id
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'FullShoppingListPage',
|
||||
components: { MealSelectionList, ShoppingListItem },
|
||||
props: {
|
||||
stockTaking: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const from = new Date();
|
||||
from.setTime(0);
|
||||
const outstandingItemGroups = computed(() =>
|
||||
itemsToGroups(shoppingList.value?.outstanding_items ?? [])
|
||||
)
|
||||
const purchasedItemGroups = computed(() => itemsToGroups(shoppingList.value?.purchased_items ?? []))
|
||||
const purchasedMeals = computed(() => uniqueMeals(shoppingList.value?.purchased_items ?? []))
|
||||
const availableMeals = computed(() => {
|
||||
const meals = { ...(shoppingList.value?.meals_lookup ?? {}) }
|
||||
upcomingMeals.value?.forEach((m) => {
|
||||
if (!meals[m.id]) meals[m.id] = m
|
||||
})
|
||||
return Object.values(meals)
|
||||
.filter((m) => !m.purchase_date)
|
||||
.sort((a, b) => a.suggested_date - b.suggested_date)
|
||||
})
|
||||
const includedMeals = computed(() => shoppingList.value?.requested_meals.map((m) => m.meal) ?? [])
|
||||
|
||||
const to = new Date();
|
||||
to.setDate(to.getDate() + 7);
|
||||
|
||||
return { from, to, shoppingList: null, selected: [], showPurchased: false }
|
||||
},
|
||||
async beforeMount() {
|
||||
this.loadData();
|
||||
},
|
||||
computed: {
|
||||
outstandingItemGroups() {
|
||||
return itemsToGroups(this.shoppingList?.outstanding_items ?? []);
|
||||
},
|
||||
purchasedItemGroups() {
|
||||
return itemsToGroups(this.shoppingList?.purchased_items ?? []);
|
||||
},
|
||||
purchasedMeals() {
|
||||
return uniqueMeals(this.shoppingList?.purchased_items ?? []);
|
||||
},
|
||||
availableMeals() {
|
||||
const meals = { ...this.shoppingList?.meals_lookup ?? {} };
|
||||
this.upcomingMeals?.forEach(m => {
|
||||
if (!meals[m.id]) {
|
||||
meals[m.id] = m;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(meals).filter(m => !m.purchase_date).sort((a, b) => a.suggested_date - b.suggested_date);
|
||||
},
|
||||
includedMeals() {
|
||||
return this.shoppingList?.requested_meals.map(m => m.meal) ?? [];
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadData() {
|
||||
this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to);
|
||||
this.shoppingList = await data.getCurrentShoppingList();
|
||||
},
|
||||
async mealSelected(meal) {
|
||||
await data.requestMeal(meal.id);
|
||||
await this.loadData();
|
||||
},
|
||||
async mealUnselected(meal) {
|
||||
await data.unrequestMeal(meal.id);
|
||||
await this.loadData();
|
||||
},
|
||||
async markFound() {
|
||||
await saveShoppingList(this.selected);
|
||||
|
||||
this.selected = [];
|
||||
await this.loadData();
|
||||
},
|
||||
async markPurchased() {
|
||||
const shoppingList = await saveShoppingList(this.selected);
|
||||
|
||||
if (!shoppingList || !shoppingList.id) {
|
||||
alert.show({ type: 'error', message: 'Failed to purchase.' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.selected = [];
|
||||
this.$router.push(`/shopping/${shoppingList.id}`);
|
||||
},
|
||||
toggleSelect(item) {
|
||||
const index = this.selected.findIndex(i => groupsMatch(i, item));
|
||||
if (index === -1)
|
||||
this.selected.push(item);
|
||||
else
|
||||
this.selected.splice(index, 1);
|
||||
},
|
||||
isSelected(item) {
|
||||
return this.selected.some(i => groupsMatch(i, item));
|
||||
}
|
||||
}
|
||||
async function loadData() {
|
||||
upcomingMeals.value = await getUpcomingMeals(from, to)
|
||||
shoppingList.value = await getCurrentShoppingList()
|
||||
}
|
||||
|
||||
async function mealSelected(meal) {
|
||||
await requestMeal(meal.id)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function mealUnselected(meal) {
|
||||
await unrequestMeal(meal.id)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function markFound() {
|
||||
const result = await purchaseFromGroups(selected.value)
|
||||
if (!result) {
|
||||
showAlert({ type: 'error', message: 'No items selected.' })
|
||||
return
|
||||
}
|
||||
selected.value = []
|
||||
await loadData()
|
||||
}
|
||||
|
||||
async function markPurchased() {
|
||||
const shopping = await purchaseFromGroups(selected.value)
|
||||
if (!shopping || !shopping.id) {
|
||||
showAlert({ type: 'error', message: 'Failed to purchase.' })
|
||||
return
|
||||
}
|
||||
selected.value = []
|
||||
router.push(`/shopping/${shopping.id}`)
|
||||
}
|
||||
|
||||
function toggleSelect(item) {
|
||||
const index = selected.value.findIndex((i) => groupsMatch(i, item))
|
||||
if (index === -1) selected.value.push(item)
|
||||
else selected.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function isSelected(item) {
|
||||
return selected.value.some((i) => groupsMatch(i, item))
|
||||
}
|
||||
|
||||
onBeforeMount(loadData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.full-shopping-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.full-shopping-list li {
|
||||
list-style-type: none;
|
||||
|
||||
outline: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.full-shopping-list li.selectable {
|
||||
cursor: pointer;
|
||||
outline: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.full-shopping-list li.selected {
|
||||
background-color: #f0f0f0;
|
||||
outline-width: 3px;
|
||||
}
|
||||
|
||||
.footer-buttons {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 1ex;
|
||||
background-color: #f0f0f0;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.footer-buttons p {
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
/* Display as vertical fixed to the bottom of the screen */
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
flex: 1;
|
||||
padding: 1em;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
background-color: #f0f0f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button img {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 12em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,160 +1,149 @@
|
|||
<template>
|
||||
|
||||
<ul>
|
||||
<li v-for="meal in meals" :key="meal.id">
|
||||
<!-- Have a checkbox and card for each meal, show the image and name -->
|
||||
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
||||
<input type="checkbox" :id="meal.id" :checked="isChecked(meal)" @change="mealCheckChanged" :disabled="disabled" />
|
||||
<label :for="meal.id" :style="getImageStyling(meal)">
|
||||
{{ formatDate(meal.suggested_date) }}
|
||||
</label>
|
||||
<ul>
|
||||
<li
|
||||
v-for="meal in meals"
|
||||
:key="meal.id"
|
||||
>
|
||||
<!-- Have a checkbox and card for each meal, show the image and name -->
|
||||
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
||||
<input
|
||||
:id="meal.id"
|
||||
type="checkbox"
|
||||
:checked="isChecked(meal)"
|
||||
:disabled="disabled"
|
||||
@change="mealCheckChanged"
|
||||
>
|
||||
<label
|
||||
:for="meal.id"
|
||||
:style="getImageStyling(meal)"
|
||||
>
|
||||
{{ formatDate(meal.suggested_date) }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
meals: { type: Array, required: true },
|
||||
checked: { type: Array, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['meal-selected', 'meal-unselected'])
|
||||
|
||||
const nth = (d) => {
|
||||
if (d > 3 && d < 21) return 'th'
|
||||
switch (d % 10) {
|
||||
case 1:
|
||||
return 'st'
|
||||
case 2:
|
||||
return 'nd'
|
||||
case 3:
|
||||
return 'rd'
|
||||
default:
|
||||
return 'th'
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date) =>
|
||||
`${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
||||
|
||||
const getUrl = (meal) => {
|
||||
// First non empty value in meal.recipe/image_urls
|
||||
for (const mr of meal.recipes) {
|
||||
const recipe = mr.recipe
|
||||
if (recipe.image_urls.length && recipe.image_urls[0]) {
|
||||
return recipe.image_urls[0]
|
||||
}
|
||||
}
|
||||
|
||||
// First meal.extra_ingredient with a product with an image
|
||||
for (const ingredient of meal.extra_ingredients) {
|
||||
if (ingredient.product) {
|
||||
if (ingredient.product.img_large) return ingredient.product.img_large
|
||||
if (ingredient.product.img_small) return ingredient.product.img_small
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getImageStyling(meal) {
|
||||
const imageUrl = getUrl(meal)
|
||||
if (!imageUrl) return null
|
||||
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`,
|
||||
}
|
||||
}
|
||||
|
||||
function mealCheckChanged(event) {
|
||||
const mealId = parseInt(event.target.id)
|
||||
const meal = props.meals.find((m) => m.id === mealId)
|
||||
if (event.target.checked) emit('meal-selected', meal)
|
||||
else emit('meal-unselected', meal)
|
||||
}
|
||||
|
||||
function isChecked(meal) {
|
||||
return props.checked.some((m) => m.id === meal.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
ul {
|
||||
padding: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
list-style-type: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
padding: none;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
list-style-type: none;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
padding: none;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hide the default checkbox formatting, and format the card instead */
|
||||
input[type="checkbox"] {
|
||||
display: none;
|
||||
input[type='checkbox'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
padding: 0.1vh 0.3em;
|
||||
/* Help the visibility of the text over the image */
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 0.5em;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
color: #3d5447;
|
||||
display: inline-block;
|
||||
padding: 0.1vh 0.3em;
|
||||
/* Help the visibility of the text over the image */
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 0.5em;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
color: #3d5447;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked + label {
|
||||
border: 3px solid #3d5447;
|
||||
text-shadow: #ccc 0 0 0.1em;
|
||||
input[type='checkbox']:checked + label {
|
||||
border: 3px solid #3d5447;
|
||||
text-shadow: #ccc 0 0 0.1em;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:disabled + label {
|
||||
cursor: not-allowed;
|
||||
input[type='checkbox']:disabled + label {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Show the image as the background image of the card */
|
||||
.meal-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Position the text in the center of the card */
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: larger;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: larger;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
const nth = (d) => {
|
||||
if (d > 3 && d < 21) return 'th';
|
||||
switch (d % 10) {
|
||||
case 1: return "st";
|
||||
case 2: return "nd";
|
||||
case 3: return "rd";
|
||||
default: return "th";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
return `${date.toLocaleDateString('en-AU', { weekday: 'short' })} ${date.getDate()}${nth(date.getDate())}`
|
||||
}
|
||||
|
||||
const getUrl = (meal) => {
|
||||
// First non empty value in meal.recipe/image_urls
|
||||
for (const mr of meal.recipes) {
|
||||
const recipe = mr.recipe;
|
||||
if (recipe.image_urls.length && recipe.image_urls[0]) {
|
||||
return recipe.image_urls[0];
|
||||
}
|
||||
}
|
||||
|
||||
// First meal.extra_ingredient with a product with an image
|
||||
for (const ingredient of meal.extra_ingredients) {
|
||||
if (ingredient.product) {
|
||||
if (ingredient.product.img_large) {
|
||||
return ingredient.product.img_large;
|
||||
}
|
||||
|
||||
if (ingredient.product.img_small) {
|
||||
return ingredient.product.img_small;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'MealSelectionList',
|
||||
props: {
|
||||
meals: Array,
|
||||
checked: Array,
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
formatDate,
|
||||
getImageStyling(meal) {
|
||||
const imageUrl = getUrl(meal);
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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`};
|
||||
},
|
||||
mealCheckChanged(event) {
|
||||
const mealId = parseInt(event.target.id);
|
||||
const meal = this.meals.find(m => m.id === mealId);
|
||||
if (event.target.checked) {
|
||||
this.$emit('meal-selected', meal);
|
||||
} else {
|
||||
this.$emit('meal-unselected', meal);
|
||||
}
|
||||
},
|
||||
isChecked(meal) {
|
||||
for (const checkedMeal of this.checked) {
|
||||
if (checkedMeal.id === meal.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,11 +1,19 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>My Shopping List</h1>
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>My Shopping List</h1>
|
||||
<router-link :to="`/shopping/current`">
|
||||
Full Shopping List
|
||||
</router-link>
|
||||
<editable-ingredients-panel
|
||||
:ingredients="ingredients"
|
||||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
@on-editing="onEditing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<!--
|
||||
# Functions
|
||||
* Add a random item to next shop
|
||||
* Add meals to next shop
|
||||
|
|
@ -31,53 +39,52 @@
|
|||
-->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import data from '@/data.js'
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { useShopping } from '@/composables/useShopping'
|
||||
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
|
||||
|
||||
export default {
|
||||
name: 'MyShoppingpage',
|
||||
components: { EditableIngredientsPanel },
|
||||
data() {
|
||||
return { ingredients: [], person: null }
|
||||
},
|
||||
async beforeMount() {
|
||||
const person = await data.currentUser();
|
||||
if (!person)
|
||||
return this.$router.push({ name: 'login' });
|
||||
const router = useRouter()
|
||||
const { loadUser } = useAuth()
|
||||
const { getMyShoppingList, saveMyShoppingList } = useShopping()
|
||||
|
||||
this.person = person;
|
||||
await this.updateShoppingList();
|
||||
},
|
||||
methods: {
|
||||
async updateShoppingList(save = false) {
|
||||
const new_ingredients = save ?
|
||||
await data.saveMyShoppingList(this.ingredients) :
|
||||
await data.getMyShoppingList();
|
||||
const person = ref(null)
|
||||
const ingredients = ref([])
|
||||
|
||||
this.ingredients = new_ingredients;
|
||||
},
|
||||
addIngredient() {
|
||||
this.ingredients = [{ id: -1 }, ...this.ingredients];
|
||||
},
|
||||
deleteIngredient(ingredient) {
|
||||
this.ingredients = this.ingredients.filter(i => i !== ingredient);
|
||||
},
|
||||
updateIngredient(oldIngredient, newIngredient) {
|
||||
this.ingredients = this.ingredients.map(source => source === oldIngredient ? newIngredient : source);
|
||||
},
|
||||
async onEditing(isStartingEdit) {
|
||||
await this.updateShoppingList(!isStartingEdit);
|
||||
|
||||
if (isStartingEdit && this.ingredients.length === 0) {
|
||||
this.addIngredient();
|
||||
}
|
||||
}
|
||||
}
|
||||
async function updateShoppingList(save = false) {
|
||||
const newIngredients = save
|
||||
? await saveMyShoppingList(ingredients.value)
|
||||
: await getMyShoppingList()
|
||||
ingredients.value = newIngredients
|
||||
}
|
||||
|
||||
function addIngredient() {
|
||||
ingredients.value = [{ id: -1 }, ...ingredients.value]
|
||||
}
|
||||
|
||||
function deleteIngredient(ingredient) {
|
||||
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
|
||||
}
|
||||
|
||||
function updateIngredient(oldIngredient, newIngredient) {
|
||||
ingredients.value = ingredients.value.map((source) =>
|
||||
source === oldIngredient ? newIngredient : source
|
||||
)
|
||||
}
|
||||
|
||||
async function onEditing(isStartingEdit) {
|
||||
await updateShoppingList(!isStartingEdit)
|
||||
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
|
||||
}
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const u = await loadUser()
|
||||
if (!u) return router.push({ name: 'login' })
|
||||
person.value = u
|
||||
await updateShoppingList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -1,70 +1,61 @@
|
|||
<template>
|
||||
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
||||
<h3>Purchased {{ shoppingList ? ago(shoppingList.created_date) : '' }}</h3>
|
||||
|
||||
<div v-if="includedMeals.length > 0">
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
||||
</div>
|
||||
<div v-if="includedMeals.length > 0">
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list
|
||||
:checked="includedMeals"
|
||||
:meals="includedMeals"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="full-shopping-list">
|
||||
<li v-for="item in listByProduct" :key="item.id">
|
||||
<shopping-list-item :shopping-list-item-group="item" />
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="full-shopping-list">
|
||||
<li
|
||||
v-for="item in listByProduct"
|
||||
:key="item.id"
|
||||
>
|
||||
<shopping-list-item :shopping-list-item-group="item" />
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.full-shopping-list li {
|
||||
list-style-type: none;
|
||||
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.full-shopping-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onBeforeMount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ago } from '@/dateformats.js'
|
||||
|
||||
import data from '@/data.js'
|
||||
import { getShoppingList } from '@/api/shopping'
|
||||
import { itemsToGroups, uniqueMeals } from './shopping.js'
|
||||
|
||||
import MealSelectionList from './MealSelectionList.vue'
|
||||
import ShoppingListItem from './ShoppingListItem.vue'
|
||||
|
||||
export default {
|
||||
name: 'FullShoppingListPage',
|
||||
components: { MealSelectionList, ShoppingListItem },
|
||||
props: {
|
||||
id: [String, Number]
|
||||
},
|
||||
computed: {
|
||||
includedMeals() {
|
||||
if (!this.shoppingList) return [];
|
||||
return uniqueMeals(this.shoppingList.items);
|
||||
},
|
||||
listByProduct() {
|
||||
return this.shoppingList ? itemsToGroups(this.shoppingList.items) : [];
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shoppingList: null,
|
||||
}
|
||||
},
|
||||
async beforeMount() {
|
||||
this.shoppingList = await data.getShoppingList(this.id);
|
||||
},
|
||||
methods: {
|
||||
ago
|
||||
}
|
||||
const route = useRoute()
|
||||
const shoppingList = ref(null)
|
||||
|
||||
const includedMeals = computed(() =>
|
||||
shoppingList.value ? uniqueMeals(shoppingList.value.items) : []
|
||||
)
|
||||
const listByProduct = computed(() =>
|
||||
shoppingList.value ? itemsToGroups(shoppingList.value.items) : []
|
||||
)
|
||||
|
||||
onBeforeMount(async () => {
|
||||
const idParam = route.params.id
|
||||
const id = typeof idParam === 'string' ? parseInt(idParam) : idParam
|
||||
shoppingList.value = await getShoppingList(id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.full-shopping-list li {
|
||||
list-style-type: none;
|
||||
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
</script>
|
||||
.full-shopping-list {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,174 +1,204 @@
|
|||
<template>
|
||||
|
||||
<!-- 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">
|
||||
<img :src="`${ shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg') }`" class="product-image" />
|
||||
<!-- 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">
|
||||
<img
|
||||
:src="`${shoppingListItemGroup.product?.img_small ?? require('@/assets/missing-product.svg')}`"
|
||||
class="product-image"
|
||||
>
|
||||
<div class="product-details">
|
||||
<h3 class="header">
|
||||
<strong>
|
||||
<a v-if="shoppingListItemGroup.product?.link" :href="shoppingListItemGroup.product?.link">{{ shoppingListItemGroup.product?.name }}</a>
|
||||
<span v-else>{{ shoppingListItemGroup.name }}</span>
|
||||
</strong>,
|
||||
<small>
|
||||
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
|
||||
<span v-if="index">, </span>
|
||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||
</span>
|
||||
<span class="found-marker partial" v-if="purchased.length > 0">✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||
</small>
|
||||
</h3>
|
||||
<p class="sources" v-if="required.length > 0">
|
||||
<strong>Need: </strong>
|
||||
<span v-for="(source, index) in required" :key="source.id">
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||
</span>
|
||||
<span v-else-if="source.recipe">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
||||
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ 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 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>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="purchased.length > 0">
|
||||
<strong>Already found or purchased: </strong>
|
||||
<span v-for="(source, index) in purchased" :key="source.id">
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for {{ source.person.name }}
|
||||
</span>
|
||||
<span v-else-if="source.recipe">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }}
|
||||
in <router-link :to="`/recipes/${ source.recipe.id }/`">{{ 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 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>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<h3 class="header">
|
||||
<strong>
|
||||
<a
|
||||
v-if="shoppingListItemGroup.product?.link"
|
||||
:href="shoppingListItemGroup.product?.link"
|
||||
>{{ shoppingListItemGroup.product?.name }}</a>
|
||||
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong>,
|
||||
<small>
|
||||
<span
|
||||
v-for="(total, index) in remainingRequiredTotals"
|
||||
:key="total.id"
|
||||
>
|
||||
<span v-if="index">, </span>
|
||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="purchased.length > 0"
|
||||
class="found-marker partial"
|
||||
>✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||
</small>
|
||||
</h3>
|
||||
<p
|
||||
v-if="required.length > 0"
|
||||
class="sources"
|
||||
>
|
||||
<strong>Need: </strong>
|
||||
<span
|
||||
v-for="(source, index) in required"
|
||||
:key="source.id"
|
||||
>
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for
|
||||
{{ source.person.name }}
|
||||
</span>
|
||||
<span v-else-if="source.recipe">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
||||
<router-link :to="`/recipes/${source.recipe.id}/`">{{
|
||||
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 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>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="purchased.length > 0">
|
||||
<strong>Already found or purchased: </strong>
|
||||
<span
|
||||
v-for="(source, index) in purchased"
|
||||
:key="source.id"
|
||||
>
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for
|
||||
{{ source.person.name }}
|
||||
</span>
|
||||
<span v-else-if="source.recipe">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} in
|
||||
<router-link :to="`/recipes/${source.recipe.id}/`">{{
|
||||
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 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>
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ago } from '@/dateformats.js'
|
||||
import { calculateTotals } from '@/units.js'
|
||||
|
||||
const props = defineProps({
|
||||
// { product: { ... }, OR name: 'string', shoppingListItems: [...] }
|
||||
shoppingListItemGroup: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const remainingRequiredTotals = computed(() =>
|
||||
calculateTotals(props.shoppingListItemGroup.shoppingListItems.map((item) => item.ingredient))
|
||||
)
|
||||
|
||||
const required = computed(() =>
|
||||
props.shoppingListItemGroup.shoppingListItems.filter((item) => !item.list_id)
|
||||
)
|
||||
|
||||
const purchased = computed(() =>
|
||||
props.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
|
||||
)
|
||||
|
||||
const lastPurchased = computed(() => {
|
||||
const purchasedItems = props.shoppingListItemGroup.shoppingListItems.filter((item) => item.list_id)
|
||||
if (purchasedItems.length === 0) return null
|
||||
return purchasedItems.reduce((latest, item) => {
|
||||
const itemDate = item?.meal?.suggested_date || item?.created_at
|
||||
return !latest || (itemDate && itemDate > latest) ? itemDate : latest
|
||||
}, null)
|
||||
})
|
||||
|
||||
function getFriendlyDate(date) {
|
||||
if (!date) return ''
|
||||
return ago(date)
|
||||
}
|
||||
|
||||
function formatQuantity(quantity) {
|
||||
const log10 = Math.log10(quantity)
|
||||
if (log10 < 0) return quantity.toPrecision(2)
|
||||
if (log10 < 1) return quantity.toFixed(1)
|
||||
return quantity.toFixed(0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Show the product image to the left, then the product name and size to the right */
|
||||
|
||||
.shopping-list-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
max-width: 5em;
|
||||
max-height: 4em;
|
||||
margin-right: 1em;
|
||||
max-width: 5em;
|
||||
max-height: 4em;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.product-details {
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
flex-grow: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.found-marker {
|
||||
margin: 1ex;
|
||||
padding-top: 0.6ex;
|
||||
padding-bottom: 0.5ex;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
border-radius: 25px;
|
||||
margin: 1ex;
|
||||
padding-top: 0.6ex;
|
||||
padding-bottom: 0.5ex;
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
border-radius: 25px;
|
||||
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-size: smaller;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-size: smaller;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.found-marker.found {
|
||||
background-color: green;
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.found-marker.partial {
|
||||
background-color: darkgoldenrod;
|
||||
background-color: darkgoldenrod;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<script>
|
||||
|
||||
import { ago } from '@/dateformats.js';
|
||||
import { calculateTotals } from '@/units.js';
|
||||
|
||||
export default {
|
||||
name: 'ShoppingListItem',
|
||||
props: ['shoppingListItemGroup' ], // { product: { ... }, OR name: 'string', shoppingListItems: { person, ingredient, list_id?, meal? }} where list_id is null if not yet purchased
|
||||
data() {
|
||||
return {
|
||||
expanded: false,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
remainingRequiredTotals() {
|
||||
return calculateTotals(this.shoppingListItemGroup.shoppingListItems.map(item => item.ingredient));
|
||||
},
|
||||
expectedExistingTotals() {
|
||||
const purchasedNotEaten = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id && !(item?.meal?.consumed_date));
|
||||
|
||||
return calculateTotals(purchasedNotEaten.map(item => item.ingredient));
|
||||
},
|
||||
required() {
|
||||
return this.shoppingListItemGroup.shoppingListItems.filter(item => !item.list_id);
|
||||
},
|
||||
purchased() {
|
||||
return this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
|
||||
},
|
||||
lastPurchased() {
|
||||
const purchasedItems = this.shoppingListItemGroup.shoppingListItems.filter(item => item.list_id);
|
||||
if (purchasedItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// Find the most recently purchased item
|
||||
return purchasedItems.reduce((latest, item) => {
|
||||
const itemDate = item?.meal?.suggested_date || item?.created_at;
|
||||
return (!latest || (itemDate && itemDate > latest)) ? itemDate : latest;
|
||||
}, null);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFriendlyDate(date) {
|
||||
if (!date)
|
||||
return '';
|
||||
|
||||
return ago(date);
|
||||
},
|
||||
formatQuantity(quantity) {
|
||||
const log10 = Math.log10(quantity);
|
||||
if (log10 < 0) {
|
||||
return quantity.toPrecision(2);
|
||||
}
|
||||
else if (log10 < 1) {
|
||||
return quantity.toFixed(1);
|
||||
}
|
||||
else {
|
||||
return quantity.toFixed(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -1,45 +1,42 @@
|
|||
export function groupsToItems(groups) {
|
||||
return groups.map(group => group.shoppingListItems).flat();
|
||||
return groups.map((group) => group.shoppingListItems).flat()
|
||||
}
|
||||
|
||||
export function uniqueMeals(shoppingListItems) {
|
||||
const mealsWithDuplicates = shoppingListItems
|
||||
.map(item => item.meal)
|
||||
.filter(m => m);
|
||||
const mealsWithDuplicates = shoppingListItems.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) {
|
||||
const ingredients_by_product_id = {};
|
||||
const ingredients_by_name = {};
|
||||
for (const item of shoppingListItems) {
|
||||
if (item.ingredient.product) {
|
||||
let group = ingredients_by_product_id[item.ingredient.product.id];
|
||||
if (!group) {
|
||||
group = ingredients_by_product_id[item.ingredient.product.id] = {
|
||||
product: item.ingredient.product,
|
||||
shoppingListItems: []
|
||||
};
|
||||
}
|
||||
group.shoppingListItems.push(item);
|
||||
const ingredients_by_product_id = {}
|
||||
const ingredients_by_name = {}
|
||||
for (const item of shoppingListItems) {
|
||||
if (item.ingredient.product) {
|
||||
let group = ingredients_by_product_id[item.ingredient.product.id]
|
||||
if (!group) {
|
||||
group = ingredients_by_product_id[item.ingredient.product.id] = {
|
||||
product: item.ingredient.product,
|
||||
shoppingListItems: [],
|
||||
}
|
||||
else {
|
||||
let group = ingredients_by_name[item.ingredient.name];
|
||||
if (!group) {
|
||||
group = ingredients_by_name[item.ingredient.name] = {
|
||||
name: item.ingredient.name,
|
||||
shoppingListItems: []
|
||||
};
|
||||
}
|
||||
group.shoppingListItems.push(item);
|
||||
}
|
||||
group.shoppingListItems.push(item)
|
||||
} else {
|
||||
let group = ingredients_by_name[item.ingredient.name]
|
||||
if (!group) {
|
||||
group = ingredients_by_name[item.ingredient.name] = {
|
||||
name: item.ingredient.name,
|
||||
shoppingListItems: [],
|
||||
}
|
||||
}
|
||||
group.shoppingListItems.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...Object.values(ingredients_by_product_id),
|
||||
...Object.values(ingredients_by_name)
|
||||
]
|
||||
return [...Object.values(ingredients_by_product_id), ...Object.values(ingredients_by_name)]
|
||||
}
|
||||
27
src/composables/useAlert.js
Normal file
27
src/composables/useAlert.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
// Singleton reactive alert state for the app
|
||||
const current = ref(null)
|
||||
let timeoutId = null
|
||||
|
||||
function show(message) {
|
||||
// message: { heading, message, type: 'success' | 'error' | 'info' }
|
||||
current.value = { ...message, _ts: Date.now() }
|
||||
}
|
||||
|
||||
function clear() {
|
||||
current.value = null
|
||||
}
|
||||
|
||||
function scheduleAutoDismiss(ms = 5000) {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
if (!current.value) return
|
||||
timeoutId = setTimeout(() => {
|
||||
clear()
|
||||
timeoutId = null
|
||||
}, ms)
|
||||
}
|
||||
|
||||
export function useAlert() {
|
||||
return { current, show, clear, scheduleAutoDismiss }
|
||||
}
|
||||
22
src/composables/useAuth.js
Normal file
22
src/composables/useAuth.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { ref } from 'vue'
|
||||
import { currentUser as apiCurrentUser, login as apiLogin } from '@/api/auth'
|
||||
|
||||
const user = ref(null)
|
||||
let initialized = false
|
||||
|
||||
export async function loadUser() {
|
||||
if (!initialized) {
|
||||
user.value = await apiCurrentUser()
|
||||
initialized = true
|
||||
}
|
||||
return user.value
|
||||
}
|
||||
|
||||
export async function login(username) {
|
||||
user.value = await apiLogin(username)
|
||||
return user.value
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return { user, loadUser, login }
|
||||
}
|
||||
25
src/composables/useMeals.js
Normal file
25
src/composables/useMeals.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import * as api from '@/api/meals'
|
||||
|
||||
export async function getUpcomingMeals(from, to) {
|
||||
return api.getUpcomingMeals(from, to)
|
||||
}
|
||||
|
||||
export async function getMeal(id) {
|
||||
return api.getMeal(id)
|
||||
}
|
||||
|
||||
export async function saveMeal(meal) {
|
||||
return api.saveMeal(meal)
|
||||
}
|
||||
|
||||
export async function markMealConsumed(mealId) {
|
||||
return api.markMealConsumed(mealId)
|
||||
}
|
||||
|
||||
export async function deleteMeal(mealId) {
|
||||
return api.deleteMeal(mealId)
|
||||
}
|
||||
|
||||
export function useMeals() {
|
||||
return { getUpcomingMeals, getMeal, saveMeal, markMealConsumed, deleteMeal }
|
||||
}
|
||||
27
src/composables/useShopping.js
Normal file
27
src/composables/useShopping.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {
|
||||
getCurrentShoppingList,
|
||||
getShoppingList,
|
||||
purchaseShoppingList,
|
||||
requestMeal,
|
||||
unrequestMeal,
|
||||
getMyShoppingList,
|
||||
saveMyShoppingList,
|
||||
} from '@/api/shopping'
|
||||
import { groupsToItems } from '@/components/shopping/shopping.js'
|
||||
|
||||
export function useShopping() {
|
||||
return {
|
||||
getCurrentShoppingList,
|
||||
getShoppingList,
|
||||
purchaseShoppingList,
|
||||
requestMeal,
|
||||
unrequestMeal,
|
||||
getMyShoppingList,
|
||||
saveMyShoppingList,
|
||||
async purchaseFromGroups(groups) {
|
||||
const items = groupsToItems(groups)
|
||||
if (!items?.length) return null
|
||||
return purchaseShoppingList(items)
|
||||
},
|
||||
}
|
||||
}
|
||||
332
src/data.js
332
src/data.js
|
|
@ -1,332 +0,0 @@
|
|||
const BASE_URL = window.location.href.replace(/^(https?:\/\/[^/]+).*/, "$1/api");
|
||||
|
||||
const datesToFix = {
|
||||
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) }) },
|
||||
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) => {
|
||||
if (!obj) return;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
fixDates(item, type);
|
||||
}
|
||||
}
|
||||
|
||||
const toFix = datesToFix[type];
|
||||
if (!toFix) return;
|
||||
|
||||
if (toFix.fields) {
|
||||
for (const field of toFix.fields) {
|
||||
if (obj[field]) {
|
||||
obj[field] = new Date(obj[field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFix.dependants) {
|
||||
for (const [key, value] of Object.entries(toFix.dependants(obj))) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
fixDates(item, key);
|
||||
}
|
||||
}
|
||||
else {
|
||||
fixDates(value, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const setPurchasedShoppingListReferences = (purchasedShoppingList) => {
|
||||
if (!purchasedShoppingList) return;
|
||||
const { ingredients_lookup, meals_lookup, recipes_lookup, } = purchasedShoppingList;
|
||||
const shopping_list_lookup = { [purchasedShoppingList.list.id]: purchasedShoppingList.list };
|
||||
setShoppingListItemReferences(purchasedShoppingList.list.items, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
|
||||
}
|
||||
|
||||
const setCurrentShoppingListReferences = (currentShoppingList) => {
|
||||
if (!currentShoppingList) return;
|
||||
const { ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup } = currentShoppingList;
|
||||
const allShoppingListItems = [...currentShoppingList.outstanding_items, ...currentShoppingList.requested_meals, ...currentShoppingList.purchased_items];
|
||||
|
||||
setShoppingListItemReferences(allShoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup);
|
||||
}
|
||||
|
||||
const setShoppingListItemReferences = (shoppingListItems, ingredients_lookup, meals_lookup, recipes_lookup, shopping_list_lookup) => {
|
||||
if (!shoppingListItems) return;
|
||||
|
||||
for (const item of shoppingListItems) {
|
||||
if (item.ingredient_id) {
|
||||
item.ingredient = ingredients_lookup[item.ingredient_id];
|
||||
}
|
||||
if (item.meal_id) {
|
||||
item.meal = meals_lookup[item.meal_id];
|
||||
}
|
||||
if (item.list_id) {
|
||||
item.list = shopping_list_lookup[item.list_id];
|
||||
}
|
||||
if (item.recipe_id) {
|
||||
item.recipe = recipes_lookup[item.recipe_id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let user = null;
|
||||
export default {
|
||||
async markMealConsumed(meal_id) {
|
||||
const response = await fetch(BASE_URL + `/meals/${encodeURIComponent(meal_id)}/consumed`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const meal = await response.json();
|
||||
fixDates(meal, "Meal");
|
||||
|
||||
return meal;
|
||||
},
|
||||
async getUpcomingMeals(from, to) {
|
||||
const response = await fetch(BASE_URL + "/meals/upcoming?from=" + from.toISOString() + "&to=" + to.toISOString());
|
||||
const meals = await response.json();
|
||||
fixDates(meals, "Meal");
|
||||
|
||||
return meals.sort((a, b) => a.suggested_date - b.suggested_date);
|
||||
},
|
||||
async getMeal(id) {
|
||||
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`);
|
||||
const meal = await response.json();
|
||||
fixDates(meal, "Meal");
|
||||
|
||||
return meal;
|
||||
},
|
||||
async deleteMeal(id) {
|
||||
var response = await fetch(BASE_URL + `/meals/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
async searchRecipes(query) {
|
||||
const response = await fetch(BASE_URL + "/recipes?q=" + encodeURIComponent(query));
|
||||
const recipes = await response.json();
|
||||
fixDates(recipes, "Recipe");
|
||||
|
||||
return recipes;
|
||||
},
|
||||
async parseRecipe(url) {
|
||||
const response = await fetch(BASE_URL + `/recipes/parse?url=${encodeURIComponent(url)}`, { credentials: "include" });
|
||||
const recipe = await response.json();
|
||||
fixDates(recipe, "Recipe");
|
||||
|
||||
return recipe;
|
||||
},
|
||||
async getRecipe(id) {
|
||||
const response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`);
|
||||
const recipe = await response.json();
|
||||
fixDates(recipe, "Recipe");
|
||||
|
||||
return recipe;
|
||||
},
|
||||
async parseProduct(ingredient, url) {
|
||||
const body = {
|
||||
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();
|
||||
fixDates(saved, "Meal");
|
||||
|
||||
return saved;
|
||||
},
|
||||
async currentUser() {
|
||||
if (user) {
|
||||
return user;
|
||||
}
|
||||
|
||||
var cookie = decodeURIComponent(document.cookie).split(";").find(cookie => cookie.trimStart().startsWith("user_id="));
|
||||
if (cookie) {
|
||||
const response = await fetch(BASE_URL + "/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
user = await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
async login(username) {
|
||||
const response = await fetch(BASE_URL + "/auth/login", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
user = await response.json();
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
async deleteRecipe(id) {
|
||||
var response = await fetch(BASE_URL + `/recipes/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
const recipe = await response.json();
|
||||
fixDates(recipe, "Recipe");
|
||||
return recipe;
|
||||
},
|
||||
async searchPerson(name) {
|
||||
const response = await fetch(BASE_URL + "/persons?q=" + encodeURIComponent(name));
|
||||
return await response.json();
|
||||
},
|
||||
async getMyShoppingList() {
|
||||
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", { credentials: "include" });
|
||||
const ingredients = await response.json();
|
||||
fixDates(ingredients, 'Ingredient');
|
||||
|
||||
return ingredients;
|
||||
},
|
||||
async saveMyShoppingList(list) {
|
||||
const response = await fetch(BASE_URL + "/shopping/current/me/ingredients", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(list),
|
||||
});
|
||||
|
||||
const ingredients = await response.json();
|
||||
fixDates(ingredients, 'Ingredient');
|
||||
|
||||
return ingredients;
|
||||
},
|
||||
async getShoppingList(id) {
|
||||
const response = await fetch(BASE_URL + `/shopping/${encodeURIComponent(id)}`);
|
||||
|
||||
const purchasedShoppingList = await response.json();
|
||||
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
||||
setPurchasedShoppingListReferences(purchasedShoppingList);
|
||||
|
||||
return purchasedShoppingList.list;
|
||||
},
|
||||
async getCurrentShoppingList() {
|
||||
const response = await fetch(BASE_URL + "/shopping/current");
|
||||
|
||||
const lst = await response.json();
|
||||
fixDates(lst, "CurrentShoppingList");
|
||||
setCurrentShoppingListReferences(lst);
|
||||
|
||||
return lst;
|
||||
},
|
||||
async purchaseShoppingList(completed_requests) {
|
||||
const response = await fetch(BASE_URL + "/shopping/", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ items: completed_requests }),
|
||||
});
|
||||
|
||||
const purchasedShoppingList = await response.json();
|
||||
fixDates(purchasedShoppingList, "PurchasedShoppingList");
|
||||
setPurchasedShoppingListReferences(purchasedShoppingList);
|
||||
|
||||
return purchasedShoppingList.list;
|
||||
},
|
||||
async requestMeal(meal_id) {
|
||||
const response = await fetch(BASE_URL + "/shopping/current/meals/me", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ meal_id }),
|
||||
});
|
||||
|
||||
const requests = await response.json();
|
||||
fixDates(requests, "ShoppingListItem");
|
||||
|
||||
return requests;
|
||||
},
|
||||
async unrequestMeal(meal_id) {
|
||||
const response = await fetch(BASE_URL + `/shopping/current/meals/${encodeURIComponent(meal_id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to unrequest meal");
|
||||
}
|
||||
},
|
||||
async getPersonsInHome() {
|
||||
const response = await fetch(BASE_URL + "/persons");
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +1,25 @@
|
|||
function plural(num, unit) {
|
||||
num = Math.floor(num);
|
||||
return num + " " + unit + (num === 1 ? "" : "s");
|
||||
num = Math.floor(num)
|
||||
return num + ' ' + unit + (num === 1 ? '' : 's')
|
||||
}
|
||||
|
||||
export function ago(date) {
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
if (diff < 1000) {
|
||||
return "just now";
|
||||
}
|
||||
if (diff < 60 * 1000) {
|
||||
return plural(diff / 1000, "second") + " ago";
|
||||
}
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
return plural(diff / (60 * 1000), "minute") + " ago";
|
||||
}
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
return plural(diff / (60 * 60 * 1000), "hour") + " ago";
|
||||
}
|
||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||
return plural(diff / (24 * 60 * 60 * 1000), "day") + " ago";
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
if (diff < 1000) {
|
||||
return 'just now'
|
||||
}
|
||||
if (diff < 60 * 1000) {
|
||||
return plural(diff / 1000, 'second') + ' ago'
|
||||
}
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
return plural(diff / (60 * 1000), 'minute') + ' ago'
|
||||
}
|
||||
if (diff < 24 * 60 * 60 * 1000) {
|
||||
return plural(diff / (60 * 60 * 1000), 'hour') + ' ago'
|
||||
}
|
||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||
return plural(diff / (24 * 60 * 60 * 1000), 'day') + ' ago'
|
||||
}
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
48
src/main.js
48
src/main.js
|
|
@ -1,51 +1,11 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
import App from './App.vue'
|
||||
import { createAppRouter } from '@/router'
|
||||
import { currentUser } from '@/api/auth'
|
||||
|
||||
import LoginPage from './components/LoginPage.vue'
|
||||
import RecipesPage from './components/recipes/RecipesPage.vue'
|
||||
import MealPlanPage from './components/meals/MealPlanPage.vue'
|
||||
import MyShoppingPage from './components/shopping/MyShoppingPage.vue'
|
||||
import PurchasedShoppingListPage from './components/shopping/PurchasedShoppingListPage.vue'
|
||||
import CurrentShoppingListPage from './components/shopping/CurrentShoppingListPage.vue'
|
||||
import EditMealPage from './components/meals/EditMealPage.vue'
|
||||
import EditRecipePage from './components/recipes/EditRecipePage.vue'
|
||||
// Create the router with an auth callback to check current user
|
||||
const router = createAppRouter(() => currentUser())
|
||||
|
||||
|
||||
// 2. Define some routes
|
||||
// Each route should map to a component.
|
||||
// We'll talk about nested routes later.
|
||||
const routes = [
|
||||
// Redirect index to mealplan
|
||||
{ path: '/', redirect: '/mealplan' },
|
||||
{ path: '/mealplan', component: MealPlanPage },
|
||||
{ path: '/login', component: LoginPage },
|
||||
{ path: '/shopping', component: MyShoppingPage },
|
||||
{ path: '/shopping/current', component: CurrentShoppingListPage },
|
||||
{ path: '/shopping/:id', component: PurchasedShoppingListPage, props : true },
|
||||
{ path: '/recipes', component: RecipesPage },
|
||||
{ path: '/recipes/add', component: EditRecipePage },
|
||||
{ path: '/recipes/:id', component: EditRecipePage, props: true },
|
||||
{ path: '/meals/add', component: EditMealPage },
|
||||
{ path: '/meals/:id', component: EditMealPage, props: true },
|
||||
]
|
||||
|
||||
// 3. Create the router instance and pass the `routes` option
|
||||
// You can pass in additional options here, but let's
|
||||
// keep it simple for now.
|
||||
const router = createRouter({
|
||||
// 4. Provide the history implementation to use. We are using the hash history for simplicity here.
|
||||
history: createWebHashHistory(),
|
||||
routes, // short for `routes: routes`
|
||||
})
|
||||
|
||||
// 5. Create and mount the root instance.
|
||||
const app = createApp(App)
|
||||
// Make sure to _use_ the router instance to make the
|
||||
// whole app router-aware.
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Now the app has started!
|
||||
80
src/router/index.js
Normal file
80
src/router/index.js
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
// Lazy-loaded route components
|
||||
const LoginPage = () => import('@/components/LoginPage.vue')
|
||||
const RecipesPage = () => import('@/components/recipes/RecipesPage.vue')
|
||||
const MealPlanPage = () => import('@/components/meals/MealPlanPage.vue')
|
||||
const MyShoppingPage = () => import('@/components/shopping/MyShoppingPage.vue')
|
||||
const PurchasedShoppingListPage = () =>
|
||||
import('@/components/shopping/PurchasedShoppingListPage.vue')
|
||||
const CurrentShoppingListPage = () => import('@/components/shopping/CurrentShoppingListPage.vue')
|
||||
const EditMealPage = () => import('@/components/meals/EditMealPage.vue')
|
||||
const EditRecipePage = () => import('@/components/recipes/EditRecipePage.vue')
|
||||
|
||||
export function createAppRouter(getCurrentUser) {
|
||||
const routes = [
|
||||
{ path: '/', redirect: { name: 'mealplan' } },
|
||||
{ path: '/login', name: 'login', component: LoginPage },
|
||||
{ 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/:id',
|
||||
name: 'shopping-list',
|
||||
component: PurchasedShoppingListPage,
|
||||
props: true,
|
||||
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: '/meals/add', name: 'meal-add', component: EditMealPage, meta: { requiresAuth: true } },
|
||||
{
|
||||
path: '/meals/:id',
|
||||
name: 'meal-edit',
|
||||
component: EditMealPage,
|
||||
props: true,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Simple auth guard using provided getter
|
||||
router.beforeEach(async (to) => {
|
||||
if (!to.meta.requiresAuth) return true
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
if (user) return true
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
218
src/units.js
218
src/units.js
|
|
@ -1,142 +1,142 @@
|
|||
export const equivalentUnits = {
|
||||
'kg': {
|
||||
'kgs': 1,
|
||||
'kilograms': 1,
|
||||
'kilogram': 1,
|
||||
kg: {
|
||||
kgs: 1,
|
||||
kilograms: 1,
|
||||
kilogram: 1,
|
||||
|
||||
'g': 1000,
|
||||
'gram': 1000,
|
||||
'grams': 1000,
|
||||
g: 1000,
|
||||
gram: 1000,
|
||||
grams: 1000,
|
||||
|
||||
'lb': 2.20462,
|
||||
'lbs': 2.20462,
|
||||
'pound': 2.20462,
|
||||
'pounds': 2.20462,
|
||||
},
|
||||
'litres': {
|
||||
'l': 1,
|
||||
'liter': 1,
|
||||
'litre': 1,
|
||||
lb: 2.20462,
|
||||
lbs: 2.20462,
|
||||
pound: 2.20462,
|
||||
pounds: 2.20462,
|
||||
},
|
||||
litres: {
|
||||
l: 1,
|
||||
liter: 1,
|
||||
litre: 1,
|
||||
|
||||
'ml': 1000,
|
||||
'milliliters': 1000,
|
||||
'milliliter': 1000,
|
||||
ml: 1000,
|
||||
milliliters: 1000,
|
||||
milliliter: 1000,
|
||||
|
||||
'fl oz': 33.814,
|
||||
'fluid ounce': 33.814,
|
||||
'fluid ounces': 33.814,
|
||||
'fl oz': 33.814,
|
||||
'fluid ounce': 33.814,
|
||||
'fluid ounces': 33.814,
|
||||
|
||||
'cup': 4.22675,
|
||||
'cups': 4.22675,
|
||||
cup: 4.22675,
|
||||
cups: 4.22675,
|
||||
|
||||
'tbsp': 67.628,
|
||||
'tablespoon': 67.628,
|
||||
'tablespoons': 67.628,
|
||||
tbsp: 67.628,
|
||||
tablespoon: 67.628,
|
||||
tablespoons: 67.628,
|
||||
|
||||
'tsp': 202.884,
|
||||
'teaspoon': 202.884,
|
||||
'teaspoons': 202.884,
|
||||
tsp: 202.884,
|
||||
teaspoon: 202.884,
|
||||
teaspoons: 202.884,
|
||||
|
||||
'pt': 2.11338,
|
||||
'pint': 2.11338,
|
||||
'pints': 2.11338,
|
||||
pt: 2.11338,
|
||||
pint: 2.11338,
|
||||
pints: 2.11338,
|
||||
|
||||
'qt': 1.05669,
|
||||
'quart': 1.05669,
|
||||
'quarts': 1.05669,
|
||||
qt: 1.05669,
|
||||
quart: 1.05669,
|
||||
quarts: 1.05669,
|
||||
|
||||
'gal': 0.264172,
|
||||
'gallon': 0.264172,
|
||||
'gallons': 0.264172,
|
||||
gal: 0.264172,
|
||||
gallon: 0.264172,
|
||||
gallons: 0.264172,
|
||||
|
||||
'oz': 35.1951,
|
||||
'ounce': 35.1951,
|
||||
},
|
||||
'items': {
|
||||
'item': 1,
|
||||
'items': 1,
|
||||
'pcs': 1,
|
||||
'piece': 1,
|
||||
'pieces': 1,
|
||||
'florets': 8, // Broccoli
|
||||
'head': 1, // Broccoli
|
||||
'heads': 1, // Broccoli
|
||||
'slice': 10, // Bread
|
||||
'slices': 10, // Bread
|
||||
'loaf': 1, // Bread
|
||||
'loaves': 1, // Bread
|
||||
'cloves': 8, // Garlic
|
||||
'bulb': 1, // Garlic
|
||||
'bulbs': 1, // Garlic
|
||||
'stalk': 1, // Celery
|
||||
'stalks': 1, // Celery
|
||||
'bunch': 1, // Cilantro
|
||||
'bunches': 1, // Cilantro
|
||||
'sprig': 1, // Cilantro
|
||||
'sprigs': 1, // Cilantro
|
||||
'cans': 1, // Canned goods
|
||||
'can': 1, // Canned goods
|
||||
'pack': 1, // Packaged goods
|
||||
'packs': 1, // Packaged goods
|
||||
'package': 1, // Packaged goods
|
||||
'packages': 1, // Packaged goods
|
||||
'container': 1, // Packaged goods
|
||||
'containers': 1, // Packaged goods
|
||||
},
|
||||
oz: 35.1951,
|
||||
ounce: 35.1951,
|
||||
},
|
||||
items: {
|
||||
item: 1,
|
||||
items: 1,
|
||||
pcs: 1,
|
||||
piece: 1,
|
||||
pieces: 1,
|
||||
florets: 8, // Broccoli
|
||||
head: 1, // Broccoli
|
||||
heads: 1, // Broccoli
|
||||
slice: 10, // Bread
|
||||
slices: 10, // Bread
|
||||
loaf: 1, // Bread
|
||||
loaves: 1, // Bread
|
||||
cloves: 8, // Garlic
|
||||
bulb: 1, // Garlic
|
||||
bulbs: 1, // Garlic
|
||||
stalk: 1, // Celery
|
||||
stalks: 1, // Celery
|
||||
bunch: 1, // Cilantro
|
||||
bunches: 1, // Cilantro
|
||||
sprig: 1, // Cilantro
|
||||
sprigs: 1, // Cilantro
|
||||
cans: 1, // Canned goods
|
||||
can: 1, // Canned goods
|
||||
pack: 1, // Packaged goods
|
||||
packs: 1, // Packaged goods
|
||||
package: 1, // Packaged goods
|
||||
packages: 1, // Packaged goods
|
||||
container: 1, // Packaged goods
|
||||
containers: 1, // Packaged goods
|
||||
},
|
||||
}
|
||||
|
||||
function getBaseUnit(unit) {
|
||||
for (const unitType in equivalentUnits) {
|
||||
if (unit in equivalentUnits[unitType]) {
|
||||
return unitType;
|
||||
}
|
||||
for (const unitType in equivalentUnits) {
|
||||
if (unit in equivalentUnits[unitType]) {
|
||||
return unitType
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
export function getConversionFactor(unit) {
|
||||
if (unit in equivalentUnits) {
|
||||
return { unit, factor: 1 };
|
||||
}
|
||||
if (unit in equivalentUnits) {
|
||||
return { unit, factor: 1 }
|
||||
}
|
||||
|
||||
const unitLower = unit.toLowerCase();
|
||||
if (unitLower in equivalentUnits) {
|
||||
return { unit: unitLower, factor: 1 };
|
||||
}
|
||||
const unitLower = unit.toLowerCase()
|
||||
if (unitLower in equivalentUnits) {
|
||||
return { unit: unitLower, factor: 1 }
|
||||
}
|
||||
|
||||
const baseUnit = getBaseUnit(unit);
|
||||
if (baseUnit) {
|
||||
return {
|
||||
unit: baseUnit,
|
||||
factor: equivalentUnits[baseUnit][unit],
|
||||
};
|
||||
const baseUnit = getBaseUnit(unit)
|
||||
if (baseUnit) {
|
||||
return {
|
||||
unit: baseUnit,
|
||||
factor: equivalentUnits[baseUnit][unit],
|
||||
}
|
||||
}
|
||||
|
||||
const baseUnitLower = getBaseUnit(unitLower);
|
||||
if (baseUnitLower) {
|
||||
return {
|
||||
unit: baseUnitLower,
|
||||
factor: equivalentUnits[baseUnitLower][unitLower],
|
||||
};
|
||||
const baseUnitLower = getBaseUnit(unitLower)
|
||||
if (baseUnitLower) {
|
||||
return {
|
||||
unit: baseUnitLower,
|
||||
factor: equivalentUnits[baseUnitLower][unitLower],
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
export function calculateTotals(quantityList) {
|
||||
const totals = {};
|
||||
for (const quantity of quantityList) {
|
||||
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 };
|
||||
const factor = baseUnit.factor;
|
||||
const unit = baseUnit.unit;
|
||||
const totals = {}
|
||||
for (const quantity of quantityList) {
|
||||
const baseUnit = getConversionFactor(quantity.unit) ?? { unit: quantity.unit, factor: 1 }
|
||||
const factor = baseUnit.factor
|
||||
const unit = baseUnit.unit
|
||||
|
||||
if (!totals[unit]) {
|
||||
totals[unit] = 0;
|
||||
}
|
||||
|
||||
totals[unit] += quantity.quantity / factor;
|
||||
if (!totals[unit]) {
|
||||
totals[unit] = 0
|
||||
}
|
||||
|
||||
return Object.keys(totals).map(unit => ({ unit, quantity: totals[unit] }));
|
||||
totals[unit] += quantity.quantity / factor
|
||||
}
|
||||
|
||||
return Object.keys(totals).map((unit) => ({ unit, quantity: totals[unit] }))
|
||||
}
|
||||
27
tests/mealMapper.test.js
Normal file
27
tests/mealMapper.test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mapMeal, mapMeals } from '@/api/mappers/mealMapper'
|
||||
|
||||
describe('mealMapper', () => {
|
||||
it('maps individual meal date fields to Date instances', () => {
|
||||
const input = {
|
||||
id: 1,
|
||||
suggested_date: '2025-01-01T00:00:00Z',
|
||||
purchase_date: '2025-01-02T00:00:00Z',
|
||||
consumed_date: '2025-01-03T00:00:00Z',
|
||||
}
|
||||
const result = mapMeal({ ...input })
|
||||
expect(result.suggested_date).toBeInstanceOf(Date)
|
||||
expect(result.purchase_date).toBeInstanceOf(Date)
|
||||
expect(result.consumed_date).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('maps lists of meals', () => {
|
||||
const input = [
|
||||
{ id: 1, suggested_date: '2025-01-01T00:00:00Z' },
|
||||
{ id: 2, suggested_date: '2025-01-02T00:00:00Z' },
|
||||
]
|
||||
const result = mapMeals(input)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].suggested_date).toBeInstanceOf(Date)
|
||||
})
|
||||
})
|
||||
33
tests/shoppingListMapper.test.js
Normal file
33
tests/shoppingListMapper.test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/mappers/shoppingListMapper'
|
||||
|
||||
describe('shoppingListMapper', () => {
|
||||
it('maps current shopping list and wires references', () => {
|
||||
const dto = {
|
||||
ingredients_lookup: { 10: { id: 10, name: 'Eggs' } },
|
||||
meals_lookup: { 1: { id: 1, suggested_date: '2025-01-01T00:00:00Z', recipes: [], extra_ingredients: [], chefs: [], consumers: [], cleanup: [] } },
|
||||
recipes_lookup: { 5: { id: 5, name: 'Omelette' } },
|
||||
shopping_list_lookup: { 7: { id: 7, created_date: '2025-01-01T00:00:00Z' } },
|
||||
outstanding_items: [{ ingredient_id: 10, meal_id: 1, recipe_id: 5, list_id: 7, created_date: '2025-01-01T00:00:00Z' }],
|
||||
requested_meals: [],
|
||||
purchased_items: [],
|
||||
}
|
||||
const mapped = mapCurrentShoppingList(dto)
|
||||
const item = mapped.outstanding_items[0]
|
||||
expect(item.ingredient.name).toBe('Eggs')
|
||||
expect(item.meal.suggested_date).toBeInstanceOf(Date)
|
||||
expect(mapped.shopping_list_lookup['7'].created_date).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('maps purchased shopping list with list dates and item refs', () => {
|
||||
const dto = {
|
||||
ingredients_lookup: { 10: { id: 10, name: 'Eggs' } },
|
||||
meals_lookup: { 1: { id: 1, suggested_date: '2025-01-01T00:00:00Z', recipes: [], extra_ingredients: [], chefs: [], consumers: [], cleanup: [] } },
|
||||
recipes_lookup: { 5: { id: 5, name: 'Omelette' } },
|
||||
list: { id: 9, created_date: '2025-02-02T00:00:00Z', items: [{ ingredient_id: 10, meal_id: 1, recipe_id: 5, list_id: 9 }] },
|
||||
}
|
||||
const mapped = mapPurchasedShoppingList(dto)
|
||||
expect(mapped.list.created_date).toBeInstanceOf(Date)
|
||||
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
|
||||
})
|
||||
})
|
||||
27
tests/units.test.js
Normal file
27
tests/units.test.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { getConversionFactor, calculateTotals } from '@/units'
|
||||
|
||||
describe('units', () => {
|
||||
it('returns conversion factors for aliases and base units', () => {
|
||||
expect(getConversionFactor('kg')).toEqual({ unit: 'kg', factor: 1 })
|
||||
expect(getConversionFactor('g')).toEqual({ unit: 'kg', factor: 1000 })
|
||||
expect(getConversionFactor('kgs')).toEqual({ unit: 'kg', factor: 1 })
|
||||
expect(getConversionFactor('litre')).toEqual({ unit: 'litres', factor: 1 })
|
||||
expect(getConversionFactor('ml')).toEqual({ unit: 'litres', factor: 1000 })
|
||||
})
|
||||
|
||||
it('calculates totals grouped by base units', () => {
|
||||
const totals = calculateTotals([
|
||||
{ quantity: 500, unit: 'g' },
|
||||
{ quantity: 0.5, unit: 'kg' },
|
||||
{ quantity: 250, unit: 'ml' },
|
||||
{ quantity: 0.75, unit: 'litre' },
|
||||
])
|
||||
// Expect kg total = 0.5 (from g) + 0.5 (from kg) = 1
|
||||
const kgTotal = totals.find((t) => t.unit === 'kg')
|
||||
expect(kgTotal.quantity).toBeCloseTo(1)
|
||||
// Expect litres total = 0.25 (from ml) + 0.75 (from litre) = 1
|
||||
const lTotal = totals.find((t) => t.unit === 'litres')
|
||||
expect(lTotal.quantity).toBeCloseTo(1)
|
||||
})
|
||||
})
|
||||
26
tests/useAlert.test.js
Normal file
26
tests/useAlert.test.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { useAlert } from '@/composables/useAlert'
|
||||
|
||||
describe('useAlert', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
it('shows and clears alerts', () => {
|
||||
const { current, show, clear } = useAlert()
|
||||
expect(current.value).toBeNull()
|
||||
show({ heading: 'Hello', message: 'World', type: 'info' })
|
||||
expect(current.value).toMatchObject({ heading: 'Hello', message: 'World', type: 'info' })
|
||||
clear()
|
||||
expect(current.value).toBeNull()
|
||||
})
|
||||
|
||||
it('auto-dismisses after scheduleAutoDismiss', () => {
|
||||
const { current, show, scheduleAutoDismiss } = useAlert()
|
||||
show({ heading: 'Auto', message: 'Dismiss', type: 'success' })
|
||||
scheduleAutoDismiss(5000)
|
||||
expect(current.value).not.toBeNull()
|
||||
vi.advanceTimersByTime(5000)
|
||||
expect(current.value).toBeNull()
|
||||
})
|
||||
})
|
||||
16
vitest.config.js
Normal file
16
vitest.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.{test,spec}.js'],
|
||||
globals: true,
|
||||
reporters: 'default',
|
||||
},
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const { defineConfig } = require('@vue/cli-service')
|
||||
module.exports = defineConfig({
|
||||
transpileDependencies: true
|
||||
transpileDependencies: true,
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue