Compare commits

...

9 Commits

Author SHA1 Message Date
Pantry Lead Agent
14e5cab7bb feat: add PWA manifest configuration (#32)
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
- Install @vite-pwa/nuxt module
- Configure PWA manifest with app metadata
- Set up Workbox service worker configuration
- Add runtime caching for Supabase API
- Enable PWA dev mode for testing
- Configure icons and screenshots (placeholders for #33)

Closes #32
2026-02-25 00:03:24 +00:00
229cb2cc90 Merge pull request 'feat: add TagManager and tag filtering (#30 #31)' (#53) from feature/issue-30-31-tag-manager-filter into develop
Some checks failed
Deploy to Coolify / Code Quality (push) Has been cancelled
Deploy to Coolify / Run Tests (push) Has been cancelled
Deploy to Coolify / Deploy to Development (push) Has been cancelled
Deploy to Coolify / Deploy to Production (push) Has been cancelled
Deploy to Coolify / Deploy to Test (push) Has been cancelled
2026-02-24 00:07:51 +00:00
Pantry Lead Agent
d4d3d9390c feat: add TagManager and tag filtering (#30 #31)
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Issue #30 - TagManager component:
- Create/delete tags with name, category, icon, color
- Color picker with hex input
- Organized display by category
- Integrated in settings page with tabs

Issue #31 - Tag filter for inventory:
- TagFilter component with multi-select
- Filter button in inventory header
- Active filter display with removable badges
- Filters items by selected tags (OR logic)
- Clean "Clear" button

Updates:
- Extended useTags composable with createTag, deleteTag
- Enhanced settings page with tab navigation
- Improved inventory filtering UX

Closes #30, #31
2026-02-24 00:07:37 +00:00
12c5304638 Merge pull request 'feat: create and integrate tag components (#26 #27 #28 #29)' (#52) from feature/issue-26-27-tag-components into develop
Some checks failed
Deploy to Coolify / Code Quality (push) Has been cancelled
Deploy to Coolify / Run Tests (push) Has been cancelled
Deploy to Coolify / Deploy to Development (push) Has been cancelled
Deploy to Coolify / Deploy to Production (push) Has been cancelled
Deploy to Coolify / Deploy to Test (push) Has been cancelled
2026-02-24 00:05:58 +00:00
Pantry Lead Agent
080d2424c8 feat: integrate TagBadge and TagPicker in inventory (#28 #29)
Some checks failed
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Issue #28 - Tag assignment in AddItemForm:
- Replace custom tag selection with TagPicker component
- Simplified code (removed manual tag state management)
- Cleaner UI with reusable component

Issue #29 - Display tags in InventoryList:
- Replace UBadge with TagBadge in InventoryCard
- Automatic contrast color for readability
- Consistent tag display across app

Closes #28, #29
2026-02-24 00:05:44 +00:00
Pantry Lead Agent
6b1c34ceff feat: create TagBadge and TagPicker components (#26 #27)
TagBadge:
- Display tag with icon, name, color
- Automatic contrast text color (light/dark)
- Optional removable with X button
- Configurable size (sm/md/lg)

TagPicker:
- Select multiple tags by category
- Visual feedback for selected tags
- Category-based organization
- Position category prioritized
- Two-way binding with v-model

Closes #26, #27
2026-02-24 00:05:12 +00:00
231f594004 Merge pull request 'feat: implement scan-to-add flow (#25)' (#51) from feature/issue-25-scan-to-add-flow into develop
Some checks failed
Deploy to Coolify / Deploy to Test (push) Has been cancelled
Deploy to Coolify / Code Quality (push) Has been cancelled
Deploy to Coolify / Run Tests (push) Has been cancelled
Deploy to Coolify / Deploy to Development (push) Has been cancelled
Deploy to Coolify / Deploy to Production (push) Has been cancelled
2026-02-24 00:04:23 +00:00
Pantry Lead Agent
7d35a3e7b3 feat: implement scan-to-add flow (#25)
Some checks failed
Deploy to Coolify / Run Tests (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Development (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Production (pull_request) Has been cancelled
Deploy to Coolify / Deploy to Test (pull_request) Has been cancelled
Pull Request Checks / Validate PR (pull_request) Has been cancelled
Deploy to Coolify / Code Quality (pull_request) Has been cancelled
- Create useProductLookup composable
- Integrate real product lookup in scan page
- Add query parameter handling in index.vue
- Pre-fill AddItemForm from scan data
- Parse quantity and unit from product data
- Include barcode and brand in notes

Complete end-to-end scan workflow:
1. Scan barcode
2. Fetch from Open Food Facts
3. Navigate to inventory with data
4. Pre-filled add form
5. One-click add to inventory

Closes #25
2026-02-24 00:04:10 +00:00
670b2f9200 Merge pull request 'feat: add product-lookup Edge Function (#24)' (#50) from feature/issue-24-product-lookup into develop
Some checks failed
Deploy to Coolify / Code Quality (push) Has been cancelled
Deploy to Coolify / Run Tests (push) Has been cancelled
Deploy to Coolify / Deploy to Development (push) Has been cancelled
Deploy to Coolify / Deploy to Production (push) Has been cancelled
Deploy to Coolify / Deploy to Test (push) Has been cancelled
2026-02-24 00:03:04 +00:00
15 changed files with 18417 additions and 176 deletions

View File

@@ -68,37 +68,7 @@
<!-- Tags -->
<UFormGroup label="Tags" hint="Optional">
<div class="space-y-2">
<!-- Selected Tags -->
<div v-if="selectedTags.length > 0" class="flex flex-wrap gap-1 mb-2">
<UBadge
v-for="tag in selectedTags"
:key="tag.id"
:style="{ backgroundColor: tag.color }"
class="text-white cursor-pointer"
@click="removeTag(tag.id)"
>
{{ tag.icon }} {{ tag.name }}
</UBadge>
</div>
<!-- Tag Selection by Category -->
<div v-for="category in tagCategories" :key="category.name" class="space-y-1">
<p class="text-xs font-medium text-gray-500 uppercase">{{ category.name }}</p>
<div class="flex flex-wrap gap-1">
<UButton
v-for="tag in category.tags"
:key="tag.id"
size="xs"
:color="isTagSelected(tag.id) ? 'primary' : 'gray'"
:variant="isTagSelected(tag.id) ? 'solid' : 'outline'"
@click="toggleTag(tag)"
>
{{ tag.icon }} {{ tag.name }}
</UButton>
</div>
</div>
</div>
<TagsTagPicker v-model="selectedTags" />
</UFormGroup>
<!-- Submit -->
@@ -129,7 +99,16 @@
<script setup lang="ts">
const { addInventoryItem, addItemTags } = useInventory()
const { getUnits } = useUnits()
const { getTags } = useTags()
const props = defineProps<{
initialData?: {
barcode?: string
name?: string
brand?: string
image_url?: string
quantity?: string
}
}>()
const emit = defineEmits<{
close: []
@@ -148,24 +127,52 @@ const form = reactive({
const submitting = ref(false)
const selectedTags = ref<any[]>([])
// Load units and tags
// Load units
const units = ref<any[]>([])
const tags = ref<any[]>([])
onMounted(async () => {
const [unitsResult, tagsResult] = await Promise.all([
getUnits(),
getTags()
])
const unitsResult = await getUnits()
units.value = unitsResult.data || []
tags.value = tagsResult.data || []
// Set default unit (Piece)
const defaultUnit = units.value.find(u => u.abbreviation === 'pc')
if (defaultUnit) {
form.unit_id = defaultUnit.id
}
// Pre-fill from initial data (scan-to-add flow)
if (props.initialData) {
if (props.initialData.name) {
form.name = props.initialData.name
}
// Add brand to notes if available
if (props.initialData.brand) {
form.notes = `Brand: ${props.initialData.brand}`
if (props.initialData.barcode) {
form.notes += `\nBarcode: ${props.initialData.barcode}`
}
} else if (props.initialData.barcode) {
form.notes = `Barcode: ${props.initialData.barcode}`
}
// Parse quantity if available (e.g., "750g")
if (props.initialData.quantity) {
const quantityMatch = props.initialData.quantity.match(/^([\d.]+)\s*([a-zA-Z]+)$/)
if (quantityMatch) {
form.quantity = parseFloat(quantityMatch[1])
// Try to match unit
const unitAbbr = quantityMatch[2].toLowerCase()
const matchedUnit = units.value.find(u =>
u.abbreviation.toLowerCase() === unitAbbr
)
if (matchedUnit) {
form.unit_id = matchedUnit.id
}
}
}
}
})
// Unit options for select
@@ -187,39 +194,6 @@ const unitOptions = computed(() => {
])
})
// Tag categories for display
const tagCategories = computed(() => {
const categories: Record<string, any[]> = {}
for (const tag of tags.value) {
const cat = tag.category
if (!categories[cat]) categories[cat] = []
categories[cat].push(tag)
}
return Object.entries(categories).map(([name, tags]) => ({
name,
tags
}))
})
// Tag selection helpers
const isTagSelected = (tagId: string) => {
return selectedTags.value.some(t => t.id === tagId)
}
const toggleTag = (tag: any) => {
if (isTagSelected(tag.id)) {
removeTag(tag.id)
} else {
selectedTags.value.push(tag)
}
}
const removeTag = (tagId: string) => {
selectedTags.value = selectedTags.value.filter(t => t.id !== tagId)
}
// Validation
const isValid = computed(() => {
return form.name.trim().length > 0 && form.quantity > 0 && form.unit_id

View File

@@ -50,15 +50,12 @@
<!-- Tags -->
<div v-if="item.tags && item.tags.length > 0" class="flex flex-wrap gap-1">
<UBadge
<TagsTagBadge
v-for="tagItem in item.tags.slice(0, 3)"
:key="tagItem.tag.id"
:style="{ backgroundColor: tagItem.tag.color }"
size="xs"
class="text-white"
>
{{ tagItem.tag.icon }} {{ tagItem.tag.name }}
</UBadge>
:tag="tagItem.tag"
size="sm"
/>
<UBadge v-if="item.tags.length > 3" size="xs" color="gray">
+{{ item.tags.length - 3 }}
</UBadge>

View File

@@ -43,7 +43,7 @@
<!-- Inventory Grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<InventoryCard
v-for="item in items"
v-for="item in filteredItems"
:key="item.id"
:item="item"
@edit="$emit('edit-item', item)"
@@ -59,6 +59,7 @@ const { getInventory, deleteInventoryItem, updateQuantity } = useInventory()
const props = defineProps<{
refresh?: boolean
tagFilters?: string[]
}>()
const emit = defineEmits<{
@@ -86,6 +87,21 @@ const loadInventory = async () => {
loading.value = false
}
// Computed filtered items
const filteredItems = computed(() => {
if (!props.tagFilters || props.tagFilters.length === 0) {
return items.value
}
// Filter items that have at least one of the selected tags
return items.value.filter(item => {
if (!item.tags || item.tags.length === 0) return false
const itemTagIds = item.tags.map((t: any) => t.tag.id)
return props.tagFilters!.some(filterId => itemTagIds.includes(filterId))
})
})
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this item?')) {
return

View File

@@ -0,0 +1,71 @@
<template>
<UBadge
:style="badgeStyle"
:class="badgeClasses"
v-bind="$attrs"
>
<span v-if="tag.icon" class="mr-1">{{ tag.icon }}</span>
<span>{{ tag.name }}</span>
<UButton
v-if="removable"
icon="i-heroicons-x-mark"
size="2xs"
color="white"
variant="link"
class="ml-1 -mr-1"
@click.stop="$emit('remove', tag.id)"
/>
</UBadge>
</template>
<script setup lang="ts">
interface Tag {
id: string
name: string
color: string
icon?: string
category: string
}
const props = withDefaults(defineProps<{
tag: Tag
removable?: boolean
size?: 'sm' | 'md' | 'lg'
}>(), {
removable: false,
size: 'md'
})
defineEmits<{
remove: [tagId: string]
}>()
const badgeStyle = computed(() => ({
backgroundColor: props.tag.color,
color: getContrastColor(props.tag.color)
}))
const badgeClasses = computed(() => ({
'cursor-pointer': props.removable,
'text-xs px-2 py-1': props.size === 'sm',
'text-sm px-2.5 py-1': props.size === 'md',
'text-base px-3 py-1.5': props.size === 'lg'
}))
// Calculate contrast color for text (black or white)
function getContrastColor(hexColor: string): string {
// Remove # if present
const hex = hexColor.replace('#', '')
// Convert to RGB
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
// Return white for dark colors, black for light colors
return luminance > 0.5 ? '#000000' : '#FFFFFF'
}
</script>

View File

@@ -0,0 +1,127 @@
<template>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">Filter by Tags</h4>
<UButton
v-if="selectedTagIds.length > 0"
size="xs"
color="gray"
variant="ghost"
@click="clearFilters"
>
Clear
</UButton>
</div>
<!-- Selected Tags (active filters) -->
<div v-if="selectedTagIds.length > 0" class="flex flex-wrap gap-1">
<TagsTagBadge
v-for="tagId in selectedTagIds"
:key="tagId"
:tag="findTag(tagId)!"
size="sm"
removable
@remove="toggleTag"
/>
</div>
<!-- Available Tags by Category -->
<div v-for="category in tagsByCategory" :key="category.name" class="space-y-2">
<p class="text-xs font-medium text-gray-500 uppercase">{{ category.name }}</p>
<div class="flex flex-wrap gap-1">
<UButton
v-for="tag in category.tags"
:key="tag.id"
size="xs"
:color="isSelected(tag.id) ? 'primary' : 'gray'"
:variant="isSelected(tag.id) ? 'solid' : 'soft'"
@click="toggleTag(tag.id)"
>
<span v-if="tag.icon">{{ tag.icon }}</span>
<span class="ml-1">{{ tag.name }}</span>
</UButton>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-4">
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-500"></div>
</div>
</div>
</template>
<script setup lang="ts">
interface Tag {
id: string
name: string
color: string
icon?: string
category: string
}
const props = defineProps<{
modelValue: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [tagIds: string[]]
}>()
const { getTags } = useTags()
const availableTags = ref<Tag[]>([])
const loading = ref(true)
// Load tags on mount
onMounted(async () => {
const { data } = await getTags()
if (data) {
availableTags.value = data
}
loading.value = false
})
// Computed
const selectedTagIds = computed(() => props.modelValue)
const tagsByCategory = computed(() => {
const grouped: Record<string, Tag[]> = {}
for (const tag of availableTags.value) {
if (!grouped[tag.category]) {
grouped[tag.category] = []
}
grouped[tag.category].push(tag)
}
return Object.entries(grouped).map(([name, tags]) => ({
name: name.charAt(0).toUpperCase() + name.slice(1),
tags: tags.sort((a, b) => a.name.localeCompare(b.name))
})).sort((a, b) => {
if (a.name === 'Position') return -1
if (b.name === 'Position') return 1
return a.name.localeCompare(b.name)
})
})
// Methods
const isSelected = (tagId: string) => {
return selectedTagIds.value.includes(tagId)
}
const toggleTag = (tagId: string) => {
if (isSelected(tagId)) {
emit('update:modelValue', selectedTagIds.value.filter(id => id !== tagId))
} else {
emit('update:modelValue', [...selectedTagIds.value, tagId])
}
}
const clearFilters = () => {
emit('update:modelValue', [])
}
const findTag = (tagId: string) => {
return availableTags.value.find(t => t.id === tagId)
}
</script>

View File

@@ -0,0 +1,212 @@
<template>
<div class="space-y-6">
<!-- Add New Tag Form -->
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Create New Tag</h3>
</template>
<form @submit.prevent="handleCreate" class="space-y-4">
<div class="grid grid-cols-2 gap-3">
<UFormGroup label="Name" required>
<UInput
v-model="newTag.name"
placeholder="e.g. Freezer, Vegan"
size="md"
/>
</UFormGroup>
<UFormGroup label="Category" required>
<USelect
v-model="newTag.category"
:options="categoryOptions"
placeholder="Select category"
size="md"
/>
</UFormGroup>
</div>
<div class="grid grid-cols-2 gap-3">
<UFormGroup label="Icon" hint="Single emoji">
<UInput
v-model="newTag.icon"
placeholder="❄️"
maxlength="2"
size="md"
/>
</UFormGroup>
<UFormGroup label="Color" required>
<div class="flex gap-2">
<UInput
v-model="newTag.color"
type="color"
class="w-16"
/>
<UInput
v-model="newTag.color"
placeholder="#3B82F6"
class="flex-1"
size="md"
/>
</div>
</UFormGroup>
</div>
<UButton
type="submit"
color="primary"
:loading="creating"
:disabled="!isFormValid"
>
Create Tag
</UButton>
</form>
</UCard>
<!-- Existing Tags -->
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Existing Tags</h3>
</template>
<div v-if="loading" class="text-center py-8">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
<p class="text-gray-600 mt-2">Loading tags...</p>
</div>
<div v-else-if="tagsByCategory.length === 0" class="text-center py-8">
<p class="text-gray-500">No tags yet. Create your first tag above!</p>
</div>
<div v-else class="space-y-4">
<div v-for="category in tagsByCategory" :key="category.name">
<h4 class="text-sm font-semibold text-gray-600 uppercase mb-2">
{{ category.name }}
</h4>
<div class="space-y-2">
<div
v-for="tag in category.tags"
:key="tag.id"
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100"
>
<div class="flex items-center gap-3">
<TagsTagBadge :tag="tag" size="md" />
<span class="text-sm text-gray-500">{{ tag.color }}</span>
</div>
<UButton
icon="i-heroicons-trash"
color="red"
variant="ghost"
size="sm"
@click="handleDelete(tag.id, tag.name)"
/>
</div>
</div>
</div>
</div>
</UCard>
</div>
</template>
<script setup lang="ts">
const { getTags, createTag, deleteTag } = useTags()
const tags = ref<any[]>([])
const loading = ref(true)
const creating = ref(false)
const newTag = reactive({
name: '',
category: '',
icon: '',
color: '#3B82F6'
})
const categoryOptions = [
{ label: 'Position', value: 'position' },
{ label: 'Type', value: 'type' },
{ label: 'Custom', value: 'custom' }
]
// Load tags on mount
const loadTags = async () => {
loading.value = true
const { data } = await getTags()
if (data) {
tags.value = data
}
loading.value = false
}
onMounted(loadTags)
// Computed
const tagsByCategory = computed(() => {
const grouped: Record<string, any[]> = {}
for (const tag of tags.value) {
if (!grouped[tag.category]) {
grouped[tag.category] = []
}
grouped[tag.category].push(tag)
}
return Object.entries(grouped).map(([name, tags]) => ({
name: name.charAt(0).toUpperCase() + name.slice(1),
tags: tags.sort((a, b) => a.name.localeCompare(b.name))
})).sort((a, b) => {
if (a.name === 'Position') return -1
if (b.name === 'Position') return 1
return a.name.localeCompare(b.name)
})
})
const isFormValid = computed(() => {
return newTag.name.trim() && newTag.category && newTag.color
})
// Methods
const handleCreate = async () => {
if (!isFormValid.value) return
creating.value = true
const { data, error } = await createTag({
name: newTag.name.trim(),
category: newTag.category,
icon: newTag.icon.trim() || null,
color: newTag.color
})
if (error) {
alert('Failed to create tag: ' + error.message)
} else {
// Reset form
newTag.name = ''
newTag.category = ''
newTag.icon = ''
newTag.color = '#3B82F6'
// Reload tags
await loadTags()
}
creating.value = false
}
const handleDelete = async (tagId: string, tagName: string) => {
if (!confirm(`Delete tag "${tagName}"? This will remove it from all items.`)) {
return
}
const { error } = await deleteTag(tagId)
if (error) {
alert('Failed to delete tag: ' + error.message)
} else {
await loadTags()
}
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div class="space-y-4">
<!-- Selected Tags -->
<div v-if="selectedTags.length > 0" class="flex flex-wrap gap-2">
<TagsTagBadge
v-for="tag in selectedTags"
:key="tag.id"
:tag="tag"
:removable="true"
@remove="removeTag"
/>
</div>
<!-- Empty State -->
<div v-else class="text-sm text-gray-500 italic">
No tags selected
</div>
<!-- Tag Selection by Category -->
<div v-for="category in tagsByCategory" :key="category.name" class="space-y-2">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide">
{{ category.name }}
</h4>
<div class="flex flex-wrap gap-2">
<UButton
v-for="tag in category.tags"
:key="tag.id"
size="sm"
:color="isSelected(tag.id) ? 'primary' : 'gray'"
:variant="isSelected(tag.id) ? 'solid' : 'outline'"
@click="toggleTag(tag)"
>
<span v-if="tag.icon" class="mr-1">{{ tag.icon }}</span>
{{ tag.name }}
</UButton>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-4">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-primary-500"></div>
<p class="text-sm text-gray-500 mt-2">Loading tags...</p>
</div>
<!-- Empty State (no tags available) -->
<div v-if="!loading && availableTags.length === 0" class="text-center py-4">
<p class="text-gray-500">No tags available</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Tag {
id: string
name: string
color: string
icon?: string
category: string
}
const props = defineProps<{
modelValue: Tag[]
}>()
const emit = defineEmits<{
'update:modelValue': [tags: Tag[]]
}>()
const { getTags } = useTags()
const availableTags = ref<Tag[]>([])
const loading = ref(true)
// Load tags on mount
onMounted(async () => {
const { data, error } = await getTags()
if (data) {
availableTags.value = data
}
loading.value = false
})
// Computed
const selectedTags = computed(() => props.modelValue)
const tagsByCategory = computed(() => {
const grouped: Record<string, Tag[]> = {}
for (const tag of availableTags.value) {
if (!grouped[tag.category]) {
grouped[tag.category] = []
}
grouped[tag.category].push(tag)
}
return Object.entries(grouped).map(([name, tags]) => ({
name: name.charAt(0).toUpperCase() + name.slice(1),
tags: tags.sort((a, b) => a.name.localeCompare(b.name))
})).sort((a, b) => {
// Position category first, then others alphabetically
if (a.name === 'Position') return -1
if (b.name === 'Position') return 1
return a.name.localeCompare(b.name)
})
})
// Methods
const isSelected = (tagId: string) => {
return selectedTags.value.some(t => t.id === tagId)
}
const toggleTag = (tag: Tag) => {
const isCurrentlySelected = isSelected(tag.id)
if (isCurrentlySelected) {
removeTag(tag.id)
} else {
emit('update:modelValue', [...selectedTags.value, tag])
}
}
const removeTag = (tagId: string) => {
emit('update:modelValue', selectedTags.value.filter(t => t.id !== tagId))
}
</script>

View File

@@ -0,0 +1,61 @@
// Composable for product lookup via Edge Function
export interface ProductData {
barcode: string
name: string
brand?: string
quantity?: string
image_url?: string
category?: string
cached?: boolean
}
export const useProductLookup = () => {
const supabase = useSupabaseClient()
const isLoading = ref(false)
const error = ref<string | null>(null)
const lookupProduct = async (barcode: string): Promise<ProductData | null> => {
isLoading.value = true
error.value = null
try {
const { data, error: functionError } = await supabase.functions.invoke('product-lookup', {
body: { barcode }
})
if (functionError) {
console.error('Product lookup error:', functionError)
error.value = functionError.message || 'Failed to lookup product'
// Return basic product data even on error
return {
barcode,
name: `Product ${barcode}`,
cached: false
}
}
return data as ProductData
} catch (err) {
console.error('Unexpected error during product lookup:', err)
error.value = err instanceof Error ? err.message : 'Unknown error'
// Return basic product data even on error
return {
barcode,
name: `Product ${barcode}`,
cached: false
}
} finally {
isLoading.value = false
}
}
return {
lookupProduct,
isLoading: readonly(isLoading),
error: readonly(error)
}
}

View File

@@ -37,8 +37,50 @@ export const useTags = () => {
return { data, error: null }
}
/**
* Create a new tag
*/
const createTag = async (tag: {
name: string
category: string
icon?: string | null
color: string
}) => {
const { data, error } = await supabase
.from('tags')
.insert(tag)
.select()
.single()
if (error) {
console.error('Error creating tag:', error)
return { data: null, error }
}
return { data, error: null }
}
/**
* Delete a tag
*/
const deleteTag = async (tagId: string) => {
const { error } = await supabase
.from('tags')
.delete()
.eq('id', tagId)
if (error) {
console.error('Error deleting tag:', error)
return { error }
}
return { error: null }
}
return {
getTags,
getTagsByCategory
getTagsByCategory,
createTag,
deleteTag
}
}

View File

@@ -5,7 +5,8 @@ export default defineNuxtConfig({
modules: [
'@nuxt/ui',
'@nuxt/fonts'
'@nuxt/fonts',
'@vite-pwa/nuxt'
],
runtimeConfig: {
@@ -17,5 +18,102 @@ export default defineNuxtConfig({
colorMode: {
preference: 'light'
},
pwa: {
registerType: 'autoUpdate',
manifest: {
name: 'Pantry - Smart Inventory Manager',
short_name: 'Pantry',
description: 'Track your household pantry inventory with ease. Barcode scanning, smart organization, and multi-user support.',
theme_color: '#10b981',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
categories: ['productivity', 'lifestyle'],
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any'
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any'
},
{
src: '/icon-192x192-maskable.png',
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: '/icon-512x512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
],
screenshots: [
{
src: '/screenshot-mobile.png',
sizes: '390x844',
type: 'image/png',
form_factor: 'narrow',
label: 'Pantry inventory view on mobile'
},
{
src: '/screenshot-desktop.png',
sizes: '1920x1080',
type: 'image/png',
form_factor: 'wide',
label: 'Pantry inventory view on desktop'
}
]
},
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
cleanupOutdatedCaches: true,
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.supabase\.co\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'supabase-api',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/.*\.supabase\.co\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'supabase-data',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24 // 24 hours
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},
devOptions: {
enabled: true,
type: 'module'
}
}
})

17494
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
"@nuxtjs/tailwindcss": "^6.14.0",
"@vite-pwa/nuxt": "^1.1.1"
}
}

View File

@@ -21,14 +21,29 @@
>
Add Manually
</UButton>
<UButton
color="gray"
size="lg"
icon="i-heroicons-funnel"
@click="showFilters = !showFilters"
>
Filter
</UButton>
</div>
</div>
<!-- Tag Filters -->
<UCard v-if="showFilters" class="mb-6">
<TagsTagFilter v-model="selectedTagFilters" />
</UCard>
<!-- Add Item Form (Overlay) -->
<div v-if="showAddForm" class="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4 bg-black/50">
<div class="w-full max-w-lg">
<AddItemForm
@close="showAddForm = false"
:initial-data="prefilledData"
@close="handleCloseAddForm"
@added="handleItemAdded"
/>
</div>
@@ -45,6 +60,7 @@
<InventoryList
ref="inventoryListRef"
:refresh="refreshKey"
:tag-filters="selectedTagFilters"
@add-item="showAddForm = true"
@edit-item="editingItem = $event"
/>
@@ -56,13 +72,44 @@ definePageMeta({
layout: 'default'
})
const route = useRoute()
const router = useRouter()
const showAddForm = ref(false)
const showFilters = ref(false)
const editingItem = ref<any>(null)
const refreshKey = ref(0)
const inventoryListRef = ref()
const prefilledData = ref<any>(null)
const selectedTagFilters = ref<string[]>([])
// Handle scan-to-add flow (Issue #25)
onMounted(() => {
if (route.query.action === 'add') {
// Pre-fill data from query params (from scan)
prefilledData.value = {
barcode: route.query.barcode as string || undefined,
name: route.query.name as string || undefined,
brand: route.query.brand as string || undefined,
image_url: route.query.image_url as string || undefined,
quantity: route.query.quantity as string || undefined,
}
showAddForm.value = true
// Clean up URL
router.replace({ query: {} })
}
})
const handleCloseAddForm = () => {
showAddForm.value = false
prefilledData.value = null
}
const handleItemAdded = (item: any) => {
showAddForm.value = false
prefilledData.value = null
// Reload the inventory list
inventoryListRef.value?.reload()
}

View File

@@ -72,49 +72,33 @@ definePageMeta({
const scannedBarcode = ref<string | null>(null)
const productData = ref<any>(null)
const isLookingUp = ref(false)
const lookupError = ref<string | null>(null)
const showManualEntry = ref(false)
// Use product lookup composable
const { lookupProduct, isLoading: isLookingUp, error: lookupError } = useProductLookup()
const handleBarcodeDetected = async (barcode: string) => {
scannedBarcode.value = barcode
lookupError.value = null
isLookingUp.value = true
try {
// TODO: Implement product lookup via Edge Function (Issue #24)
// For now, create a basic product object
await new Promise(resolve => setTimeout(resolve, 1000)) // Simulate API call
// Fetch product data from Edge Function
const data = await lookupProduct(barcode)
productData.value = {
name: `Product ${barcode}`,
brand: 'Unknown Brand',
barcode: barcode,
image_url: null
}
lookupError.value = 'Product lookup not yet implemented. Using default data.'
} catch (error) {
console.error('Product lookup error:', error)
lookupError.value = 'Failed to look up product. You can still add it manually.'
productData.value = {
name: `Product ${barcode}`,
barcode: barcode
}
} finally {
isLookingUp.value = false
if (data) {
productData.value = data
}
}
const addToInventory = () => {
// TODO: Implement scan-to-add flow (Issue #25)
// Navigate to add form with pre-filled data
// Navigate to home page with add form open and pre-filled
navigateTo({
path: '/',
query: {
action: 'add',
barcode: scannedBarcode.value,
name: productData.value?.name,
brand: productData.value?.brand
name: productData.value?.name || undefined,
brand: productData.value?.brand || undefined,
image_url: productData.value?.image_url || undefined,
quantity: productData.value?.quantity || undefined
}
})
}

View File

@@ -2,73 +2,65 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 mb-6">Settings</h1>
<div class="grid gap-6 md:grid-cols-2">
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Account</h3>
</template>
<div class="space-y-4">
<div v-if="user">
<label class="text-sm font-medium text-gray-700">Email</label>
<p class="text-gray-900">{{ user.email }}</p>
<UTabs :items="tabs" v-model="activeTab">
<template #account>
<UCard class="mt-4">
<div class="space-y-4">
<h3 class="text-lg font-semibold">Account Settings</h3>
<p class="text-gray-600">Account management will be implemented in future updates.</p>
</div>
</UCard>
</template>
<UButton
v-if="!user"
to="/auth/login"
color="primary"
>
Sign In
</UButton>
<template #tags>
<div class="mt-4">
<TagsTagManager />
</div>
</UCard>
</template>
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Tags</h3>
</template>
<p class="text-gray-600">
Manage your custom tags here (coming in Week 2).
</p>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-semibold">Units</h3>
</template>
<p class="text-gray-600">
Manage your custom units here (coming in Week 2).
</p>
</UCard>
<UCard>
<template #header>
<h3 class="text-lg font-semibold">About</h3>
</template>
<div class="space-y-2 text-sm text-gray-600">
<p><strong>Pantry</strong> v0.1.0-alpha</p>
<p>Self-hosted inventory management</p>
<a
href="https://github.com/pantry-app/pantry"
target="_blank"
class="text-primary hover:underline"
>
View on GitHub
</a>
</div>
</UCard>
</div>
<template #about>
<UCard class="mt-4">
<div class="space-y-4">
<h3 class="text-lg font-semibold">About Pantry</h3>
<p class="text-gray-600">Version 0.1.0 (MVP)</p>
<p class="text-gray-600">Self-hosted pantry management app with barcode scanning.</p>
<UButton
to="https://github.com/pantry-app/pantry"
target="_blank"
color="gray"
variant="soft"
>
View on GitHub
</UButton>
</div>
</UCard>
</template>
</UTabs>
</div>
</template>
<script setup lang="ts">
const { user } = useSupabaseAuth()
definePageMeta({
layout: 'default'
})
const activeTab = ref('tags')
const tabs = [
{
key: 'tags',
label: 'Tags',
icon: 'i-heroicons-tag'
},
{
key: 'account',
label: 'Account',
icon: 'i-heroicons-user'
},
{
key: 'about',
label: 'About',
icon: 'i-heroicons-information-circle'
}
]
</script>