kiwi/frontend/src/App.vue

559 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div id="app" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<!-- Desktop sidebar (hidden on mobile) -->
<aside class="sidebar" role="navigation" aria-label="Main navigation">
<!-- Wordmark + collapse toggle -->
<div class="sidebar-header">
<span class="wordmark-kiwi" @click="onWordmarkClick" style="cursor:pointer">Kiwi</span>
<button class="sidebar-toggle" @click="sidebarCollapsed = !sidebarCollapsed" :aria-label="sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="18" height="18">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<nav class="sidebar-nav">
<button :class="['sidebar-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<!-- Ramen bowl: chopsticks, rim, body, wavy noodles -->
<line x1="9" y1="2" x2="11" y2="9"/>
<line x1="15" y1="2" x2="13" y2="9"/>
<path d="M4 9 Q4 6 12 6 Q20 6 20 9"/>
<path d="M4 9 L4.5 17 Q4.5 21 12 21 Q19.5 21 19.5 17 L20 9"/>
<path d="M7 14 Q9 12 11 14 Q13 16 15 14 Q17 12 19 14"/>
</svg>
<span class="sidebar-label">Recipes</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
<rect x="3" y="11" width="18" height="4" rx="1"/>
<rect x="3" y="18" width="18" height="3" rx="1"/>
</svg>
<span class="sidebar-label">Pantry</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
<line x1="8" y1="9" x2="16" y2="9"/>
<line x1="8" y1="13" x2="14" y2="13"/>
</svg>
<span class="sidebar-label">Receipts</span>
</button>
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</svg>
<span class="sidebar-label">Settings</span>
</button>
</nav>
</aside>
<!-- Main area: header + content -->
<div class="app-body">
<!-- Mobile-only header -->
<header class="app-header">
<div class="header-inner">
<span class="wordmark-kiwi">Kiwi</span>
</div>
</header>
<main class="app-main">
<div class="container">
<div v-show="currentTab === 'inventory'" class="tab-content fade-in">
<InventoryList />
</div>
<div v-show="currentTab === 'receipts'" class="tab-content fade-in">
<ReceiptsView />
</div>
<div v-show="currentTab === 'recipes'" class="tab-content fade-in">
<RecipesView />
</div>
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
<SettingsView />
</div>
</div>
</main>
</div>
<!-- Mobile bottom nav only -->
<nav class="bottom-nav" role="navigation" aria-label="Main navigation">
<button :class="['nav-item', { active: currentTab === 'recipes' }]" @click="switchTab('recipes')" aria-label="Recipes">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<line x1="9" y1="2" x2="11" y2="9"/>
<line x1="15" y1="2" x2="13" y2="9"/>
<path d="M4 9 Q4 6 12 6 Q20 6 20 9"/>
<path d="M4 9 L4.5 17 Q4.5 21 12 21 Q19.5 21 19.5 17 L20 9"/>
<path d="M7 14 Q9 12 11 14 Q13 16 15 14 Q17 12 19 14"/>
</svg>
<span class="nav-label">Recipes</span>
</button>
<button :class="['nav-item', { active: currentTab === 'inventory' }]" @click="switchTab('inventory')" aria-label="Pantry">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="4" rx="1"/>
<rect x="3" y="11" width="18" height="4" rx="1"/>
<rect x="3" y="18" width="18" height="3" rx="1"/>
</svg>
<span class="nav-label">Pantry</span>
</button>
<button :class="['nav-item', { active: currentTab === 'receipts' }]" @click="switchTab('receipts')" aria-label="Receipts">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 4v16l2-1.5 2 1.5 2-1.5 2 1.5 2-1.5 2 1.5 2-1.5V4"/>
<line x1="8" y1="9" x2="16" y2="9"/>
<line x1="8" y1="13" x2="14" y2="13"/>
</svg>
<span class="nav-label">Receipts</span>
</button>
<button :class="['nav-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')" aria-label="Settings">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>
</svg>
<span class="nav-label">Settings</span>
</button>
</nav>
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
<FeedbackButton :current-tab="currentTab" />
<!-- Easter egg: Kiwi bird sprite — triggered by typing "kiwi" -->
<Transition name="kiwi-fade">
<div v-if="kiwiVisible" class="kiwi-bird-stage" aria-hidden="true">
<div class="kiwi-bird" :class="kiwiDirection">
<!-- Kiwi bird SVG side profile, facing left by default (rtl walk) -->
<svg class="kiwi-svg" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Body plump oval -->
<ellipse cx="30" cy="38" rx="18" ry="15" fill="#8B6914" />
<!-- Head -->
<ellipse cx="46" cy="26" rx="10" ry="9" fill="#6B4F10" />
<!-- Long beak -->
<path d="M54 25 Q66 24 70 25 Q66 27 54 27Z" fill="#C8A96E" />
<!-- Eye -->
<circle cx="49" cy="23" r="2" fill="#1a1a1a" />
<circle cx="49.7" cy="22.3" r="0.6" fill="white" />
<!-- Wing texture lines -->
<path d="M18 32 Q24 28 34 30" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
<path d="M16 37 Q22 33 32 35" stroke="#6B4F10" stroke-width="1.2" stroke-linecap="round" />
<!-- Legs -->
<line x1="24" y1="52" x2="22" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
<line x1="34" y1="52" x2="36" y2="60" stroke="#A07820" stroke-width="2.5" stroke-linecap="round" />
<!-- Feet -->
<path d="M18 60 L22 60 L24 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<path d="M32 60 L36 60 L38 57" stroke="#A07820" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" />
<!-- Feather texture -->
<path d="M22 38 Q28 34 36 36" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
<path d="M20 43 Q26 39 34 41" stroke="#A07820" stroke-width="0.8" stroke-linecap="round" />
</svg>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import InventoryList from './components/InventoryList.vue'
import ReceiptsView from './components/ReceiptsView.vue'
import RecipesView from './components/RecipesView.vue'
import SettingsView from './components/SettingsView.vue'
import FeedbackButton from './components/FeedbackButton.vue'
import { useInventoryStore } from './stores/inventory'
import { useEasterEggs } from './composables/useEasterEggs'
import { householdAPI } from './services/api'
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
const currentTab = ref<Tab>('inventory')
const sidebarCollapsed = ref(false)
const inventoryStore = useInventoryStore()
const { kiwiVisible, kiwiDirection } = useEasterEggs()
// Wordmark click counter for chef mode easter egg
const wordmarkClicks = ref(0)
let wordmarkTimer: ReturnType<typeof setTimeout> | null = null
function onWordmarkClick() {
wordmarkClicks.value++
if (wordmarkTimer) clearTimeout(wordmarkTimer)
if (wordmarkClicks.value >= 5) {
wordmarkClicks.value = 0
document.querySelector('.wordmark-kiwi')?.classList.add('chef-spin')
setTimeout(() => document.querySelector('.wordmark-kiwi')?.classList.remove('chef-spin'), 800)
} else {
wordmarkTimer = setTimeout(() => { wordmarkClicks.value = 0 }, 1200)
}
}
async function switchTab(tab: Tab) {
currentTab.value = tab
if (tab === 'recipes' && inventoryStore.items.length === 0) {
await inventoryStore.fetchItems()
}
}
onMounted(async () => {
// Handle household invite links: /#/join?household_id=xxx&token=yyy
const hash = window.location.hash
if (hash.includes('/join')) {
const params = new URLSearchParams(hash.split('?')[1] ?? '')
const householdId = params.get('household_id')
const token = params.get('token')
if (householdId && token) {
try {
const result = await householdAPI.accept(householdId, token)
alert(result.message)
// Clear the invite params from URL and reload
window.location.hash = '/'
window.location.reload()
} catch (err: unknown) {
const msg = (err instanceof Object && 'response' in err)
? ((err as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? 'Could not join household.')
: 'Could not join household.'
alert(`Failed to join: ${msg}`)
window.location.hash = '/'
}
}
}
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: var(--color-bg-primary);
color: var(--color-text-primary);
}
.wordmark-kiwi {
font-family: var(--font-display);
font-style: italic;
font-weight: 700;
color: var(--color-primary);
letter-spacing: -0.01em;
line-height: 1;
white-space: nowrap;
overflow: hidden;
}
/* ============================================
MOBILE LAYOUT (< 769px)
sidebar hidden, bottom nav visible
============================================ */
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.sidebar { display: none; }
.app-body { display: contents; }
.app-header {
background: var(--gradient-header);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-sm) var(--spacing-md);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(8px);
}
.header-inner {
display: flex;
align-items: center;
min-height: 44px;
}
.header-inner .wordmark-kiwi { font-size: 24px; }
.app-main {
flex: 1;
padding: var(--spacing-md) 0 var(--spacing-xl);
/* Clear fixed bottom nav — env() gives extra room for iPhone home bar */
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
}
.container {
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.tab-content { min-height: 0; }
/* ---- Bottom nav ---- */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 200;
background: var(--color-bg-elevated);
border-top: 1px solid var(--color-border);
display: flex;
align-items: stretch;
padding-bottom: env(safe-area-inset-bottom, 0);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25);
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 8px 4px 10px;
border: none;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.18s ease, background 0.18s ease;
border-radius: 0;
position: relative;
}
.nav-item::before {
content: '';
position: absolute;
top: 0;
left: 20%;
right: 20%;
height: 2px;
background: var(--color-primary);
border-radius: 0 0 2px 2px;
transform: scaleX(0);
transition: transform 0.18s ease;
}
.nav-item:hover {
color: var(--color-text-secondary);
background: rgba(232, 168, 32, 0.06);
transform: none;
border-color: transparent;
}
.nav-item.active { color: var(--color-primary); }
.nav-item.active::before { transform: scaleX(1); }
.nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
.nav-label {
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1;
}
@media (max-width: 480px) {
.container { padding: 0 var(--spacing-sm); }
.app-main {
padding: var(--spacing-sm) 0 var(--spacing-lg);
padding-bottom: calc(68px + env(safe-area-inset-bottom, 0px));
}
}
/* ============================================
DESKTOP LAYOUT (≥ 769px)
sidebar visible, bottom nav hidden
============================================ */
@media (min-width: 769px) {
.bottom-nav { display: none; }
#app {
flex-direction: row;
padding-bottom: 0;
min-height: 100vh;
}
/* ---- Sidebar ---- */
.sidebar {
display: flex;
flex-direction: column;
width: 200px;
min-height: 100vh;
background: var(--color-bg-elevated);
border-right: 1px solid var(--color-border);
position: sticky;
top: 0;
flex-shrink: 0;
transition: width 0.22s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
z-index: 100;
}
.sidebar-collapsed .sidebar {
width: 56px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md) var(--spacing-md) var(--spacing-sm);
min-height: 56px;
gap: var(--spacing-sm);
}
.sidebar-header .wordmark-kiwi {
font-size: 22px;
opacity: 1;
transition: opacity 0.15s ease, width 0.22s ease;
flex-shrink: 0;
}
.sidebar-collapsed .sidebar-header .wordmark-kiwi {
opacity: 0;
width: 0;
pointer-events: none;
}
.sidebar-toggle {
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 6px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: color 0.15s, background 0.15s;
}
.sidebar-toggle:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
transform: none;
border-color: transparent;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-sm);
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: 10px var(--spacing-sm);
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
width: 100%;
text-align: left;
}
.sidebar-item:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
transform: none;
border-color: transparent;
}
.sidebar-item.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.sidebar-item .nav-icon { width: 20px; height: 20px; flex-shrink: 0; }
.sidebar-label {
font-size: var(--font-size-sm);
font-weight: 600;
opacity: 1;
transition: opacity 0.12s ease;
overflow: hidden;
}
.sidebar-collapsed .sidebar-label {
opacity: 0;
width: 0;
pointer-events: none;
}
/* ---- Main body ---- */
.app-body {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0; /* prevent overflow */
contents: unset;
}
.app-header { display: none; } /* wordmark lives in sidebar on desktop */
/* Override style.css #app max-width so sidebar spans full viewport */
#app {
max-width: none;
margin: 0;
}
.app-main {
flex: 1;
padding: var(--spacing-xl) 0;
}
.container {
max-width: 860px;
padding: 0 var(--spacing-lg);
}
}
@media (min-width: 1200px) {
.container {
max-width: 960px;
padding: 0 var(--spacing-xl);
}
}
/* Easter egg: wordmark spin on 5× click */
@keyframes chefSpin {
0% { transform: rotate(0deg) scale(1); }
30% { transform: rotate(180deg) scale(1.3); }
60% { transform: rotate(340deg) scale(1.1); }
100% { transform: rotate(360deg) scale(1); }
}
.wordmark-kiwi.chef-spin {
display: inline-block;
animation: chefSpin 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
/* Kiwi bird transition */
.kiwi-fade-enter-active,
.kiwi-fade-leave-active {
transition: opacity 0.4s ease;
}
.kiwi-fade-enter-from,
.kiwi-fade-leave-to {
opacity: 0;
}
</style>