fix: frontend concurrent-mount errors, nginx routing, and browser UX (#98 #106 #107)
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

- App.vue: lazy-mount pattern (v-if + v-show) so non-active tabs only mount on
  first visit, eliminating concurrent onMounted calls across all components (#98)
- nginx.cloud.conf: add /kiwi/api/ location to proxy API calls on direct-port
  access (localhost:8515); was serving SPA HTML → causing M.map/filter/find
  TypeError chain on load (#98)
- nginx.cloud.conf: $host → $http_host so 307 redirects preserve port number (#107)
- RecipeBrowserPanel: show friendly "corpus not loaded" notice and skip auto-select
  when all category counts are 0, instead of rendering confusing empty buttons (#106)
- Defensive Array.isArray guards in inventory store, mealPlan store, ReceiptsView
This commit is contained in:
pyr0ball 2026-04-18 17:12:34 -07:00
parent bea61054fa
commit 22a3da61c3
6 changed files with 48 additions and 14 deletions

View file

@ -8,7 +8,7 @@ server {
# Proxy API requests to the FastAPI container via Docker bridge network. # Proxy API requests to the FastAPI container via Docker bridge network.
location /api/ { location /api/ {
proxy_pass http://api:8512; proxy_pass http://api:8512;
proxy_set_header Host $host; proxy_set_header Host $http_host;
# Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr # Prefer X-Real-IP set by Caddy (real client address); fall back to $remote_addr
# when accessed directly on LAN without Caddy in the path. # when accessed directly on LAN without Caddy in the path.
proxy_set_header X-Real-IP $http_x_real_ip; proxy_set_header X-Real-IP $http_x_real_ip;
@ -20,6 +20,22 @@ server {
client_max_body_size 20m; client_max_body_size 20m;
} }
# Direct-port LAN access (localhost:8515): when VITE_API_BASE='/kiwi', the frontend
# builds API calls as /kiwi/api/v1/... — proxy these to the API container.
# Through Caddy the /kiwi prefix is stripped before reaching nginx, so this block
# is only active for direct-port access without Caddy in the path.
# Longer prefix (/kiwi/api/ = 10 chars) beats ^~/kiwi/ (6 chars) per nginx rules.
location /kiwi/api/ {
rewrite ^/kiwi(/api/.*)$ $1 break;
proxy_pass http://api:8512;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $http_x_real_ip;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
proxy_set_header X-CF-Session $http_x_cf_session;
client_max_body_size 20m;
}
# When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip), # When accessed directly (localhost:8515) instead of via Caddy (/kiwi path-strip),
# Vite's /kiwi base URL means assets are requested at /kiwi/assets/... but stored # Vite's /kiwi base URL means assets are requested at /kiwi/assets/... but stored
# at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works. # at /assets/... in nginx's root. Alias /kiwi/ → root so direct port access works.

View file

@ -88,22 +88,22 @@
<main class="app-main"> <main class="app-main">
<div class="container"> <div class="container">
<div v-show="currentTab === 'inventory'" class="tab-content fade-in"> <div v-if="mountedTabs.has('inventory')" v-show="currentTab === 'inventory'" class="tab-content fade-in">
<InventoryList /> <InventoryList />
</div> </div>
<div v-show="currentTab === 'receipts'" class="tab-content fade-in"> <div v-if="mountedTabs.has('receipts')" v-show="currentTab === 'receipts'" class="tab-content fade-in">
<ReceiptsView /> <ReceiptsView />
</div> </div>
<div v-show="currentTab === 'recipes'" class="tab-content fade-in"> <div v-show="currentTab === 'recipes'" class="tab-content fade-in">
<RecipesView /> <RecipesView />
</div> </div>
<div v-show="currentTab === 'settings'" class="tab-content fade-in"> <div v-if="mountedTabs.has('settings')" v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView /> <SettingsView />
</div> </div>
<div v-show="currentTab === 'mealplan'" class="tab-content"> <div v-if="mountedTabs.has('mealplan')" v-show="currentTab === 'mealplan'" class="tab-content">
<MealPlanView /> <MealPlanView />
</div> </div>
<div v-show="currentTab === 'shopping'" class="tab-content fade-in"> <div v-if="mountedTabs.has('shopping')" v-show="currentTab === 'shopping'" class="tab-content fade-in">
<ShoppingView /> <ShoppingView />
</div> </div>
</div> </div>
@ -204,7 +204,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import InventoryList from './components/InventoryList.vue' import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue' import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue' import RecipesView from './components/RecipesView.vue'
@ -220,6 +220,10 @@ type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan' | 'sho
const currentTab = ref<Tab>('recipes') const currentTab = ref<Tab>('recipes')
const sidebarCollapsed = ref(false) const sidebarCollapsed = ref(false)
// Lazy-mount: tabs mount on first visit and stay mounted (KeepAlive-like behaviour).
// Only 'recipes' is in the initial set so non-active tabs don't mount simultaneously
// on page load eliminates concurrent onMounted calls across all tab components.
const mountedTabs = reactive(new Set<Tab>(['recipes']))
const inventoryStore = useInventoryStore() const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs() const { kiwiVisible, kiwiDirection } = useEasterEggs()
@ -239,6 +243,7 @@ function onWordmarkClick() {
} }
async function switchTab(tab: Tab) { async function switchTab(tab: Tab) {
mountedTabs.add(tab)
currentTab.value = tab currentTab.value = tab
if (tab === 'recipes' && inventoryStore.items.length === 0) { if (tab === 'recipes' && inventoryStore.items.length === 0) {
await inventoryStore.fetchItems() await inventoryStore.fetchItems()

View file

@ -175,7 +175,8 @@ async function uploadFile(file: File) {
async function loadReceipts() { async function loadReceipts() {
try { try {
const data = await receiptsAPI.listReceipts() const raw = await receiptsAPI.listReceipts()
const data = Array.isArray(raw) ? raw : []
// Fetch OCR data for each receipt // Fetch OCR data for each receipt
receipts.value = await Promise.all( receipts.value = await Promise.all(
data.map(async (receipt: any) => { data.map(async (receipt: any) => {

View file

@ -15,8 +15,13 @@
<div v-if="loadingDomains" class="text-secondary text-sm">Loading</div> <div v-if="loadingDomains" class="text-secondary text-sm">Loading</div>
<div v-else-if="activeDomain" class="browser-body"> <div v-else-if="activeDomain" class="browser-body">
<!-- Corpus unavailable notice shown when all category counts are 0 -->
<div v-if="allCountsZero" class="browser-unavailable card p-md text-secondary text-sm">
Recipe library is not available on this instance yet. Browse categories will appear once the recipe corpus is loaded.
</div>
<!-- Category list + Surprise Me --> <!-- Category list + Surprise Me -->
<div class="category-list mb-md flex flex-wrap gap-xs"> <div v-else class="category-list mb-md flex flex-wrap gap-xs">
<button <button
v-for="cat in categories" v-for="cat in categories"
:key="cat.category" :key="cat.category"
@ -101,7 +106,7 @@
</template> </template>
</template> </template>
<div v-else class="text-secondary text-sm">Loading recipes</div> <div v-else-if="!allCountsZero" class="text-secondary text-sm">Loading recipes</div>
</div> </div>
<div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading</div> <div v-else-if="!loadingDomains" class="text-secondary text-sm">Loading</div>
@ -145,6 +150,9 @@ const loadingRecipes = ref(false)
const savingRecipe = ref<BrowserRecipe | null>(null) const savingRecipe = ref<BrowserRecipe | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const allCountsZero = computed(() =>
categories.value.length > 0 && categories.value.every(c => c.recipe_count === 0)
)
const pantryItems = computed(() => const pantryItems = computed(() =>
inventoryStore.items inventoryStore.items
@ -179,8 +187,10 @@ async function selectDomain(domainId: string) {
total.value = 0 total.value = 0
page.value = 1 page.value = 1
categories.value = await browserAPI.listCategories(domainId) categories.value = await browserAPI.listCategories(domainId)
// Auto-select the most-populated category so content appears immediately // Auto-select the most-populated category so content appears immediately.
if (categories.value.length > 0) { // Skip when all counts are 0 (corpus not seeded) no point loading an empty result.
const hasRecipes = categories.value.some(c => c.recipe_count > 0)
if (hasRecipes) {
const top = categories.value.reduce((best, c) => const top = categories.value.reduce((best, c) =>
c.recipe_count > best.recipe_count ? c : best, categories.value[0]!) c.recipe_count > best.recipe_count ? c : best, categories.value[0]!)
selectCategory(top.category) selectCategory(top.category)

View file

@ -55,11 +55,12 @@ export const useInventoryStore = defineStore('inventory', () => {
error.value = null error.value = null
try { try {
items.value = await inventoryAPI.listItems({ const result = await inventoryAPI.listItems({
item_status: statusFilter.value === 'all' ? undefined : statusFilter.value, item_status: statusFilter.value === 'all' ? undefined : statusFilter.value,
location: locationFilter.value === 'all' ? undefined : locationFilter.value, location: locationFilter.value === 'all' ? undefined : locationFilter.value,
limit: 1000, limit: 1000,
}) })
items.value = Array.isArray(result) ? result : []
} catch (err: any) { } catch (err: any) {
error.value = err.response?.data?.detail || 'Failed to fetch inventory items' error.value = err.response?.data?.detail || 'Failed to fetch inventory items'
console.error('Error fetching inventory:', err) console.error('Error fetching inventory:', err)

View file

@ -34,7 +34,8 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
async function loadPlans() { async function loadPlans() {
loading.value = true loading.value = true
try { try {
plans.value = await mealPlanAPI.list() const result = await mealPlanAPI.list()
plans.value = Array.isArray(result) ? result : []
} finally { } finally {
loading.value = false loading.value = false
} }