Compare commits
3 commits
8303b0981b
...
0a0769115b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a0769115b | |||
| b46d23b5bb | |||
| a2c4cfd955 |
24 changed files with 103 additions and 217 deletions
126
public/sw.js
126
public/sw.js
|
|
@ -1,126 +0,0 @@
|
|||
// Custom service worker for push notifications
|
||||
// This will be merged with Workbox generated SW
|
||||
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
|
||||
import { clientsClaim, skipWaiting } from 'workbox-core'
|
||||
|
||||
// Precache and route static assets
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// Take control of all pages immediately
|
||||
skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
// Push notification event handler
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Push event received:', event)
|
||||
|
||||
let notificationData = {
|
||||
title: 'New Announcement',
|
||||
body: 'You have a new admin announcement',
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-192x192.png',
|
||||
data: {
|
||||
url: '/',
|
||||
timestamp: Date.now()
|
||||
},
|
||||
tag: 'admin-announcement',
|
||||
requireInteraction: true,
|
||||
actions: [
|
||||
{
|
||||
action: 'view',
|
||||
title: 'View',
|
||||
icon: '/pwa-192x192.png'
|
||||
},
|
||||
{
|
||||
action: 'dismiss',
|
||||
title: 'Dismiss'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Parse push data if available
|
||||
if (event.data) {
|
||||
try {
|
||||
const pushData = event.data.json()
|
||||
notificationData = {
|
||||
...notificationData,
|
||||
...pushData
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse push data:', error)
|
||||
// Use default notification data
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(notificationData.title, notificationData)
|
||||
)
|
||||
})
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('Notification clicked:', event)
|
||||
|
||||
event.notification.close()
|
||||
|
||||
const action = event.action
|
||||
const notificationData = event.notification.data || {}
|
||||
|
||||
if (action === 'dismiss') {
|
||||
return // Just close the notification
|
||||
}
|
||||
|
||||
// Default action or 'view' action - open the app
|
||||
const urlToOpen = notificationData.url || '/'
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
// Try to find an existing window with the app
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.focus()
|
||||
// Navigate to the notification URL if different
|
||||
if (client.url !== urlToOpen) {
|
||||
client.navigate(urlToOpen)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// If no existing window, open a new one
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
// Background sync for offline notification queue (future enhancement)
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'notification-queue') {
|
||||
event.waitUntil(
|
||||
// Process any queued notifications when back online
|
||||
console.log('Background sync: notification-queue')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Message handler for communication with main app
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('Service worker received message:', event.data)
|
||||
|
||||
if (event.data && event.data.type === 'SHOW_NOTIFICATION') {
|
||||
const { title, body, data } = event.data.payload
|
||||
|
||||
self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/pwa-192x192.png',
|
||||
data,
|
||||
tag: 'manual-notification',
|
||||
requireInteraction: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -26,8 +26,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Top bar with login -->
|
||||
|
|
|
|||
|
|
@ -46,8 +46,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Main content (with bottom padding for nav bar) -->
|
||||
|
|
|
|||
|
|
@ -177,4 +177,31 @@
|
|||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Match shadcn ScrollArea look on native scrollbars (full-page scroll,
|
||||
textareas, anything not wrapped in <ScrollArea>). */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--color-border) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-muted-foreground);
|
||||
background-clip: content-box;
|
||||
}
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const openSidebar = () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="min-h-dvh bg-background">
|
||||
<!-- Mobile Drawer -->
|
||||
<MobileDrawer v-model:open="sidebarOpen" />
|
||||
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
|
|
|
|||
38
src/lib/decommission-hub-sw.ts
Normal file
38
src/lib/decommission-hub-sw.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Unregister the legacy hub service worker (closes #41).
|
||||
*
|
||||
* Pre-#41 the hub shipped a Workbox SW at `${origin}/sw.js` with scope `/`,
|
||||
* which claimed the whole origin and blocked Chrome from offering installs
|
||||
* for the path-mounted standalones at /libra/, /market/, etc. The hub is no
|
||||
* longer a PWA; this helper proactively cleans up that stale registration
|
||||
* for users who installed the hub before this change shipped.
|
||||
*
|
||||
* Only touches SWs whose scope is the origin root. Standalone SWs at
|
||||
* /libra/, /market/, etc. live at deeper scopes and are left alone — they
|
||||
* are still legitimate PWAs.
|
||||
*
|
||||
* Idempotent: once the legacy registration is gone, future calls are
|
||||
* cheap no-ops. Safe to leave in place permanently.
|
||||
*/
|
||||
export async function decommissionHubServiceWorker(): Promise<void> {
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
|
||||
try {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
const originRoot = `${location.origin}/`
|
||||
const legacy = regs.filter(r => r.scope === originRoot)
|
||||
if (legacy.length === 0) return
|
||||
|
||||
console.warn(
|
||||
`[decommission-hub-sw] Unregistering ${legacy.length} legacy hub service worker(s).`
|
||||
)
|
||||
await Promise.all(legacy.map(r => r.unregister()))
|
||||
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map(k => caches.delete(k)))
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[decommission-hub-sw] failed to unregister:', err)
|
||||
}
|
||||
}
|
||||
17
src/main.ts
17
src/main.ts
|
|
@ -1,24 +1,15 @@
|
|||
// New modular application entry point
|
||||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import { decommissionHubServiceWorker } from '@/lib/decommission-hub-sw'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
// Clean up any leftover dev-mode service workers from a previous session
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
// Simple periodic service worker updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('App ready to work offline')
|
||||
}
|
||||
})
|
||||
// Hub is no longer a PWA (#41) — unregister any legacy hub SW left behind
|
||||
// on users who installed the old hub PWA before this change shipped.
|
||||
void decommissionHubServiceWorker()
|
||||
|
||||
// Start the modular application
|
||||
startApp()
|
||||
|
|
|
|||
|
|
@ -69,8 +69,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ onMounted(() => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<div class="flex flex-col h-[calc(100dvh-3.5rem)]">
|
||||
<!-- Loading overlay -->
|
||||
<div v-if="isLoading && geoActivities.length === 0" class="flex-1 flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex flex-col items-center justify-center min-h-screen">
|
||||
<div v-if="isLoading" class="flex flex-col items-center justify-center min-h-dvh">
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<div class="text-center space-y-2">
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="flex flex-col items-center justify-center min-h-screen">
|
||||
<div v-else-if="error" class="flex flex-col items-center justify-center min-h-dvh">
|
||||
<div class="text-center space-y-4">
|
||||
<h2 class="text-xl font-semibold text-red-600">Failed to load chat</h2>
|
||||
<p class="text-muted-foreground">{{ error }}</p>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Chat Content - Only render when module is ready -->
|
||||
<div v-else class="h-[calc(100vh-3.5rem)] lg:h-[calc(100vh-4rem)] xl:h-[calc(100vh-5rem)] w-full">
|
||||
<div v-else class="h-[calc(100dvh-3.5rem)] lg:h-[calc(100dvh-4rem)] xl:h-[calc(100dvh-5rem)] w-full">
|
||||
<ChatComponent />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ watch(comments, (newComments) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="min-h-dvh bg-background">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b">
|
||||
<div class="max-w-4xl mx-auto px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ function onSubmissionClick(submission: SubmissionWithMeta) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-background">
|
||||
<div class="flex flex-col h-dvh bg-background">
|
||||
<div class="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between px-4 py-2 sm:px-6">
|
||||
<h1 class="text-lg font-semibold">Forum</h1>
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ watch(comments, (newComments) => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="min-h-dvh bg-background">
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-30 bg-background/95 backdrop-blur border-b">
|
||||
<div class="max-w-4xl mx-auto px-4 py-3">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-screen bg-background">
|
||||
<div class="flex flex-col h-dvh bg-background">
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||
<div class="flex items-center justify-between px-4 py-2 sm:px-6">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="flex flex-col h-screen bg-background">
|
||||
<div class="flex flex-col h-dvh bg-background">
|
||||
<PWAInstallPrompt auto-show />
|
||||
|
||||
<!-- Header -->
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ function notImplemented() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-screen flex flex-col text-foreground overflow-hidden bg-background"
|
||||
<div class="relative h-dvh flex flex-col text-foreground overflow-hidden bg-background"
|
||||
style="
|
||||
background-image:
|
||||
linear-gradient(to bottom,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div class="min-h-dvh flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
<!-- Logo and Title -->
|
||||
<div class="text-center space-y-6">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
class="min-h-screen flex items-start sm:items-center justify-center px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8 sm:py-12">
|
||||
class="min-h-dvh flex items-start sm:items-center justify-center px-4 sm:px-6 lg:px-8 xl:px-12 2xl:px-16 py-8 sm:py-12">
|
||||
<div class="flex flex-col items-center justify-center space-y-3 sm:space-y-6 max-w-4xl mx-auto w-full mt-8 sm:mt-0">
|
||||
<!-- Welcome Section -->
|
||||
<div class="text-center space-y-2 sm:space-y-4">
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ async function handleLoginSuccess() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
<div class="min-h-dvh bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-dvh flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Top bar with login -->
|
||||
|
|
|
|||
|
|
@ -2,12 +2,16 @@ import { fileURLToPath, URL } from 'node:url'
|
|||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
|
||||
// https://vite.dev/config/
|
||||
//
|
||||
// The hub is intentionally NOT a PWA (closes #41). Its scope `/` claimed
|
||||
// the entire origin and blocked Chrome from offering installs for the
|
||||
// path-mounted standalones at /libra/, /market/, etc. The hub is a
|
||||
// launcher page; users install the standalones they actually use.
|
||||
export default defineConfig(({ mode }) => ({
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-hub',
|
||||
|
|
@ -18,54 +22,6 @@ export default defineConfig(({ mode }) => ({
|
|||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
// SW disabled in dev — was caching stale bundles across restarts.
|
||||
// Run `npm run preview` to test PWA behaviour against a real build.
|
||||
enabled: false
|
||||
},
|
||||
// strategies: 'injectManifest',
|
||||
srcDir: 'public',
|
||||
filename: 'sw.js',
|
||||
workbox: {
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,ico,png,svg}'
|
||||
],
|
||||
// Don't intercept standalone app paths — they have their own service workers
|
||||
navigateFallbackDenylist: [/^\/sortir\//, /^\/libra\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
// optional: include the icon PNGs explicitly if you also reference them directly
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'AIO - Community Hub',
|
||||
short_name: 'AIO',
|
||||
description: 'Nostr-based community platform with Lightning Network integration for events, market and announcements',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
id: 'aio-community-hub',
|
||||
categories: ['social', 'utilities'],
|
||||
lang: 'en',
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
}
|
||||
}),
|
||||
Inspect(),
|
||||
ViteImageOptimizer({
|
||||
jpg: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue