This commit is contained in:
jableader 2025-10-18 12:36:55 +11:00
parent ee2f08cbfc
commit ec28531df9
15 changed files with 219 additions and 31 deletions

View file

@ -16,7 +16,7 @@ Last updated: 2025-10-18
- [x] Extract router into `src/router/index.js` with named routes - [x] Extract router into `src/router/index.js` with named routes
- [x] Add route meta `requiresAuth` and a global auth guard - [x] Add route meta `requiresAuth` and a global auth guard
- [x] Remove auth-redirect from `App.vue` (handled by guard instead) - [x] Remove auth-redirect from `App.vue` (handled by guard instead)
- [ ] Convert top-level nav to use route names consistently (optional) - [x] Convert top-level nav to use route names consistently (optional)
Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth. Outcome: Routing logic is centralized and testable; pages redirect consistently based on auth.
@ -24,9 +24,9 @@ Outcome: Routing logic is centralized and testable; pages redirect consistently
- [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL - [x] Add `src/api/http.js` wrapper for JSON fetch with error handling and env-based base URL
- [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates) - [x] Add `src/api/mappers/mealMapper.js` to normalize Meal data (dates)
- [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete) - [x] Add `src/api/meals.js` and migrate MealPlan API calls (get upcoming, mark consumed, delete)
- [ ] Create `src/api/recipes.js` and migrate recipe endpoints - [x] Create `src/api/recipes.js` and migrate recipe endpoints
- [ ] Create `src/api/shopping.js` and migrate shopping endpoints - [x] Create `src/api/shopping.js` and migrate shopping endpoints
- [ ] Create `src/api/auth.js` and migrate auth endpoints - [x] Create `src/api/auth.js` and migrate auth endpoints
Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable. Outcome: Feature modules call cohesive services; logic for mapping/normalization is isolated and testable.
@ -60,3 +60,6 @@ Outcome: Confidence in refactors and easier onboarding.
- Removed `App.vue` auth redirect - Removed `App.vue` auth redirect
- Added `api/http.js`, `api/mappers/mealMapper.js`, `api/meals.js` - Added `api/http.js`, `api/mappers/mealMapper.js`, `api/meals.js`
- Updated `MealPlanPage.vue` to use meals API - Updated `MealPlanPage.vue` to use meals API
- Added `api/mappers/recipeMapper.js`, `api/recipes.js`; updated recipes components
- Added `api/mappers/shoppingListMapper.js`, `api/shopping.js`; updated shopping components
- Added `api/auth.js` and `api/persons.js`; router uses auth API

View file

@ -2,13 +2,13 @@
<div> <div>
<ul class="nav"> <ul class="nav">
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" to="/recipes" active-class="active">Recipes</router-link> <router-link class="nav-link" :to="{ name: 'recipes' }" active-class="active">Recipes</router-link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" to="/mealplan" active-class="active">Meal Plan</router-link> <router-link class="nav-link" :to="{ name: 'mealplan' }" active-class="active">Meal Plan</router-link>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<router-link class="nav-link" to="/shopping" active-class="active">Shopping</router-link> <router-link class="nav-link" :to="{ name: 'shopping' }" active-class="active">Shopping</router-link>
</li> </li>
</ul> </ul>
</div> </div>

21
src/api/auth.js Normal file
View 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
}

View 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)
}

View file

@ -0,0 +1,69 @@
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
}

5
src/api/persons.js Normal file
View file

@ -0,0 +1,5 @@
import { http } from './http'
export async function getPersonsInHome() {
return http.get('/persons')
}

36
src/api/recipes.js Normal file
View 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
View 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)}`)
}

View file

@ -75,7 +75,7 @@ li > div {
<script> <script>
import data from '@/data.js' import { parseProduct, parseIngredients } from '@/api/recipes'
import IngredientLine from './IngredientLine.vue' import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue' import CompactParsedIngredient from './CompactParsedIngredient.vue'
@ -90,11 +90,11 @@ export default {
}, },
methods: { methods: {
async updateProduct(ingredient, product_link) { async updateProduct(ingredient, product_link) {
const product = await data.parseProduct(ingredient, product_link); const product = await parseProduct(ingredient, product_link);
this.$emit('on-update-ingredient', ingredient, { ...ingredient, product }); this.$emit('on-update-ingredient', ingredient, { ...ingredient, product });
}, },
async updateIngredient(ingredient, line) { async updateIngredient(ingredient, line) {
const newIngredients = await data.parseIngredients([line]); const newIngredients = await parseIngredients([line]);
this.$emit('on-update-ingredient', ingredient, newIngredients[0]); this.$emit('on-update-ingredient', ingredient, newIngredients[0]);
}, },
toggleEditing() { toggleEditing() {

View file

@ -65,7 +65,7 @@ input.recipe-name {
<script> <script>
import alert from '@/alert.js' import alert from '@/alert.js'
import data from '@/data.js' import { getRecipe, parseRecipe, saveRecipe, deleteRecipe as deleteRecipeApi } from '@/api/recipes'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
export default { export default {
@ -101,12 +101,12 @@ export default {
}, },
async refreshRecipe() { async refreshRecipe() {
if (this.id >= 0) { if (this.id >= 0) {
this.recipe = await data.getRecipe(this.id); this.recipe = await getRecipe(this.id);
this.link = this.recipe.link; this.link = this.recipe.link;
return; return;
} }
else if (this.link) { else if (this.link) {
this.recipe = await data.parseRecipe(this.link); this.recipe = await parseRecipe(this.link);
this.parse_failed = !this.recipe; this.parse_failed = !this.recipe;
} }
else { else {
@ -120,7 +120,7 @@ export default {
this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient); this.recipe.ingredients = this.recipe.ingredients.filter(i => i != ingredient);
}, },
async saveRecipe() { async saveRecipe() {
const recipe = await data.saveRecipe(this.recipe); const recipe = await saveRecipe(this.recipe);
if (recipe?.id >= 0) { if (recipe?.id >= 0) {
alert.show({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' }); alert.show({ heading: 'Recipe saved', message: 'Your recipe has been saved', type: 'success' });
this.$router.push(`/recipes/${recipe.id}`); this.$router.push(`/recipes/${recipe.id}`);
@ -144,7 +144,7 @@ export default {
}, },
async deleteRecipe() { async deleteRecipe() {
if (confirm('Are you sure you want to delete this recipe?')) { if (confirm('Are you sure you want to delete this recipe?')) {
await data.deleteRecipe(this.recipe.id); await deleteRecipeApi(this.recipe.id);
this.$router.push('/recipes'); this.$router.push('/recipes');
} }
} }

View file

@ -11,7 +11,7 @@
</template> </template>
<script> <script>
import data from '@/data.js' import { searchRecipes } from '@/api/recipes'
import RecipeCard from './RecipeCard.vue'; import RecipeCard from './RecipeCard.vue';
export default { export default {
@ -41,7 +41,7 @@ export default {
}, },
methods: { methods: {
async search() { async search() {
this.recipes = await data.searchRecipes(this.searchTerm) ?? this.recipes; this.recipes = await searchRecipes(this.searchTerm) ?? this.recipes;
}, },
selectRecipe(recipe) { selectRecipe(recipe) {
this.$emit('select-recipe', recipe); this.$emit('select-recipe', recipe);

View file

@ -137,7 +137,8 @@ button img {
import alert from '@/alert.js' import alert from '@/alert.js'
import data from '@/data.js' import { getCurrentShoppingList, purchaseShoppingList, requestMeal, unrequestMeal } from '@/api/shopping'
import { getUpcomingMeals } from '@/api/meals'
import { itemsToGroups, groupsToItems, uniqueMeals } from './shopping.js' import { itemsToGroups, groupsToItems, uniqueMeals } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
@ -150,7 +151,7 @@ async function saveShoppingList(outstandingItemGroups) {
return; return;
} }
return await data.purchaseShoppingList(items); return await purchaseShoppingList(items);
} }
const groupsMatch = (a, b) => { const groupsMatch = (a, b) => {
@ -206,15 +207,15 @@ export default {
}, },
methods: { methods: {
async loadData() { async loadData() {
this.upcomingMeals = await data.getUpcomingMeals(this.from, this.to); this.upcomingMeals = await getUpcomingMeals(this.from, this.to);
this.shoppingList = await data.getCurrentShoppingList(); this.shoppingList = await getCurrentShoppingList();
}, },
async mealSelected(meal) { async mealSelected(meal) {
await data.requestMeal(meal.id); await requestMeal(meal.id);
await this.loadData(); await this.loadData();
}, },
async mealUnselected(meal) { async mealUnselected(meal) {
await data.unrequestMeal(meal.id); await unrequestMeal(meal.id);
await this.loadData(); await this.loadData();
}, },
async markFound() { async markFound() {

View file

@ -36,7 +36,7 @@
</style> </style>
<script> <script>
import data from '@/data.js' import { getMyShoppingList, saveMyShoppingList } from '@/api/shopping'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue' import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
@ -47,7 +47,7 @@ export default {
return { ingredients: [], person: null } return { ingredients: [], person: null }
}, },
async beforeMount() { async beforeMount() {
const person = await data.currentUser(); const person = await this.$router.app?.config?.globalProperties?.$user || null;
if (!person) if (!person)
return this.$router.push({ name: 'login' }); return this.$router.push({ name: 'login' });
@ -57,8 +57,8 @@ export default {
methods: { methods: {
async updateShoppingList(save = false) { async updateShoppingList(save = false) {
const new_ingredients = save ? const new_ingredients = save ?
await data.saveMyShoppingList(this.ingredients) : await saveMyShoppingList(this.ingredients) :
await data.getMyShoppingList(); await getMyShoppingList();
this.ingredients = new_ingredients; this.ingredients = new_ingredients;
}, },

View file

@ -33,7 +33,7 @@
import { ago } from '@/dateformats.js' import { ago } from '@/dateformats.js'
import data from '@/data.js' import { getShoppingList } from '@/api/shopping'
import { itemsToGroups, uniqueMeals } from './shopping.js' import { itemsToGroups, uniqueMeals } from './shopping.js'
import MealSelectionList from './MealSelectionList.vue' import MealSelectionList from './MealSelectionList.vue'
@ -60,7 +60,7 @@ export default {
} }
}, },
async beforeMount() { async beforeMount() {
this.shoppingList = await data.getShoppingList(this.id); this.shoppingList = await getShoppingList(this.id);
}, },
methods: { methods: {
ago ago

View file

@ -1,10 +1,10 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { createAppRouter } from '@/router' import { createAppRouter } from '@/router'
import data from '@/data.js' import { currentUser } from '@/api/auth'
// Create the router with an auth callback to check current user // Create the router with an auth callback to check current user
const router = createAppRouter(() => data.currentUser()) const router = createAppRouter(() => currentUser())
const app = createApp(App) const app = createApp(App)
app.use(router) app.use(router)