diff --git a/.gitignore b/.gitignore index 4e3b4f1..1a39a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ Homestead.json Homestead.yaml Thumbs.db -pgdata/ \ No newline at end of file +pgdata/ +/public/sw.js +/public/manifest.webmanifest diff --git a/app/Models/User.php b/app/Models/User.php index 6fd402f..a6927cd 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,7 +2,6 @@ namespace App\Models; -use App\Http\Controllers\UserFlightController; use App\Settings\SettingsRegistry; use Database\Factories\UserFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; @@ -12,9 +11,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use App\Traits\HasAchievements; +use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Laravel\Sanctum\HasApiTokens; +use NotificationChannels\WebPush\HasPushSubscriptions; use Spatie\Permission\Traits\HasRoles; #[Fillable(['name', 'email', 'password', 'distance_unit', 'settings'])] @@ -23,7 +24,7 @@ class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, HasAchievements, HasApiTokens, HasRoles; + use HasFactory, HasAchievements, HasApiTokens, HasRoles, HasPushSubscriptions, Notifiable; protected $casts = [ 'email_verified_at' => 'datetime', diff --git a/app/Notifications/PushNotification.php b/app/Notifications/PushNotification.php new file mode 100644 index 0000000..07ea3d8 --- /dev/null +++ b/app/Notifications/PushNotification.php @@ -0,0 +1,32 @@ +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, + ]); + } +} diff --git a/app/Observers/NotificationObserver.php b/app/Observers/NotificationObserver.php index 85c4987..2e7dd51 100644 --- a/app/Observers/NotificationObserver.php +++ b/app/Observers/NotificationObserver.php @@ -3,12 +3,21 @@ namespace App\Observers; use App\Events\NotificationCreated; use App\Models\Notification; +use App\Notifications\PushNotification; class NotificationObserver { public function created(Notification $notification): void { \Log::info('Notification created: ' . $notification->id); + 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]); + } } } diff --git a/config/cors.php b/config/cors.php index e6906f1..4827e59 100644 --- a/config/cors.php +++ b/config/cors.php @@ -23,6 +23,7 @@ return [ 'https://flightsgoneby.com', 'https://www.flightsgoneby.com', 'http://flightsgoneby.test:8000', + 'http://localhost:8000', ], 'allowed_origins_patterns' => [], diff --git a/package-lock.json b/package-lock.json index fbaf24b..537c0c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "vite": "^8.0.0", "vite-plugin-pwa": "^1.3.0", "vite-plugin-vuetify": "^2.1.3", - "vue": "^3.4.0" + "vue": "^3.4.0", + "workbox-precaching": "^7.4.1" } }, "node_modules/@alloc/quick-lru": { diff --git a/package.json b/package.json index cc443d8..0fa124c 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,10 @@ "type": "module", "scripts": { "build": "vite build", + "postbuild": "cp public/build/sw.js public/sw.js && cp public/build/manifest.webmanifest public/manifest.webmanifest", "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", + "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" }, "devDependencies": { @@ -31,7 +33,8 @@ "vite": "^8.0.0", "vite-plugin-pwa": "^1.3.0", "vite-plugin-vuetify": "^2.1.3", - "vue": "^3.4.0" + "vue": "^3.4.0", + "workbox-precaching": "^7.4.1" }, "dependencies": { "@mdi/font": "^7.4.47", diff --git a/public/scripts/sw.js b/public/scripts/sw.js deleted file mode 100644 index 83d0c05..0000000 --- a/public/scripts/sw.js +++ /dev/null @@ -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); - }) - ); -}); diff --git a/resources/js/Components/PushNotificationPrompt.vue b/resources/js/Components/PushNotificationPrompt.vue new file mode 100644 index 0000000..18c4e6b --- /dev/null +++ b/resources/js/Components/PushNotificationPrompt.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/js/Composables/usePushNotifications.ts b/resources/js/Composables/usePushNotifications.ts new file mode 100644 index 0000000..cd8c320 --- /dev/null +++ b/resources/js/Composables/usePushNotifications.ts @@ -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 { + 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 }; +} diff --git a/resources/js/Layouts/MainLayout.vue b/resources/js/Layouts/MainLayout.vue index e72fb9e..666dfa3 100644 --- a/resources/js/Layouts/MainLayout.vue +++ b/resources/js/Layouts/MainLayout.vue @@ -8,6 +8,7 @@ import {router, usePage} from "@inertiajs/vue3"; import { ref } from "vue"; import NotificationListener from "@/Components/FlightsGoneBy/NotificationListener.vue"; import {SharedProps} from "@/Types/types"; +import PushNotificationPrompt from "@/Components/PushNotificationPrompt.vue"; const transitionKey = ref(0); const page = usePage().props @@ -31,6 +32,7 @@ router.on('success', () => { + diff --git a/resources/js/Types/echo.d.ts b/resources/js/Types/echo.d.ts index 26a792a..31d5226 100644 --- a/resources/js/Types/echo.d.ts +++ b/resources/js/Types/echo.d.ts @@ -4,5 +4,6 @@ declare global { interface Window { Echo: Echo<'reverb'>; Pusher: unknown; + vapidPublicKey: string; } } diff --git a/resources/js/app.ts b/resources/js/app.ts index f39732f..e147793 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -19,7 +19,6 @@ import {createPinia} from "pinia"; if ('serviceWorker' in navigator) { import('virtual:pwa-register').then(({ registerSW }) => { registerSW({ immediate: true }) - navigator.serviceWorker.register('scripts/sw.js'); }) } diff --git a/resources/js/bootstrap.js b/resources/js/bootstrap.js index d56d6ea..5a996f2 100644 --- a/resources/js/bootstrap.js +++ b/resources/js/bootstrap.js @@ -18,3 +18,5 @@ window.Echo = new Echo({ forceTLS: data.scheme === 'https', enabledTransports: ['ws', 'wss'], }); + +window.vapidPublicKey = data.vapidPublicKey; diff --git a/resources/js/stores/notificationStore.ts b/resources/js/stores/notificationStore.ts index f6dcdd6..67f35a0 100644 --- a/resources/js/stores/notificationStore.ts +++ b/resources/js/stores/notificationStore.ts @@ -14,9 +14,9 @@ export const useNotificationStore = defineStore('notifications', () => { latestNotification.value = notification; unreadCount.value++; - if (notification.is_achievement) { - achievementSound.play().catch(() => {}); - } + + achievementSound.play().catch(() => {}); + } async function dismiss(notification: Notification) { diff --git a/resources/js/sw.js b/resources/js/sw.js new file mode 100644 index 0000000..6d3454e --- /dev/null +++ b/resources/js/sw.js @@ -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); + } + }) + ); +}); diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 3d787a3..a40c54d 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -13,7 +13,7 @@ - + diff --git a/routes/web.php b/routes/web.php index 14e105f..f5829f2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -30,6 +30,7 @@ use Inertia\Inertia; ]); }); + Route::get('/dashboard', function () { return Inertia::render('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::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::get('/flights/add', [FlightController::class, 'add'])->name('flights.add'); Route::get('/flights/{flight}/edit', [FlightController::class, 'edit'])->name('flights.edit'); diff --git a/vite.config.ts b/vite.config.ts index d2ba45d..31dac3f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,9 +21,17 @@ export default defineConfig({ }), vuetify({ autoImport: true }), VitePWA({ + base: '/', registerType: 'autoUpdate', + strategies: 'injectManifest', + srcDir: 'resources/js', + filename: 'sw.js', + injectManifest: { + globPatterns: [], + }, devOptions: { enabled: true, + type: 'module', }, includeAssets: ['favicon.ico', 'apple-touch-icon-180.png'], manifest: { @@ -31,6 +39,7 @@ export default defineConfig({ short_name: 'FlightsGoneBy', description: 'Track and log your flight history', start_url: '/', + scope: '/', display: 'standalone', background_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' }, ], }, - workbox: { - // Avoid caching Inertia/API responses by default — just static assets - globPatterns: ['**/*.{js,css,html,svg,png,ico}'], - navigateFallbackDenylist: [/^\/api/, /^\/admin/], - }, }), ], resolve: {