Autoformat
This commit is contained in:
parent
72fccdd8a3
commit
bd99d90c09
24 changed files with 793 additions and 566 deletions
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=
|
||||
|
|
@ -42,13 +42,16 @@
|
|||
"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,46 +0,0 @@
|
|||
# Refactor Strategy
|
||||
|
||||
This document outlines a pragmatic, step-by-step refactor plan to improve structure, readability, and maintainability. Each step includes a clear outcome and a checkbox to track progress.
|
||||
|
||||
Last updated: 2025-10-18
|
||||
|
||||
## Goals
|
||||
|
||||
- Separate concerns (routing, HTTP/API, mapping/normalization, UI logic)
|
||||
- Improve readability and testability
|
||||
- Establish light-weight standards (naming, lint/format) without blocking development
|
||||
- Keep changes incremental and safe
|
||||
|
||||
## Phases and Steps
|
||||
|
||||
### Phase 1 — Routing and Auth (Foundational)
|
||||
|
||||
# Refactor Strategy (Outstanding Work)
|
||||
|
||||
Last updated: 2025-10-18
|
||||
|
||||
Only outstanding, actionable steps are listed below. Completed work has been removed for clarity.
|
||||
|
||||
## 1) Linting and rules modernization
|
||||
- Evaluate upgrading ESLint and eslint-plugin-vue to latest that fully supports Vue 3 macros and recommended rules.
|
||||
- Align rules with Composition API best practices; ensure Prettier remains source of truth.
|
||||
|
||||
Acceptance: ESLint upgrade plan decided (or implemented), rules apply cleanly, lint passes.
|
||||
|
||||
## 2) Alerts as a composable
|
||||
- DONE: Replaced the event bus with `useAlert` composable and updated `AlertToast.vue` and callers.
|
||||
- Follow-up: consider queuing multiple alerts if needed (current behavior shows latest only).
|
||||
|
||||
Acceptance: N/A (completed). Optional enhancement if queuing desired.
|
||||
|
||||
## 3) Incremental test coverage
|
||||
- Add unit tests for new utilities/composables when added (e.g., `useAlert`).
|
||||
- Consider snapshot tests for components with stable UI fragments (cards, list items).
|
||||
|
||||
Acceptance: New logic lands with tests; existing tests stay green.
|
||||
|
||||
## 4) Developer experience
|
||||
- Document .env usage with `VUE_APP_API_BASE` and add a `.env.example` file.
|
||||
- Add Volar as a recommended extension in the project docs (README updated).
|
||||
|
||||
Acceptance: Clear env setup; editor help consistent.
|
||||
24
src/App.vue
24
src/App.vue
|
|
@ -2,19 +2,31 @@
|
|||
<div>
|
||||
<ul class="nav">
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active"
|
||||
>Recipes</router-link
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{ name: 'recipes' }"
|
||||
active-class="active"
|
||||
>
|
||||
Recipes
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active"
|
||||
>Meal Plan</router-link
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{ name: 'mealplan' }"
|
||||
active-class="active"
|
||||
>
|
||||
Meal Plan
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active"
|
||||
>Shopping</router-link
|
||||
<router-link
|
||||
class="nav-link"
|
||||
:to="{ name: 'shopping' }"
|
||||
active-class="active"
|
||||
>
|
||||
Shopping
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
<div class="card">
|
||||
<a @click="emit('click')">
|
||||
<h2>{{ title }}</h2>
|
||||
<img :src="image" :alt="title" />
|
||||
<img
|
||||
:src="image"
|
||||
:alt="title"
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,55 @@
|
|||
<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>
|
||||
</template>
|
||||
|
||||
<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 */
|
||||
|
|
@ -46,33 +88,3 @@
|
|||
background-color: #2196f3;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@
|
|||
<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)">
|
||||
<li
|
||||
v-for="person in persons"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="onLogin(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</li>
|
||||
|
|
@ -11,6 +18,33 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
|
|
@ -66,30 +100,3 @@ li:nth-child(4) > button {
|
|||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,22 +1,37 @@
|
|||
<template>
|
||||
<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 }">{{
|
||||
<span
|
||||
class="parse-element quantity"
|
||||
:class="{ missing: !ingredient?.quantity }"
|
||||
>{{
|
||||
ingredient?.quantity || 'qty'
|
||||
}}</span>
|
||||
<span class="parse-element unit" :class="{ missing: !ingredient?.unit }">{{
|
||||
<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 }">{{
|
||||
<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”">
|
||||
}}</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"
|
||||
|
|
@ -27,10 +42,14 @@
|
|||
margin-left: 0.5em;
|
||||
margin-bottom: 0.2em;
|
||||
"
|
||||
/>
|
||||
>
|
||||
)
|
||||
</a>
|
||||
<a v-else-if="ingredient?.name" :href="searchlink" target="_blank"> (search?) </a>
|
||||
<a
|
||||
v-else-if="ingredient?.name"
|
||||
:href="searchlink"
|
||||
target="_blank"
|
||||
> (search?) </a>
|
||||
<a v-else> (product) </a>
|
||||
</span>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,39 @@
|
|||
<template>
|
||||
<div :class="{ editing: editing }">
|
||||
<button v-if="editing" @click="emit('on-add')">
|
||||
<img class="icon" :src="require('@/assets/add-cart.svg')" /> <br />
|
||||
<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">
|
||||
<button
|
||||
v-if="!editOnly"
|
||||
@click="toggleEditing"
|
||||
>
|
||||
<span v-if="editing">
|
||||
<img class="icon" :src="require('@/assets/edit-off.svg')" /> <br />
|
||||
<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 />
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/edit.svg')"
|
||||
> <br>
|
||||
Edit My List
|
||||
</span>
|
||||
</button>
|
||||
<ul>
|
||||
<li v-for="ingredient in ingredients" :key="ingredient">
|
||||
<li
|
||||
v-for="ingredient in ingredients"
|
||||
:key="ingredient"
|
||||
>
|
||||
<div v-if="editing">
|
||||
<p class="ingredient-line">
|
||||
<ingredient-line
|
||||
|
|
@ -25,7 +43,10 @@
|
|||
/>
|
||||
</p>
|
||||
<button @click="emit('on-delete', ingredient)">
|
||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/trash.svg')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
|
@ -36,6 +57,36 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { parseProduct, parseIngredients } from '@/api/recipes'
|
||||
import IngredientLine from './IngredientLine.vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
|
||||
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;
|
||||
|
|
@ -69,33 +120,3 @@ li > div {
|
|||
margin-right: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { parseProduct, parseIngredients } from '@/api/recipes'
|
||||
import IngredientLine from './IngredientLine.vue'
|
||||
import CompactParsedIngredient from './CompactParsedIngredient.vue'
|
||||
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@
|
|||
<p>
|
||||
<input
|
||||
v-model="ingredientText"
|
||||
placeholder="Enter an ingredient"
|
||||
@keyup.enter="updateIngredient"
|
||||
@blur="updateIngredient"
|
||||
placeholder="Enter an ingredient"
|
||||
/>
|
||||
>
|
||||
<input
|
||||
v-model="productLink"
|
||||
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 -->
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
<template>
|
||||
<div class="date-picker">
|
||||
<input
|
||||
type="text"
|
||||
v-model="selectedDate"
|
||||
type="text"
|
||||
placeholder="Select a date"
|
||||
@focus="showDatePicker = true"
|
||||
@blur="showDatePicker = false"
|
||||
placeholder="Select a date"
|
||||
/>
|
||||
<div v-if="showDatePicker" class="date-picker-dropdown">
|
||||
>
|
||||
<div
|
||||
v-if="showDatePicker"
|
||||
class="date-picker-dropdown"
|
||||
>
|
||||
<ul>
|
||||
<li v-for="(day, index) in days" :key="index" @mousedown="selectDate(day)">
|
||||
<li
|
||||
v-for="(day, index) in days"
|
||||
:key="index"
|
||||
@mousedown="selectDate(day)"
|
||||
>
|
||||
{{ formatDay(day) }}
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<div class="container">
|
||||
<div class="fields">
|
||||
<date-picker @date-selected="selectDate" :date="meal.suggested_date" />
|
||||
<date-picker
|
||||
:date="meal.suggested_date"
|
||||
@date-selected="selectDate"
|
||||
/>
|
||||
<div class="persons-list">
|
||||
Cooked by
|
||||
<person-list
|
||||
|
|
@ -26,14 +29,21 @@
|
|||
<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">
|
||||
<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" />
|
||||
<input
|
||||
v-model="mealRecipe.servings"
|
||||
type="number"
|
||||
min="1"
|
||||
>
|
||||
<small><em>servings</em></small>
|
||||
</p>
|
||||
|
||||
|
|
@ -41,15 +51,24 @@
|
|||
type="checkbox"
|
||||
class="show-ingredient-checkbox"
|
||||
:checked="showIngredient(mealRecipe)"
|
||||
/>
|
||||
>
|
||||
<label
|
||||
for="show-ingredients"
|
||||
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
|
||||
>
|
||||
<img class="icon" :src="require('@/assets/show-ingredients.svg')" />
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/show-ingredients.svg')"
|
||||
>
|
||||
</label>
|
||||
<button class="icon-button" @click="removeRecipe(mealRecipe)">
|
||||
<img class="icon" :src="require('@/assets/trash.svg')" />
|
||||
<button
|
||||
class="icon-button"
|
||||
@click="removeRecipe(mealRecipe)"
|
||||
>
|
||||
<img
|
||||
class="icon"
|
||||
:src="require('@/assets/trash.svg')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,7 +103,9 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<button @click="onSaveMeal">Save</button>
|
||||
<button @click="onSaveMeal">
|
||||
Save
|
||||
</button>
|
||||
<p v-if="meal.purchase_date">
|
||||
<em>Purchased {{ ago(meal.purchase_date) }}</em>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -7,24 +7,30 @@
|
|||
|
||||
<p>
|
||||
Cooked by
|
||||
<span v-for="(chef, index) in meal.chefs" :key="chef.id">
|
||||
<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">
|
||||
<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>
|
||||
<p v-if="meal.purchase_date">
|
||||
Purchased {{ ago(meal.purchase_date) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ago } from '@/dateformats.js'
|
||||
|
|
@ -77,3 +83,5 @@ const mealTitle = computed(() => {
|
|||
return 'Nothing planned'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
|
|
|||
|
|
@ -1,26 +1,47 @@
|
|||
<template>
|
||||
<div>
|
||||
<ul class="meals-list" v-if="meals.length">
|
||||
<li v-for="meal in meals" :key="meal.id">
|
||||
<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">
|
||||
<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">
|
||||
<ul
|
||||
v-if="selectedMeal == meal"
|
||||
class="actions"
|
||||
>
|
||||
<li>
|
||||
<router-link class="nav-link" :to="`/meals/${selectedMeal.id}`" active-class="active"
|
||||
>Edit Meal</router-link
|
||||
<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>
|
||||
<li>
|
||||
<a
|
||||
class="button"
|
||||
@click="deleteSelectedMeal"
|
||||
>Remove</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
@ -30,11 +51,44 @@
|
|||
<action-item
|
||||
title="Plan Meal"
|
||||
:image="require('@/assets/plan-meal.svg')"
|
||||
@click="() => this.$router.push('/meals/add')"
|
||||
@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;
|
||||
|
|
@ -90,36 +144,3 @@ ul.actions {
|
|||
padding-bottom: 2ex;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
<template>
|
||||
<span class="person-list">
|
||||
<span v-for="person in people" :key="person.id">
|
||||
<button class="person-circle remove-person" @click="removePerson(person)">
|
||||
<span
|
||||
v-for="person in people"
|
||||
:key="person.id"
|
||||
>
|
||||
<button
|
||||
class="person-circle remove-person"
|
||||
@click="removePerson(person)"
|
||||
>
|
||||
{{ person.name }}
|
||||
</button>
|
||||
</span>
|
||||
|
|
@ -15,19 +21,25 @@
|
|||
</button>
|
||||
<input
|
||||
v-else
|
||||
v-model="searchName"
|
||||
ref="searchNameInput"
|
||||
v-model="searchName"
|
||||
@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)">
|
||||
<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>
|
||||
|
|
@ -36,6 +48,67 @@
|
|||
</span>
|
||||
</template>
|
||||
|
||||
<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;
|
||||
|
|
@ -116,64 +189,3 @@
|
|||
padding: 1ex 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,18 @@
|
|||
<template>
|
||||
<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">
|
||||
|
|
@ -11,11 +20,26 @@
|
|||
</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>
|
||||
<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 type="number" v-model="recipe.serves" />
|
||||
<h3 class="recipe-link"><a :href="recipe.link">View Recipe</a></h3>
|
||||
<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"
|
||||
|
|
@ -26,43 +50,24 @@
|
|||
/>
|
||||
|
||||
<div>
|
||||
<button v-if="recipe.id" class="delete-btn" @click="deleteRecipe">Delete</button>
|
||||
<button class="submit-btn" @click="saveRecipe">{{ recipe.id ? 'Save' : 'Create' }}</button>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
input.recipe-link {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
max-height: 20vh;
|
||||
min-height: 20vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input.recipe-name {
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.recipe-link {
|
||||
color: #0000ee;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
|
@ -169,3 +174,33 @@ watch(
|
|||
|
||||
// expose functions for template binding names (automatic in <script setup>)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border: 0;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: large;
|
||||
}
|
||||
|
||||
input.recipe-link {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
max-height: 20vh;
|
||||
min-height: 20vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
input.recipe-name {
|
||||
width: 100%;
|
||||
font-weight: bold;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.recipe-link {
|
||||
color: #0000ee;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
<template>
|
||||
<div class="recipe-card">
|
||||
<p>
|
||||
<img v-if="recipe.image_urls" :src="recipe.image_urls[0]" />
|
||||
<img v-else src="@/assets/egg.svg" />
|
||||
<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>
|
||||
<p class="recipe-name">{{ recipe.name }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
<template>
|
||||
<div class="recipe-search-box" @focusout="onFocusOut">
|
||||
<div
|
||||
class="recipe-search-box"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
@keyup.enter="search"
|
||||
@keyup.esc="clear"
|
||||
@focusin="search"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<ul v-if="recipes?.length" class="dropdown">
|
||||
>
|
||||
<ul
|
||||
v-if="recipes?.length"
|
||||
class="dropdown"
|
||||
>
|
||||
<li
|
||||
class="recipe"
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.id"
|
||||
class="recipe"
|
||||
@mousedown="selectRecipe(recipe)"
|
||||
>
|
||||
<recipe-card :recipe="recipe" />
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list
|
||||
@meal-selected="mealSelected"
|
||||
@meal-unselected="mealUnselected"
|
||||
:checked="includedMeals"
|
||||
:meals="availableMeals"
|
||||
@meal-selected="mealSelected"
|
||||
@meal-unselected="mealUnselected"
|
||||
/>
|
||||
|
||||
<ul class="full-shopping-list">
|
||||
|
|
@ -26,119 +26,79 @@
|
|||
</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>
|
||||
<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
|
||||
@meal-selected="mealSelected"
|
||||
@meal-unselected="mealUnselected"
|
||||
: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">
|
||||
<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>
|
||||
<div
|
||||
v-if="selected.length"
|
||||
class="spacer"
|
||||
>
|
||||
|
||||
</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">
|
||||
<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>
|
||||
<p v-else>
|
||||
Mark {{ selected.length }} items as
|
||||
</p>
|
||||
|
||||
<div class="button-group">
|
||||
<button @click="markFound">
|
||||
<img src="@/assets/house-check.svg" /><br />
|
||||
<img src="@/assets/house-check.svg"><br>
|
||||
Found
|
||||
</button>
|
||||
|
||||
<button @click="markPurchased">
|
||||
<img src="@/assets/shopping-cart.svg" /><br />
|
||||
<img src="@/assets/shopping-cart.svg"><br>
|
||||
Purchased
|
||||
</button>
|
||||
|
||||
<button @click="selected = []">
|
||||
<img src="@/assets/close.svg" /><br />
|
||||
<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 setup>
|
||||
import { ref, computed, onBeforeMount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
|
@ -232,3 +192,66 @@ function isSelected(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,82 +1,28 @@
|
|||
<template>
|
||||
<ul>
|
||||
<li v-for="meal in meals" :key="meal.id">
|
||||
<li
|
||||
v-for="meal in meals"
|
||||
:key="meal.id"
|
||||
>
|
||||
<!-- Have a checkbox and card for each meal, show the image and name -->
|
||||
<!-- Emit meal-selected event on checked and meal-unselected on unchecked -->
|
||||
<input
|
||||
type="checkbox"
|
||||
:id="meal.id"
|
||||
type="checkbox"
|
||||
:checked="isChecked(meal)"
|
||||
@change="mealCheckChanged"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<label :for="meal.id" :style="getImageStyling(meal)">
|
||||
@change="mealCheckChanged"
|
||||
>
|
||||
<label
|
||||
:for="meal.id"
|
||||
:style="getImageStyling(meal)"
|
||||
>
|
||||
{{ formatDate(meal.suggested_date) }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
ul {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Hide the default checkbox formatting, and format the card instead */
|
||||
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;
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + label {
|
||||
border: 3px solid #3d5447;
|
||||
text-shadow: #ccc 0 0 0.1em;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Position the text in the center of the card */
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: larger;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
meals: { type: Array, required: true },
|
||||
|
|
@ -141,3 +87,63 @@ function isChecked(meal) {
|
|||
return props.checked.some((m) => m.id === meal.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
ul {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Hide the default checkbox formatting, and format the card instead */
|
||||
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;
|
||||
}
|
||||
|
||||
input[type='checkbox']:checked + label {
|
||||
border: 3px solid #3d5447;
|
||||
text-shadow: #ccc 0 0 0.1em;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Position the text in the center of the card */
|
||||
label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: larger;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
<template>
|
||||
<div>
|
||||
<h1>My Shopping List</h1>
|
||||
<router-link :to="`/shopping/current`">Full Shopping List</router-link>
|
||||
<router-link :to="`/shopping/current`">
|
||||
Full Shopping List
|
||||
</router-link>
|
||||
<editable-ingredients-panel
|
||||
:ingredients="ingredients"
|
||||
@on-add="addIngredient"
|
||||
@on-delete="deleteIngredient"
|
||||
@on-update-ingredient="updateIngredient"
|
||||
@on-editing="onEditing"
|
||||
:ingredients="ingredients"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -37,8 +39,6 @@
|
|||
-->
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeMount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
|
@ -86,3 +86,5 @@ onBeforeMount(async () => {
|
|||
await updateShoppingList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -3,30 +3,23 @@
|
|||
|
||||
<div v-if="includedMeals.length > 0">
|
||||
<h4>Included Meals</h4>
|
||||
<meal-selection-list :checked="includedMeals" :meals="includedMeals" :disabled="true" />
|
||||
<meal-selection-list
|
||||
:checked="includedMeals"
|
||||
:meals="includedMeals"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="full-shopping-list">
|
||||
<li v-for="item in listByProduct" :key="item.id">
|
||||
<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 setup>
|
||||
import { ref, computed, onBeforeMount } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
|
@ -52,3 +45,17 @@ onBeforeMount(async () => {
|
|||
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;
|
||||
}
|
||||
|
||||
.full-shopping-list {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -4,30 +4,38 @@
|
|||
<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
|
||||
>,
|
||||
>{{ shoppingListItemGroup.product?.name }}</a>
|
||||
<span v-else>{{ shoppingListItemGroup.name }}</span> </strong>,
|
||||
<small>
|
||||
<span v-for="(total, index) in remainingRequiredTotals" :key="total.id">
|
||||
<span
|
||||
v-for="(total, index) in remainingRequiredTotals"
|
||||
:key="total.id"
|
||||
>
|
||||
<span v-if="index">, </span>
|
||||
<span>{{ formatQuantity(total.quantity) }} {{ total.unit }}</span>
|
||||
</span>
|
||||
<span class="found-marker partial" v-if="purchased.length > 0"
|
||||
>✓ {{ getFriendlyDate(lastPurchased) }}</span
|
||||
>
|
||||
<span
|
||||
v-if="purchased.length > 0"
|
||||
class="found-marker partial"
|
||||
>✓ {{ getFriendlyDate(lastPurchased) }}</span>
|
||||
</small>
|
||||
</h3>
|
||||
<p class="sources" v-if="required.length > 0">
|
||||
<p
|
||||
v-if="required.length > 0"
|
||||
class="sources"
|
||||
>
|
||||
<strong>Need: </strong>
|
||||
<span v-for="(source, index) in required" :key="source.id">
|
||||
<span
|
||||
v-for="(source, index) in required"
|
||||
:key="source.id"
|
||||
>
|
||||
<span v-if="index">, and </span>
|
||||
<span v-if="source.person">
|
||||
{{ formatQuantity(source.ingredient.quantity) }} {{ source.ingredient.unit }} for
|
||||
|
|
@ -61,7 +69,10 @@
|
|||
</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-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
|
||||
|
|
@ -97,6 +108,50 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<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 */
|
||||
|
||||
|
|
@ -147,47 +202,3 @@
|
|||
background-color: darkgoldenrod;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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>
|
||||
|
|
|
|||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue