Added native os notifications
This commit is contained in:
+3
-1
@@ -22,4 +22,6 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
pgdata/
|
pgdata/
|
||||||
|
/public/sw.js
|
||||||
|
/public/manifest.webmanifest
|
||||||
|
|||||||
+3
-2
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Http\Controllers\UserFlightController;
|
|
||||||
use App\Settings\SettingsRegistry;
|
use App\Settings\SettingsRegistry;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -12,9 +11,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use App\Traits\HasAchievements;
|
use App\Traits\HasAchievements;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use NotificationChannels\WebPush\HasPushSubscriptions;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])]
|
#[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])]
|
||||||
@@ -23,7 +24,7 @@ class User extends Authenticatable
|
|||||||
{
|
{
|
||||||
|
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, HasAchievements, HasApiTokens, HasRoles;
|
use HasFactory, HasAchievements, HasApiTokens, HasRoles, HasPushSubscriptions, Notifiable;
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Models\Notification as NotificationModel;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use NotificationChannels\WebPush\WebPushChannel;
|
||||||
|
use NotificationChannels\WebPush\WebPushMessage;
|
||||||
|
|
||||||
|
class PushNotification extends Notification
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected NotificationModel $notification
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function via($notifiable): array
|
||||||
|
{
|
||||||
|
return [WebPushChannel::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toWebPush($notifiable, $notification): WebPushMessage
|
||||||
|
{
|
||||||
|
return (new WebPushMessage)
|
||||||
|
->title($this->notification->title)
|
||||||
|
->body($this->notification->body)
|
||||||
|
->icon( '/img/app_icons/icon-192.png')
|
||||||
|
->data([
|
||||||
|
'url' => $this->notification->url,
|
||||||
|
'notification_id' => $this->notification->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,21 @@ namespace App\Observers;
|
|||||||
|
|
||||||
use App\Events\NotificationCreated;
|
use App\Events\NotificationCreated;
|
||||||
use App\Models\Notification;
|
use App\Models\Notification;
|
||||||
|
use App\Notifications\PushNotification;
|
||||||
|
|
||||||
class NotificationObserver
|
class NotificationObserver
|
||||||
{
|
{
|
||||||
public function created(Notification $notification): void
|
public function created(Notification $notification): void
|
||||||
{
|
{
|
||||||
\Log::info('Notification created: ' . $notification->id);
|
\Log::info('Notification created: ' . $notification->id);
|
||||||
|
|
||||||
broadcast(new NotificationCreated($notification));
|
broadcast(new NotificationCreated($notification));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$notification->user->notify(new \App\Notifications\PushNotification($notification));
|
||||||
|
\Log::info('WebPush notify() called for user ' . $notification->user_id);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('WebPush send failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ return [
|
|||||||
'https://flightsgoneby.com',
|
'https://flightsgoneby.com',
|
||||||
'https://www.flightsgoneby.com',
|
'https://www.flightsgoneby.com',
|
||||||
'http://flightsgoneby.test:8000',
|
'http://flightsgoneby.test:8000',
|
||||||
|
'http://localhost:8000',
|
||||||
],
|
],
|
||||||
|
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [],
|
||||||
|
|||||||
Generated
+2
-1
@@ -39,7 +39,8 @@
|
|||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vite-plugin-vuetify": "^2.1.3",
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0",
|
||||||
|
"workbox-precaching": "^7.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|||||||
+4
-1
@@ -4,8 +4,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "cp public/build/sw.js public/sw.js && cp public/build/manifest.webmanifest public/manifest.webmanifest",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" \"npm run dev\" > /dev/null 2>&1",
|
"dev:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" \"npm run dev\" > /dev/null 2>&1",
|
||||||
|
"serve:all": "concurrently -s all \"php artisan serve\" \"php artisan reverb:start\" \"php artisan queue:work\" > /dev/null 2>&1",
|
||||||
"updateVersion": "cross-var docker build -t dredgy/flights-api:$npm_config_tag -t dredgy/flights-api:latest . && cross-var docker push dredgy/flights-api:$npm_config_tag && docker push dredgy/flights-api:latest"
|
"updateVersion": "cross-var docker build -t dredgy/flights-api:$npm_config_tag -t dredgy/flights-api:latest . && cross-var docker push dredgy/flights-api:$npm_config_tag && docker push dredgy/flights-api:latest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -31,7 +33,8 @@
|
|||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vite-plugin-vuetify": "^2.1.3",
|
"vite-plugin-vuetify": "^2.1.3",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0",
|
||||||
|
"workbox-precaching": "^7.4.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
self.addEventListener('push', (event) => {
|
|
||||||
const data = event.data.json();
|
|
||||||
event.waitUntil(
|
|
||||||
self.registration.showNotification(data.title, {
|
|
||||||
body: data.body,
|
|
||||||
icon: data.icon || '/icons/trophy-192.png',
|
|
||||||
badge: '/icons/badge-72.png',
|
|
||||||
data: data.data,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('notificationclick', (event) => {
|
|
||||||
event.notification.close();
|
|
||||||
const url = event.notification.data?.url || '/';
|
|
||||||
event.waitUntil(
|
|
||||||
clients.matchAll({ type: 'window' }).then((clientList) => {
|
|
||||||
for (const client of clientList) {
|
|
||||||
if (client.url.includes(url) && 'focus' in client) return client.focus();
|
|
||||||
}
|
|
||||||
if (clients.openWindow) return clients.openWindow(url);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { usePushNotifications } from '@/Composables/usePushNotifications';
|
||||||
|
|
||||||
|
const { showPrompt, initPrompt, enable, dismiss } = usePushNotifications();
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initPrompt();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-snackbar
|
||||||
|
v-model="showPrompt"
|
||||||
|
:timeout="-1"
|
||||||
|
location="bottom"
|
||||||
|
color="surface"
|
||||||
|
class="push-prompt"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center ga-3">
|
||||||
|
<v-icon icon="mdi-bell-outline" />
|
||||||
|
<span>Receive notifications for things that are important for you</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<v-btn variant="text" @click="dismiss">Not now</v-btn>
|
||||||
|
<v-btn variant="text" color="primary" @click="enable">Enable</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// resources/js/composables/usePushNotifications.ts
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'push-permission-prompted';
|
||||||
|
|
||||||
|
export function usePushNotifications() {
|
||||||
|
const isSupported = 'serviceWorker' in navigator && 'PushManager' in window;
|
||||||
|
const showPrompt = ref(false);
|
||||||
|
|
||||||
|
function shouldPrompt(): boolean {
|
||||||
|
if (!isSupported) return false;
|
||||||
|
if (Notification.permission !== 'default') return false;
|
||||||
|
if (localStorage.getItem(STORAGE_KEY)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPrompt() {
|
||||||
|
showPrompt.value = shouldPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ← replace the old version with this one
|
||||||
|
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const rawData = atob(base64);
|
||||||
|
|
||||||
|
const buffer = new ArrayBuffer(rawData.length);
|
||||||
|
const outputArray = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
for (let i = 0; i < rawData.length; i++) {
|
||||||
|
outputArray[i] = rawData.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enable() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
showPrompt.value = false;
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') return;
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(window.vapidPublicKey),
|
||||||
|
});
|
||||||
|
|
||||||
|
await axios.post('/push-subscriptions', subscription.toJSON());
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, '1');
|
||||||
|
showPrompt.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isSupported, showPrompt, initPrompt, enable, dismiss };
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {router, usePage} from "@inertiajs/vue3";
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
|
import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue";
|
||||||
import {SharedProps} from "@/Types/types";
|
import {SharedProps} from "@/Types/types";
|
||||||
|
import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue";
|
||||||
const transitionKey = ref(0);
|
const transitionKey = ref(0);
|
||||||
|
|
||||||
const page = usePage<SharedProps>().props
|
const page = usePage<SharedProps>().props
|
||||||
@@ -31,6 +32,7 @@ router.on('success', () => {
|
|||||||
<NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
|
<NotificationListener v-if="page.auth.user?.id" :user-id="page.auth.user?.id" />
|
||||||
<StatusNotifications />
|
<StatusNotifications />
|
||||||
<ToastNotifications />
|
<ToastNotifications />
|
||||||
|
<PushNotificationPrompt v-if="page.auth.user?.id" />
|
||||||
</Radar>
|
</Radar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -4,5 +4,6 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
Echo: Echo<'reverb'>;
|
Echo: Echo<'reverb'>;
|
||||||
Pusher: unknown;
|
Pusher: unknown;
|
||||||
|
vapidPublicKey: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {createPinia} from "pinia";
|
|||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
import('virtual:pwa-register').then(({ registerSW }) => {
|
import('virtual:pwa-register').then(({ registerSW }) => {
|
||||||
registerSW({ immediate: true })
|
registerSW({ immediate: true })
|
||||||
navigator.serviceWorker.register('scripts/sw.js');
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
@@ -18,3 +18,5 @@ window.Echo = new Echo({
|
|||||||
forceTLS: data.scheme === 'https',
|
forceTLS: data.scheme === 'https',
|
||||||
enabledTransports: ['ws', 'wss'],
|
enabledTransports: ['ws', 'wss'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.vapidPublicKey = data.vapidPublicKey;
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
latestNotification.value = notification;
|
latestNotification.value = notification;
|
||||||
unreadCount.value++;
|
unreadCount.value++;
|
||||||
|
|
||||||
if (notification.is_achievement) {
|
|
||||||
achievementSound.play().catch(() => {});
|
achievementSound.play().catch(() => {});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dismiss(notification: Notification) {
|
async function dismiss(notification: Notification) {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// This line is required by vite-plugin-pwa's injectManifest strategy —
|
||||||
|
// it's the token the build looks for to inject the precache list.
|
||||||
|
// We don't use it for anything (no offline caching), so it's discarded.
|
||||||
|
self.__WB_MANIFEST;
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
|
||||||
|
const data = event.data.json();
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title, {
|
||||||
|
body: data.body,
|
||||||
|
icon: data.icon || '/img/app_icons/icon-192.png',
|
||||||
|
badge: '/img/app_icons/icon-192.png',
|
||||||
|
data: data.data,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const url = event.notification.data?.url || '/';
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(url) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow(url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
|
||||||
<!-- Apple Touch Icons -->
|
<!-- Apple Touch Icons -->
|
||||||
<link rel="apple-touch-icon" href="/img/app_icons/apple-touch-icon-180.png-">
|
<link rel="apple-touch-icon" href="/img/app_icons/apple-touch-icon-180.png-">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="FlightsGoneBy">
|
<meta name="apple-mobile-web-app-title" content="FlightsGoneBy">
|
||||||
<meta name="theme-color" content="#020d29">
|
<meta name="theme-color" content="#020d29">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ use Inertia\Inertia;
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
Route::get('/dashboard', function () {
|
Route::get('/dashboard', function () {
|
||||||
return Inertia::render('Dashboard');
|
return Inertia::render('Dashboard');
|
||||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
@@ -40,7 +41,20 @@ use Inertia\Inertia;
|
|||||||
Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery');
|
Route::post('/ignore-missing-livery', [AdminController::class, 'ignoreMissingLivery'])->name('ignore-missing-livery');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
|
||||||
|
Route::post('/push-subscriptions', function (Illuminate\Http\Request $request) {
|
||||||
|
$request->user()->updatePushSubscription(
|
||||||
|
$request->endpoint,
|
||||||
|
$request->keys['p256dh'] ?? null,
|
||||||
|
$request->keys['auth'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
});
|
||||||
|
|
||||||
Route::post('/flights', [FlightController::class, 'store'])->name('flights.store');
|
Route::post('/flights', [FlightController::class, 'store'])->name('flights.store');
|
||||||
Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add');
|
Route::get('/flights/add', [FlightController::class, 'add'])->name('flights.add');
|
||||||
Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit');
|
Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit');
|
||||||
|
|||||||
+9
-5
@@ -21,9 +21,17 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
vuetify({ autoImport: true }),
|
vuetify({ autoImport: true }),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
base: '/',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'resources/js',
|
||||||
|
filename: 'sw.js',
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: [],
|
||||||
|
},
|
||||||
devOptions: {
|
devOptions: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
type: 'module',
|
||||||
},
|
},
|
||||||
includeAssets: ['favicon.ico', 'apple-touch-icon-180.png'],
|
includeAssets: ['favicon.ico', 'apple-touch-icon-180.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
@@ -31,6 +39,7 @@ export default defineConfig({
|
|||||||
short_name: 'FlightsGoneBy',
|
short_name: 'FlightsGoneBy',
|
||||||
description: 'Track and log your flight history',
|
description: 'Track and log your flight history',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
background_color: '#020d29',
|
background_color: '#020d29',
|
||||||
theme_color: '#020d29',
|
theme_color: '#020d29',
|
||||||
@@ -41,11 +50,6 @@ export default defineConfig({
|
|||||||
{ src: '/img/app_icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
{ src: '/img/app_icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
workbox: {
|
|
||||||
// Avoid caching Inertia/API responses by default — just static assets
|
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
|
|
||||||
navigateFallbackDenylist: [/^\/api/, /^\/admin/],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
Reference in New Issue
Block a user