Compare commits
2 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47535d0fa | ||
| 28ff53e8cd |
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
|
<InstallPrompt />
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|||||||
104
app/components/InstallPrompt.vue
Normal file
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>
|
||||||
190
app/components/Settings/AppSettings.vue
Normal file
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>
|
||||||
93
app/composables/usePWAInstall.ts
Normal file
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #app>
|
||||||
|
<SettingsAppSettings />
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #about>
|
<template #about>
|
||||||
<UCard class="mt-4">
|
<UCard class="mt-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -52,6 +56,11 @@ const tabs = [
|
|||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
icon: 'i-heroicons-tag'
|
icon: 'i-heroicons-tag'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'app',
|
||||||
|
label: 'App',
|
||||||
|
icon: 'i-heroicons-device-phone-mobile'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'account',
|
key: 'account',
|
||||||
label: 'Account',
|
label: 'Account',
|
||||||
|
|||||||
Reference in New Issue
Block a user