feat(client): add PWA support with installability and cover caching
Add vite-plugin-pwa with Workbox in generateSW mode. Includes a web app manifest (standalone display, shortcuts for Dashboard/Libraries/Settings), iOS meta tags, and a service worker that pre-caches the app shell and uses CacheFirst for book cover and thumbnail endpoints (max 200 entries, 30-day TTL). Add useInstallPrompt composable that captures the beforeinstallprompt event and exposes isInstallable/installApp. Wire an install button into SidebarFooter that appears only when the browser signals the app is installable. Generate PWA icons (64, 192, 512, maskable-512, apple-touch-icon-180) from a new pwa-icon-source.svg using @vite-pwa/assets-generator with minimal2023Preset. Add dev-dist to .gitignore to exclude the dev-mode SW output.
@@ -34,3 +34,6 @@ test-results/
|
|||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# vite-plugin-pwa dev service worker output
|
||||||
|
dev-dist
|
||||||
|
|||||||
@@ -2,8 +2,13 @@
|
|||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" href="/pwa-icon-source.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="BookOrbit" />
|
||||||
<title>BookOrbit</title>
|
<title>BookOrbit</title>
|
||||||
<!-- Blocking theme init — must run before first paint to prevent FOUC -->
|
<!-- Blocking theme init — must run before first paint to prevent FOUC -->
|
||||||
<script src="/theme-init.js"></script>
|
<script src="/theme-init.js"></script>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
"lint:eslint:check": "eslint src --cache",
|
"lint:eslint:check": "eslint src --cache",
|
||||||
"lint:eslint:fix": "eslint src --fix --cache",
|
"lint:eslint:fix": "eslint src --fix --cache",
|
||||||
"format": "prettier --write --experimental-cli src/",
|
"format": "prettier --write --experimental-cli src/",
|
||||||
"format:check": "prettier --check src/"
|
"format:check": "prettier --check src/",
|
||||||
|
"generate-pwa-icons": "pwa-assets-generator --config pwa-assets.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bookorbit/types": "workspace:*",
|
"@bookorbit/types": "workspace:*",
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vue": "^3.5.33",
|
"vue": "^3.5.33",
|
||||||
"vue-draggable-plus": "^0.6.1",
|
"vue-draggable-plus": "^0.6.1",
|
||||||
"vue-echarts": "8.1.0-beta.2",
|
"vue-echarts": "8.1.0-beta.2",
|
||||||
@@ -56,6 +58,7 @@
|
|||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@types/jsdom": "^28.0.1",
|
"@types/jsdom": "^28.0.1",
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"@vitest/eslint-plugin": "^1.6.16",
|
"@vitest/eslint-plugin": "^1.6.16",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 962 B |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 816 B |
@@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#2c2c22"/>
|
||||||
|
<stop offset="100%" stop-color="#1a1a10"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
|
||||||
|
<!-- Orbit icon (Lucide), 24x24 viewBox scaled to fill ~252x252 of safe zone, centered -->
|
||||||
|
<!-- transform: scale(14), translate(88,88) => visual extent ~130-382 per axis -->
|
||||||
|
<g
|
||||||
|
transform="translate(88, 88) scale(14)"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(255,255,255,0.9)"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M20.341 6.484A10 10 0 0 1 10.266 21.85"/>
|
||||||
|
<path d="M3.659 17.516A10 10 0 0 1 13.74 2.152"/>
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<circle cx="19" cy="5" r="2"/>
|
||||||
|
<circle cx="5" cy="19" r="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 938 B |
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
preset: minimal2023Preset,
|
||||||
|
images: ['public/pwa-icon-source.svg'],
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ import { VueDraggable } from 'vue-draggable-plus'
|
|||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
@@ -28,6 +29,7 @@ import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmar
|
|||||||
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
|
||||||
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
|
||||||
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
|
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
|
||||||
|
import { useInstallPrompt } from '@/features/pwa/composables/useInstallPrompt'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
|
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
|
||||||
@@ -54,6 +56,7 @@ const { hasPermission } = usePermissions()
|
|||||||
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
|
||||||
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
const { handleLibraryCreated } = useLibraryCreationRedirect()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
|
const { isInstallable, installApp } = useInstallPrompt()
|
||||||
|
|
||||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||||
|
|
||||||
@@ -430,6 +433,12 @@ onUnmounted(() => stopLibraryUploadListener())
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter v-if="isInstallable" class="border-t border-sidebar-border/60 pb-2">
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarNavItem :is-active="false" tooltip="Install App" :icon="Icons.Download" label="Install App" @click="installApp" />
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { useInstallPrompt, _resetInstallPromptForTest } from '../useInstallPrompt'
|
||||||
|
|
||||||
|
// The composable uses module-level singleton state for the deferred prompt.
|
||||||
|
// Each test resets that state via the exported helper before running.
|
||||||
|
|
||||||
|
function makePromptEvent(outcome: 'accepted' | 'dismissed' = 'accepted'): Event & {
|
||||||
|
prompt: ReturnType<typeof vi.fn>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
preventDefault: ReturnType<typeof vi.fn>
|
||||||
|
} {
|
||||||
|
let resolveChoice!: (v: { outcome: 'accepted' | 'dismissed' }) => void
|
||||||
|
const userChoice = new Promise<{ outcome: 'accepted' | 'dismissed' }>((res) => {
|
||||||
|
resolveChoice = res
|
||||||
|
})
|
||||||
|
|
||||||
|
const evt = new Event('beforeinstallprompt') as Event & {
|
||||||
|
prompt: ReturnType<typeof vi.fn>
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
preventDefault: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
evt.preventDefault = vi.fn<() => void>()
|
||||||
|
evt.prompt = vi.fn<() => Promise<void>>(() => {
|
||||||
|
resolveChoice({ outcome })
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
Object.defineProperty(evt, 'userChoice', { get: () => userChoice })
|
||||||
|
return evt
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useInstallPrompt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetInstallPromptForTest()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInstallable is false before beforeinstallprompt fires', () => {
|
||||||
|
const { isInstallable } = useInstallPrompt()
|
||||||
|
expect(isInstallable.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInstallable becomes true after beforeinstallprompt fires', () => {
|
||||||
|
const { isInstallable } = useInstallPrompt()
|
||||||
|
const evt = makePromptEvent()
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
expect(isInstallable.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preventDefault is called on beforeinstallprompt', () => {
|
||||||
|
const evt = makePromptEvent()
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
expect(evt.preventDefault).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installApp calls prompt() on the deferred event', async () => {
|
||||||
|
const { installApp } = useInstallPrompt()
|
||||||
|
const evt = makePromptEvent('accepted')
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
await installApp()
|
||||||
|
expect(evt.prompt).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInstallable resets to false after user accepts', async () => {
|
||||||
|
const { isInstallable, installApp } = useInstallPrompt()
|
||||||
|
const evt = makePromptEvent('accepted')
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
expect(isInstallable.value).toBe(true)
|
||||||
|
await installApp()
|
||||||
|
expect(isInstallable.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInstallable stays true after user dismisses', async () => {
|
||||||
|
const { isInstallable, installApp } = useInstallPrompt()
|
||||||
|
const evt = makePromptEvent('dismissed')
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
expect(isInstallable.value).toBe(true)
|
||||||
|
await installApp()
|
||||||
|
expect(isInstallable.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('isInstallable resets to false on appinstalled event', () => {
|
||||||
|
const { isInstallable } = useInstallPrompt()
|
||||||
|
const evt = makePromptEvent()
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
expect(isInstallable.value).toBe(true)
|
||||||
|
window.dispatchEvent(new Event('appinstalled'))
|
||||||
|
expect(isInstallable.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('installApp is a no-op when isInstallable is false', async () => {
|
||||||
|
const { isInstallable, installApp } = useInstallPrompt()
|
||||||
|
expect(isInstallable.value).toBe(false)
|
||||||
|
await expect(installApp()).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prompt is not called again after app was accepted and installed', async () => {
|
||||||
|
const { installApp } = useInstallPrompt()
|
||||||
|
const evt = makePromptEvent('accepted')
|
||||||
|
window.dispatchEvent(evt)
|
||||||
|
await installApp()
|
||||||
|
// prompt was cleared - second call is a no-op
|
||||||
|
await installApp()
|
||||||
|
expect(evt.prompt).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt(): Promise<void>
|
||||||
|
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const _deferredPrompt = ref<BeforeInstallPromptEvent | null>(null)
|
||||||
|
|
||||||
|
function _onBeforeInstallPrompt(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
_deferredPrompt.value = e as BeforeInstallPromptEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onAppInstalled() {
|
||||||
|
_deferredPrompt.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeinstallprompt', _onBeforeInstallPrompt)
|
||||||
|
window.addEventListener('appinstalled', _onAppInstalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstallPrompt() {
|
||||||
|
const isInstallable = computed(() => _deferredPrompt.value !== null)
|
||||||
|
|
||||||
|
async function installApp(): Promise<void> {
|
||||||
|
const prompt = _deferredPrompt.value
|
||||||
|
if (!prompt) return
|
||||||
|
|
||||||
|
await prompt.prompt()
|
||||||
|
|
||||||
|
const { outcome } = await prompt.userChoice
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
_deferredPrompt.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isInstallable, installApp }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets module-level singleton state. For use in tests only. */
|
||||||
|
export function _resetInstallPromptForTest() {
|
||||||
|
_deferredPrompt.value = null
|
||||||
|
}
|
||||||
@@ -4,10 +4,121 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), vueDevTools(), tailwindcss()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'auto',
|
||||||
|
includeAssets: [
|
||||||
|
'favicon.ico',
|
||||||
|
'pwa-icon-source.svg',
|
||||||
|
'apple-touch-icon-180x180.png',
|
||||||
|
'pwa-64x64.png',
|
||||||
|
'pwa-192x192.png',
|
||||||
|
'pwa-512x512.png',
|
||||||
|
'maskable-icon-512x512.png',
|
||||||
|
],
|
||||||
|
manifest: {
|
||||||
|
name: 'BookOrbit',
|
||||||
|
short_name: 'BookOrbit',
|
||||||
|
description: 'Your personal book library and reading space',
|
||||||
|
theme_color: '#1e1e18',
|
||||||
|
background_color: '#fafaf8',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
orientation: 'any',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-64x64.png',
|
||||||
|
sizes: '64x64',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'maskable-icon-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: 'Dashboard',
|
||||||
|
short_name: 'Dashboard',
|
||||||
|
url: '/dashboard',
|
||||||
|
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Libraries',
|
||||||
|
short_name: 'Libraries',
|
||||||
|
url: '/libraries',
|
||||||
|
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
short_name: 'Settings',
|
||||||
|
url: '/settings',
|
||||||
|
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
|
||||||
|
navigateFallback: 'index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^.*\/api\/v1\/books\/\d+\/cover(\?.*)?$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'book-covers',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 200,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: /^.*\/api\/v1\/books\/\d+\/thumbnail(\?.*)?$/,
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'book-thumbnails',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 200,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ['vue'],
|
dedupe: ['vue'],
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||