Compare commits
17 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47535d0fa | ||
| 28ff53e8cd | |||
|
|
b98b3bf222 | ||
| 7a01aecb34 | |||
|
|
762ec56a3c | ||
| 91a21e274f | |||
|
|
14e5cab7bb | ||
| 229cb2cc90 | |||
|
|
d4d3d9390c | ||
| 12c5304638 | |||
|
|
080d2424c8 | ||
|
|
6b1c34ceff | ||
| 231f594004 | |||
|
|
7d35a3e7b3 | ||
| 670b2f9200 | |||
|
|
521e3f552f | ||
| 627e970986 |
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<div>
|
||||
<OfflineBanner />
|
||||
<InstallPrompt />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
104
app/components/InstallPrompt.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="showPrompt"
|
||||
class="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:max-w-md z-50"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- App Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<img
|
||||
src="/icon-192x192.png"
|
||||
alt="Pantry icon"
|
||||
class="w-16 h-16 rounded-lg shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-1">
|
||||
Install Pantry
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Install this app for quick access and offline use
|
||||
</p>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
color="emerald"
|
||||
size="sm"
|
||||
@click="install"
|
||||
:loading="installing"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-heroicons-arrow-down-tray" />
|
||||
</template>
|
||||
Install
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
color="gray"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="dismiss"
|
||||
>
|
||||
Not now
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click="dismiss"
|
||||
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition"
|
||||
>
|
||||
<UIcon name="i-heroicons-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { canInstall, promptInstall, dismissInstall, shouldShowPrompt } = usePWAInstall()
|
||||
const installing = ref(false)
|
||||
const showPrompt = ref(false)
|
||||
|
||||
// Show prompt after a delay if conditions are met
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
if (shouldShowPrompt()) {
|
||||
showPrompt.value = true
|
||||
}
|
||||
}, 3000) // Wait 3 seconds after page load
|
||||
})
|
||||
|
||||
async function install() {
|
||||
installing.value = true
|
||||
try {
|
||||
const { outcome } = await promptInstall()
|
||||
if (outcome === 'accepted') {
|
||||
showPrompt.value = false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Install failed:', error)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
dismissInstall()
|
||||
showPrompt.value = false
|
||||
}
|
||||
</script>
|
||||
58
app/components/OfflineBanner.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="!isOnline"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-white px-4 py-2 text-center shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<UIcon name="i-heroicons-wifi-slash" class="w-5 h-5" />
|
||||
<span class="font-medium">
|
||||
You're currently offline. Changes will sync when connection is restored.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="isOnline && wasOffline && showReconnected"
|
||||
class="fixed top-0 left-0 right-0 z-50 bg-emerald-500 text-white px-4 py-2 text-center shadow-lg"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<UIcon name="i-heroicons-wifi" class="w-5 h-5" />
|
||||
<span class="font-medium">
|
||||
Back online! Syncing your changes...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { isOnline, wasOffline } = useOnlineStatus()
|
||||
const showReconnected = ref(false)
|
||||
|
||||
// Show "back online" message for 3 seconds
|
||||
watch(isOnline, (online) => {
|
||||
if (online && wasOffline.value) {
|
||||
showReconnected.value = true
|
||||
setTimeout(() => {
|
||||
showReconnected.value = false
|
||||
}, 3000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
190
app/components/Settings/AppSettings.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<UCard class="mt-4">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">App Installation</h3>
|
||||
|
||||
<!-- Already installed -->
|
||||
<div v-if="isInstalled" class="flex items-start gap-3 p-4 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-6 h-6 text-emerald-600 dark:text-emerald-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-emerald-900 dark:text-emerald-100">
|
||||
App is installed
|
||||
</p>
|
||||
<p class="text-sm text-emerald-700 dark:text-emerald-300 mt-1">
|
||||
You're using Pantry as an installed app with offline support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Can install -->
|
||||
<div v-else-if="canInstall" class="space-y-3">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Install Pantry as an app for quick access and offline use.
|
||||
</p>
|
||||
<UButton
|
||||
color="emerald"
|
||||
size="lg"
|
||||
@click="install"
|
||||
:loading="installing"
|
||||
block
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-heroicons-arrow-down-tray" />
|
||||
</template>
|
||||
Install App
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Not supported or already running -->
|
||||
<div v-else class="space-y-4">
|
||||
<div v-if="isStandalone" class="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<UIcon name="i-heroicons-information-circle" class="w-6 h-6 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-medium text-blue-900 dark:text-blue-100">
|
||||
Running as standalone app
|
||||
</p>
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
You're already using Pantry in standalone mode.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Installation is not available yet. Try one of these options:
|
||||
</p>
|
||||
|
||||
<!-- iOS Instructions -->
|
||||
<UCard class="mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-heroicons-device-phone-mobile" class="w-6 h-6 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold mb-2">iOS (Safari)</h4>
|
||||
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 list-decimal list-inside">
|
||||
<li>Tap the Share button</li>
|
||||
<li>Scroll down and tap "Add to Home Screen"</li>
|
||||
<li>Tap "Add" to confirm</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Android Instructions -->
|
||||
<UCard>
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-heroicons-device-phone-mobile" class="w-6 h-6 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold mb-2">Android (Chrome)</h4>
|
||||
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 list-decimal list-inside">
|
||||
<li>Tap the menu (⋮) button</li>
|
||||
<li>Tap "Add to Home screen" or "Install app"</li>
|
||||
<li>Tap "Install" to confirm</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PWA Features -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-3">App Features</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<UIcon name="i-heroicons-check" class="w-5 h-5 text-emerald-500" />
|
||||
<span>Works offline with cached data</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<UIcon name="i-heroicons-check" class="w-5 h-5 text-emerald-500" />
|
||||
<span>Quick access from home screen</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<UIcon name="i-heroicons-check" class="w-5 h-5 text-emerald-500" />
|
||||
<span>Full-screen experience</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<UIcon name="i-heroicons-check" class="w-5 h-5 text-emerald-500" />
|
||||
<span>Automatic updates</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Info -->
|
||||
<div v-if="storageInfo">
|
||||
<h3 class="text-lg font-semibold mb-3">Storage Usage</h3>
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Used</span>
|
||||
<span class="font-medium">{{ formatBytes(storageInfo.usage) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">Available</span>
|
||||
<span class="font-medium">{{ formatBytes(storageInfo.quota) }}</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-emerald-500 h-2 rounded-full transition-all"
|
||||
:style="{ width: storagePercent + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { canInstall, isInstalled, promptInstall } = usePWAInstall()
|
||||
const installing = ref(false)
|
||||
const storageInfo = ref<{ usage: number; quota: number } | null>(null)
|
||||
|
||||
const isStandalone = computed(() => {
|
||||
if (process.client) {
|
||||
return window.matchMedia('(display-mode: standalone)').matches
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const storagePercent = computed(() => {
|
||||
if (!storageInfo.value) return 0
|
||||
return Math.round((storageInfo.value.usage / storageInfo.value.quota) * 100)
|
||||
})
|
||||
|
||||
async function install() {
|
||||
installing.value = true
|
||||
try {
|
||||
await promptInstall()
|
||||
} catch (error) {
|
||||
console.error('Install failed:', error)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Get storage info
|
||||
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||
try {
|
||||
const estimate = await navigator.storage.estimate()
|
||||
if (estimate.usage !== undefined && estimate.quota !== undefined) {
|
||||
storageInfo.value = {
|
||||
usage: estimate.usage,
|
||||
quota: estimate.quota
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get storage estimate:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
71
app/components/tags/TagBadge.vue
Normal 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>
|
||||
127
app/components/tags/TagFilter.vue
Normal 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>
|
||||
212
app/components/tags/TagManager.vue
Normal 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>
|
||||
125
app/components/tags/TagPicker.vue
Normal 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>
|
||||
47
app/composables/useOnlineStatus.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Composable to track online/offline status
|
||||
*
|
||||
* Usage:
|
||||
* const { isOnline, wasOffline } = useOnlineStatus()
|
||||
*
|
||||
* watch(isOnline, (online) => {
|
||||
* if (online && wasOffline.value) {
|
||||
* // User came back online, sync data
|
||||
* }
|
||||
* })
|
||||
*/
|
||||
export function useOnlineStatus() {
|
||||
const isOnline = ref(true)
|
||||
const wasOffline = ref(false)
|
||||
|
||||
if (process.client) {
|
||||
// Initial state
|
||||
isOnline.value = navigator.onLine
|
||||
|
||||
// Listen for online/offline events
|
||||
const updateOnlineStatus = () => {
|
||||
const online = navigator.onLine
|
||||
|
||||
if (!online && isOnline.value) {
|
||||
// Just went offline
|
||||
wasOffline.value = true
|
||||
}
|
||||
|
||||
isOnline.value = online
|
||||
}
|
||||
|
||||
window.addEventListener('online', updateOnlineStatus)
|
||||
window.addEventListener('offline', updateOnlineStatus)
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('online', updateOnlineStatus)
|
||||
window.removeEventListener('offline', updateOnlineStatus)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isOnline: readonly(isOnline),
|
||||
wasOffline: readonly(wasOffline)
|
||||
}
|
||||
}
|
||||
93
app/composables/usePWAInstall.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Composable to handle PWA installation
|
||||
*
|
||||
* Usage:
|
||||
* const { canInstall, isInstalled, promptInstall, dismissInstall } = usePWAInstall()
|
||||
*/
|
||||
export function usePWAInstall() {
|
||||
const canInstall = ref(false)
|
||||
const isInstalled = ref(false)
|
||||
const deferredPrompt = ref<any>(null)
|
||||
|
||||
if (process.client) {
|
||||
// Check if already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
isInstalled.value = true
|
||||
}
|
||||
|
||||
// Listen for beforeinstallprompt event
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent the mini-infobar from appearing on mobile
|
||||
e.preventDefault()
|
||||
|
||||
// Stash the event so it can be triggered later
|
||||
deferredPrompt.value = e
|
||||
canInstall.value = true
|
||||
})
|
||||
|
||||
// Listen for appinstalled event
|
||||
window.addEventListener('appinstalled', () => {
|
||||
isInstalled.value = true
|
||||
canInstall.value = false
|
||||
deferredPrompt.value = null
|
||||
})
|
||||
}
|
||||
|
||||
async function promptInstall() {
|
||||
if (!deferredPrompt.value) {
|
||||
return { outcome: 'not-available' }
|
||||
}
|
||||
|
||||
// Show the install prompt
|
||||
deferredPrompt.value.prompt()
|
||||
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
|
||||
// Clear the deferredPrompt
|
||||
deferredPrompt.value = null
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
canInstall.value = false
|
||||
}
|
||||
|
||||
return { outcome }
|
||||
}
|
||||
|
||||
function dismissInstall() {
|
||||
canInstall.value = false
|
||||
deferredPrompt.value = null
|
||||
|
||||
// Remember dismissal for 7 days
|
||||
if (process.client) {
|
||||
localStorage.setItem('pwa-install-dismissed', Date.now().toString())
|
||||
}
|
||||
}
|
||||
|
||||
function shouldShowPrompt() {
|
||||
if (!canInstall.value || isInstalled.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed')
|
||||
if (dismissed) {
|
||||
const dismissedTime = parseInt(dismissed)
|
||||
const sevenDays = 7 * 24 * 60 * 60 * 1000
|
||||
if (Date.now() - dismissedTime < sevenDays) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
canInstall: readonly(canInstall),
|
||||
isInstalled: readonly(isInstalled),
|
||||
promptInstall,
|
||||
dismissInstall,
|
||||
shouldShowPrompt
|
||||
}
|
||||
}
|
||||
61
app/composables/useProductLookup.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ export default defineNuxtConfig({
|
||||
|
||||
modules: [
|
||||
'@nuxt/ui',
|
||||
'@nuxt/fonts'
|
||||
'@nuxt/fonts',
|
||||
'@vite-pwa/nuxt'
|
||||
],
|
||||
|
||||
runtimeConfig: {
|
||||
@@ -17,5 +18,166 @@ 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: '/offline',
|
||||
navigateFallbackDenylist: [/^\/api\//],
|
||||
globPatterns: ['**/*.{js,css,html,png,svg,ico,woff,woff2}'],
|
||||
cleanupOutdatedCaches: true,
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
runtimeCaching: [
|
||||
// Supabase API - Network first with fallback
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\.supabase\.co\/rest\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'supabase-rest-api',
|
||||
networkTimeoutSeconds: 10,
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 // 1 hour
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Supabase Auth - Network only (don't cache auth)
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\.supabase\.co\/auth\/.*/i,
|
||||
handler: 'NetworkOnly'
|
||||
},
|
||||
// Supabase Storage - Cache first for images
|
||||
{
|
||||
urlPattern: /^https:\/\/.*\.supabase\.co\/storage\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'supabase-storage',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Open Food Facts API - Cache first with network fallback
|
||||
{
|
||||
urlPattern: /^https:\/\/world\.openfoodfacts\.org\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'openfoodfacts-api',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
// External images - Cache first
|
||||
{
|
||||
urlPattern: /^https:\/\/images\.openfoodfacts\.org\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'product-images',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
// Google Fonts - Cache first
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-stylesheets',
|
||||
expiration: {
|
||||
maxEntries: 20,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'google-fonts-webfonts',
|
||||
expiration: {
|
||||
maxEntries: 30,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
18031
app/package-lock.json
generated
Normal file
@@ -7,7 +7,8 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"generate:icons": "node scripts/generate-icons.js && node scripts/generate-screenshots.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/fonts": "^0.13.0",
|
||||
@@ -19,6 +20,8 @@
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0"
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"@vite-pwa/nuxt": "^1.1.1",
|
||||
"sharp": "^0.34.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
69
app/pages/offline.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen p-8 text-center">
|
||||
<UIcon name="i-heroicons-wifi-slash" class="w-24 h-24 text-gray-400 mb-6" />
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">
|
||||
You're Offline
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-600 mb-8 max-w-md">
|
||||
No internet connection detected. Some features may be limited, but you can still:
|
||||
</p>
|
||||
|
||||
<div class="space-y-3 mb-8 text-left max-w-md">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-6 h-6 text-emerald-500 mt-0.5" />
|
||||
<span class="text-gray-700">View cached inventory items</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-6 h-6 text-emerald-500 mt-0.5" />
|
||||
<span class="text-gray-700">Scan barcodes (will sync when online)</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-heroicons-check-circle" class="w-6 h-6 text-emerald-500 mt-0.5" />
|
||||
<span class="text-gray-700">Browse previously loaded data</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="emerald"
|
||||
size="lg"
|
||||
@click="retry"
|
||||
:loading="retrying"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-heroicons-arrow-path" />
|
||||
</template>
|
||||
Try Again
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const retrying = ref(false)
|
||||
|
||||
async function retry() {
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
// Test if we're back online
|
||||
const response = await fetch('/api/health', { method: 'HEAD' })
|
||||
if (response.ok) {
|
||||
// We're online! Go back
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
// Still offline
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-retry when online event fires
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,73 +2,74 @@
|
||||
<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>
|
||||
<template #app>
|
||||
<SettingsAppSettings />
|
||||
</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: 'app',
|
||||
label: 'App',
|
||||
icon: 'i-heroicons-device-phone-mobile'
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account',
|
||||
icon: 'i-heroicons-user'
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
label: 'About',
|
||||
icon: 'i-heroicons-information-circle'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
BIN
app/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
app/public/icon-192x192-maskable.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
app/public/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
app/public/icon-512x512-maskable.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
app/public/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
46
app/public/icon.svg
Normal file
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||
<!-- Background circle with emerald gradient -->
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#10b981;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#059669;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<circle cx="256" cy="256" r="256" fill="url(#grad)"/>
|
||||
|
||||
<!-- Pantry shelves icon (simplified cabinet with items) -->
|
||||
<g transform="translate(106, 96)">
|
||||
<!-- Cabinet outline -->
|
||||
<rect x="0" y="0" width="300" height="320" rx="12" fill="none" stroke="white" stroke-width="8"/>
|
||||
|
||||
<!-- Top shelf -->
|
||||
<line x1="0" y1="80" x2="300" y2="80" stroke="white" stroke-width="6"/>
|
||||
|
||||
<!-- Middle shelf -->
|
||||
<line x1="0" y1="160" x2="300" y2="160" stroke="white" stroke-width="6"/>
|
||||
|
||||
<!-- Bottom shelf -->
|
||||
<line x1="0" y1="240" x2="300" y2="240" stroke="white" stroke-width="6"/>
|
||||
|
||||
<!-- Top shelf items - jars -->
|
||||
<circle cx="60" cy="40" r="25" fill="white" opacity="0.9"/>
|
||||
<circle cx="150" cy="40" r="25" fill="white" opacity="0.9"/>
|
||||
<circle cx="240" cy="40" r="25" fill="white" opacity="0.9"/>
|
||||
|
||||
<!-- Middle shelf items - boxes -->
|
||||
<rect x="35" y="105" width="50" height="40" rx="4" fill="white" opacity="0.9"/>
|
||||
<rect x="125" y="105" width="50" height="40" rx="4" fill="white" opacity="0.9"/>
|
||||
<rect x="215" y="105" width="50" height="40" rx="4" fill="white" opacity="0.9"/>
|
||||
|
||||
<!-- Bottom shelf items - cans -->
|
||||
<rect x="35" y="185" width="50" height="45" rx="6" fill="white" opacity="0.9"/>
|
||||
<rect x="125" y="185" width="50" height="45" rx="6" fill="white" opacity="0.9"/>
|
||||
<rect x="215" y="185" width="50" height="45" rx="6" fill="white" opacity="0.9"/>
|
||||
|
||||
<!-- Very bottom items - larger containers -->
|
||||
<rect x="45" y="260" width="70" height="50" rx="6" fill="white" opacity="0.9"/>
|
||||
<rect x="185" y="260" width="70" height="50" rx="6" fill="white" opacity="0.9"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/public/screenshot-desktop.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
app/public/screenshot-mobile.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
70
app/scripts/generate-icons.js
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, writeFile } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = join(__dirname, '..', 'public');
|
||||
const svgPath = join(publicDir, 'icon.svg');
|
||||
|
||||
const sizes = [
|
||||
{ size: 192, name: 'icon-192x192.png', maskable: false },
|
||||
{ size: 512, name: 'icon-512x512.png', maskable: false },
|
||||
{ size: 192, name: 'icon-192x192-maskable.png', maskable: true },
|
||||
{ size: 512, name: 'icon-512x512-maskable.png', maskable: true },
|
||||
];
|
||||
|
||||
async function generateIcons() {
|
||||
console.log('Reading SVG icon...');
|
||||
const svgBuffer = await readFile(svgPath);
|
||||
|
||||
for (const { size, name, maskable } of sizes) {
|
||||
console.log(`Generating ${name}...`);
|
||||
|
||||
let buffer;
|
||||
if (maskable) {
|
||||
// Maskable icons need safe zone padding (80% of icon in center)
|
||||
// Create a transparent canvas with padding
|
||||
const paddedSize = size;
|
||||
const iconSize = Math.floor(size * 0.8);
|
||||
const offset = Math.floor((paddedSize - iconSize) / 2);
|
||||
|
||||
// Resize SVG to icon size
|
||||
const iconBuffer = await sharp(svgBuffer)
|
||||
.resize(iconSize, iconSize)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Create transparent background and composite
|
||||
buffer = await sharp({
|
||||
create: {
|
||||
width: paddedSize,
|
||||
height: paddedSize,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
||||
}
|
||||
})
|
||||
.composite([{
|
||||
input: iconBuffer,
|
||||
top: offset,
|
||||
left: offset
|
||||
}])
|
||||
.png()
|
||||
.toBuffer();
|
||||
} else {
|
||||
// Regular icon - full size
|
||||
buffer = await sharp(svgBuffer)
|
||||
.resize(size, size)
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
await writeFile(join(publicDir, name), buffer);
|
||||
console.log(`✓ ${name}`);
|
||||
}
|
||||
|
||||
console.log('\n✅ All icons generated successfully!');
|
||||
}
|
||||
|
||||
generateIcons().catch(console.error);
|
||||
124
app/scripts/generate-screenshots.js
Normal file
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env node
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const publicDir = join(__dirname, '..', 'public');
|
||||
|
||||
async function generateScreenshots() {
|
||||
// Mobile screenshot (390x844 - iPhone 12/13/14 size)
|
||||
console.log('Generating mobile screenshot placeholder...');
|
||||
const mobileBuffer = await sharp({
|
||||
create: {
|
||||
width: 390,
|
||||
height: 844,
|
||||
channels: 4,
|
||||
background: { r: 249, g: 250, b: 251, alpha: 1 } // Tailwind gray-50
|
||||
}
|
||||
})
|
||||
.composite([
|
||||
{
|
||||
input: Buffer.from(`
|
||||
<svg width="390" height="844">
|
||||
<!-- Header -->
|
||||
<rect width="390" height="64" fill="#10b981"/>
|
||||
<text x="195" y="42" font-family="Arial" font-size="20" fill="white" text-anchor="middle" font-weight="bold">Pantry</text>
|
||||
|
||||
<!-- Content area -->
|
||||
<text x="24" y="104" font-family="Arial" font-size="24" fill="#111827" font-weight="bold">My Pantry</text>
|
||||
|
||||
<!-- Item cards -->
|
||||
<rect x="16" y="130" width="358" height="80" rx="8" fill="white" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="32" y="160" font-family="Arial" font-size="16" fill="#111827" font-weight="600">Milk</text>
|
||||
<text x="32" y="185" font-family="Arial" font-size="14" fill="#6b7280">Fridge • 1L</text>
|
||||
|
||||
<rect x="16" y="226" width="358" height="80" rx="8" fill="white" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="32" y="256" font-family="Arial" font-size="16" fill="#111827" font-weight="600">Pasta</text>
|
||||
<text x="32" y="281" font-family="Arial" font-size="14" fill="#6b7280">Pantry • 500g</text>
|
||||
|
||||
<rect x="16" y="322" width="358" height="80" rx="8" fill="white" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="32" y="352" font-family="Arial" font-size="16" fill="#111827" font-weight="600">Tomato Sauce</text>
|
||||
<text x="32" y="377" font-family="Arial" font-size="14" fill="#6b7280">Pantry • 400ml</text>
|
||||
|
||||
<!-- Bottom navigation -->
|
||||
<rect y="780" width="390" height="64" fill="white" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="78" y="820" font-family="Arial" font-size="12" fill="#6b7280" text-anchor="middle">Home</text>
|
||||
<text x="195" y="820" font-family="Arial" font-size="12" fill="#10b981" text-anchor="middle">Scan</text>
|
||||
<text x="312" y="820" font-family="Arial" font-size="12" fill="#6b7280" text-anchor="middle">Settings</text>
|
||||
</svg>
|
||||
`),
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await writeFile(join(publicDir, 'screenshot-mobile.png'), mobileBuffer);
|
||||
console.log('✓ screenshot-mobile.png');
|
||||
|
||||
// Desktop screenshot (1920x1080)
|
||||
console.log('Generating desktop screenshot placeholder...');
|
||||
const desktopBuffer = await sharp({
|
||||
create: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
channels: 4,
|
||||
background: { r: 249, g: 250, b: 251, alpha: 1 } // Tailwind gray-50
|
||||
}
|
||||
})
|
||||
.composite([
|
||||
{
|
||||
input: Buffer.from(`
|
||||
<svg width="1920" height="1080">
|
||||
<!-- Header -->
|
||||
<rect width="1920" height="80" fill="#10b981"/>
|
||||
<text x="960" y="50" font-family="Arial" font-size="32" fill="white" text-anchor="middle" font-weight="bold">Pantry - Smart Inventory Manager</text>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<rect x="0" y="80" width="280" height="1000" fill="white" stroke="#e5e7eb" stroke-width="1"/>
|
||||
<text x="32" y="130" font-family="Arial" font-size="18" fill="#10b981" font-weight="600">Dashboard</text>
|
||||
<text x="32" y="180" font-family="Arial" font-size="18" fill="#6b7280">Scan Item</text>
|
||||
<text x="32" y="230" font-family="Arial" font-size="18" fill="#6b7280">Settings</text>
|
||||
|
||||
<!-- Main content -->
|
||||
<text x="340" y="150" font-family="Arial" font-size="36" fill="#111827" font-weight="bold">My Pantry Items</text>
|
||||
|
||||
<!-- Grid of items -->
|
||||
<rect x="340" y="200" width="480" height="180" rx="12" fill="white" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<text x="370" y="250" font-family="Arial" font-size="24" fill="#111827" font-weight="600">Milk</text>
|
||||
<text x="370" y="290" font-family="Arial" font-size="18" fill="#6b7280">Fridge • 1L • Expires in 5 days</text>
|
||||
|
||||
<rect x="860" y="200" width="480" height="180" rx="12" fill="white" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<text x="890" y="250" font-family="Arial" font-size="24" fill="#111827" font-weight="600">Pasta</text>
|
||||
<text x="890" y="290" font-family="Arial" font-size="18" fill="#6b7280">Pantry • 500g</text>
|
||||
|
||||
<rect x="1380" y="200" width="480" height="180" rx="12" fill="white" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<text x="1410" y="250" font-family="Arial" font-size="24" fill="#111827" font-weight="600">Tomato Sauce</text>
|
||||
<text x="1410" y="290" font-family="Arial" font-size="18" fill="#6b7280">Pantry • 400ml</text>
|
||||
|
||||
<rect x="340" y="420" width="480" height="180" rx="12" fill="white" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<text x="370" y="470" font-family="Arial" font-size="24" fill="#111827" font-weight="600">Rice</text>
|
||||
<text x="370" y="510" font-family="Arial" font-size="18" fill="#6b7280">Pantry • 1kg</text>
|
||||
|
||||
<rect x="860" y="420" width="480" height="180" rx="12" fill="white" stroke="#e5e7eb" stroke-width="2"/>
|
||||
<text x="890" y="470" font-family="Arial" font-size="24" fill="#111827" font-weight="600">Olive Oil</text>
|
||||
<text x="890" y="510" font-family="Arial" font-size="18" fill="#6b7280">Pantry • 750ml</text>
|
||||
</svg>
|
||||
`),
|
||||
top: 0,
|
||||
left: 0
|
||||
}
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
await writeFile(join(publicDir, 'screenshot-desktop.png'), desktopBuffer);
|
||||
console.log('✓ screenshot-desktop.png');
|
||||
|
||||
console.log('\n✅ All screenshots generated successfully!');
|
||||
}
|
||||
|
||||
generateScreenshots().catch(console.error);
|
||||
83
supabase/functions/product-lookup/README.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Product Lookup Edge Function
|
||||
|
||||
Fetches product data from Open Food Facts API by barcode and caches results in the database.
|
||||
|
||||
## Endpoint
|
||||
|
||||
`POST /functions/v1/product-lookup`
|
||||
|
||||
## Request
|
||||
|
||||
```json
|
||||
{
|
||||
"barcode": "8000500310427"
|
||||
}
|
||||
```
|
||||
|
||||
## Response
|
||||
|
||||
### Success (200)
|
||||
|
||||
```json
|
||||
{
|
||||
"barcode": "8000500310427",
|
||||
"name": "Nutella",
|
||||
"brand": "Ferrero",
|
||||
"quantity": "750g",
|
||||
"image_url": "https://...",
|
||||
"category": "spreads",
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found (404)
|
||||
|
||||
```json
|
||||
{
|
||||
"barcode": "1234567890123",
|
||||
"name": "Unknown Product (1234567890123)",
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
### Error (500)
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error message",
|
||||
"barcode": null,
|
||||
"name": null
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Queries Open Food Facts API
|
||||
- ✅ Caches results in `products` table
|
||||
- ✅ Returns cached data for subsequent requests
|
||||
- ✅ Handles product not found gracefully
|
||||
- ✅ CORS enabled for frontend access
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `SUPABASE_URL`: Auto-injected by Supabase
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`: Auto-injected by Supabase
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Local (with Supabase CLI)
|
||||
supabase functions serve product-lookup
|
||||
|
||||
# Test request
|
||||
curl -X POST http://localhost:54321/functions/v1/product-lookup \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_ANON_KEY" \
|
||||
-d '{"barcode":"8000500310427"}'
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
supabase functions deploy product-lookup
|
||||
```
|
||||
140
supabase/functions/product-lookup/index.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// Product Lookup Edge Function
|
||||
// Fetches product data from Open Food Facts API by barcode
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
interface ProductData {
|
||||
barcode: string
|
||||
name: string
|
||||
brand?: string
|
||||
quantity?: string
|
||||
image_url?: string
|
||||
category?: string
|
||||
cached?: boolean
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders })
|
||||
}
|
||||
|
||||
try {
|
||||
const { barcode } = await req.json()
|
||||
|
||||
if (!barcode) {
|
||||
throw new Error('Barcode is required')
|
||||
}
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
|
||||
const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
|
||||
const supabase = createClient(supabaseUrl, supabaseKey)
|
||||
|
||||
// Check cache first (products table can store known products)
|
||||
const { data: cachedProduct } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('barcode', barcode)
|
||||
.single()
|
||||
|
||||
if (cachedProduct) {
|
||||
console.log(`Cache HIT for barcode: ${barcode}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
barcode: cachedProduct.barcode,
|
||||
name: cachedProduct.name,
|
||||
brand: cachedProduct.brand,
|
||||
quantity: cachedProduct.quantity,
|
||||
image_url: cachedProduct.image_url,
|
||||
category: cachedProduct.category,
|
||||
cached: true,
|
||||
} as ProductData),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Cache MISS for barcode: ${barcode}, fetching from Open Food Facts...`)
|
||||
|
||||
// Fetch from Open Food Facts
|
||||
const offResponse = await fetch(
|
||||
`https://world.openfoodfacts.org/api/v2/product/${barcode}.json`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Pantry/1.0 (https://github.com/pantry-app/pantry)',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!offResponse.ok) {
|
||||
throw new Error(`Open Food Facts API error: ${offResponse.status}`)
|
||||
}
|
||||
|
||||
const offData = await offResponse.json()
|
||||
|
||||
if (offData.status !== 1 || !offData.product) {
|
||||
// Product not found in Open Food Facts
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
barcode,
|
||||
name: `Unknown Product (${barcode})`,
|
||||
cached: false,
|
||||
} as ProductData),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 404
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const product = offData.product
|
||||
|
||||
// Extract relevant data
|
||||
const productData: ProductData = {
|
||||
barcode,
|
||||
name: product.product_name || product.generic_name || `Product ${barcode}`,
|
||||
brand: product.brands || undefined,
|
||||
quantity: product.quantity || undefined,
|
||||
image_url: product.image_url || product.image_front_url || undefined,
|
||||
category: product.categories || undefined,
|
||||
cached: false,
|
||||
}
|
||||
|
||||
// Cache the product in our database (upsert)
|
||||
await supabase.from('products').upsert({
|
||||
barcode: productData.barcode,
|
||||
name: productData.name,
|
||||
brand: productData.brand,
|
||||
quantity: productData.quantity,
|
||||
image_url: productData.image_url,
|
||||
category: productData.category,
|
||||
}, { onConflict: 'barcode' })
|
||||
|
||||
console.log(`Successfully fetched and cached product: ${productData.name}`)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify(productData),
|
||||
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
|
||||
)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in product-lookup:', error)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
barcode: null,
|
||||
name: null,
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500
|
||||
}
|
||||
)
|
||||
}
|
||||
})
|
||||