- 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:
parent
bea61054fa
commit
22a3da61c3
6 changed files with 48 additions and 14 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue