feat: PWA Phase 2 — push notifications
Add web push notification support: push_subscriptions table (migration 0007), VAPID-based push service, subscribe/unsubscribe API routes, SW push+notificationclick handlers, and subscribe UI on account page. Triggers: new version uploaded (all project members) and version approved/rejected (uploader). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,5 +34,5 @@ export const api = {
|
||||
get: <T>(path: string, silent = false) => request<T>(path, { silent }),
|
||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
||||
};
|
||||
|
||||
70
apps/web/src/lib/stores/push.ts
Normal file
70
apps/web/src/lib/stores/push.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { api } from '$lib/api/client.js';
|
||||
|
||||
export type PushState = 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed';
|
||||
|
||||
let _state = $state<PushState>('unsupported');
|
||||
let _loading = $state(false);
|
||||
|
||||
export const pushStore = {
|
||||
get state() { return _state; },
|
||||
get loading() { return _loading; },
|
||||
};
|
||||
|
||||
export async function initPush(): Promise<void> {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
if (Notification.permission === 'denied') { _state = 'denied'; return; }
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
_state = sub ? 'subscribed' : 'unsubscribed';
|
||||
}
|
||||
|
||||
export async function subscribePush(): Promise<void> {
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
try {
|
||||
const { key } = await api.get<{ key: string }>('/push/vapid-public-key');
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key),
|
||||
});
|
||||
const json = sub.toJSON();
|
||||
await api.post('/push/subscribe', {
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: json.keys!.p256dh, auth: json.keys!.auth },
|
||||
userAgent: navigator.userAgent.slice(0, 200),
|
||||
});
|
||||
_state = 'subscribed';
|
||||
} catch (err: any) {
|
||||
if (Notification.permission === 'denied') _state = 'denied';
|
||||
console.error('[Push] subscribe error:', err.message);
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unsubscribePush(): Promise<void> {
|
||||
if (_loading) return;
|
||||
_loading = true;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await api.delete('/push/subscribe', { endpoint: sub.endpoint });
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
_state = 'unsubscribed';
|
||||
} finally {
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
const arr = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { user } from '$lib/stores/auth.js';
|
||||
import { api } from '$lib/api/client.js';
|
||||
import { toastSuccess } from '$lib/stores/toast.js';
|
||||
@@ -6,10 +7,13 @@
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Avatar from '$lib/components/ui/Avatar.svelte';
|
||||
import TopBar from '$lib/components/workspace/TopBar.svelte';
|
||||
import { pushStore, initPush, subscribePush, unsubscribePush } from '$lib/stores/push.js';
|
||||
|
||||
let name = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
onMount(() => initPush());
|
||||
|
||||
$effect(() => {
|
||||
if ($user && !name) name = $user.name;
|
||||
});
|
||||
@@ -35,6 +39,25 @@
|
||||
<p class="sub">Dein Profil — sichtbar für andere im Projekt.</p>
|
||||
</header>
|
||||
|
||||
{#if pushStore.state !== 'unsupported'}
|
||||
<section class="card">
|
||||
<h2>Benachrichtigungen</h2>
|
||||
{#if pushStore.state === 'denied'}
|
||||
<p class="push-hint">Push-Benachrichtigungen wurden im Browser blockiert. Bitte in den Browser-Einstellungen erlauben.</p>
|
||||
{:else if pushStore.state === 'subscribed'}
|
||||
<div class="push-row">
|
||||
<span class="push-active">Push-Benachrichtigungen aktiv</span>
|
||||
<Button size="sm" variant="ghost" onclick={unsubscribePush} loading={pushStore.loading}>Deaktivieren</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="push-row">
|
||||
<span class="push-desc">Werde benachrichtigt wenn neue Versionen hochgeladen oder freigegeben werden.</span>
|
||||
<Button size="sm" onclick={subscribePush} loading={pushStore.loading}>Aktivieren</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if $user}
|
||||
<section class="card">
|
||||
<h2>Profil</h2>
|
||||
@@ -112,4 +135,28 @@
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.push-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.push-active {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
flex: 1;
|
||||
}
|
||||
.push-desc {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
flex: 1;
|
||||
}
|
||||
.push-hint {
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.card + .card {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,6 +34,42 @@ sw.addEventListener('activate', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('push', (event) => {
|
||||
if (!event.data) return;
|
||||
let payload: { title?: string; body?: string; url?: string } = {};
|
||||
try {
|
||||
payload = event.data.json();
|
||||
} catch {
|
||||
payload = { title: 'Music Hub', body: event.data.text() };
|
||||
}
|
||||
event.waitUntil(
|
||||
sw.registration.showNotification(payload.title ?? 'Music Hub', {
|
||||
body: payload.body,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
data: { url: payload.url ?? '/' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url ?? '/';
|
||||
event.waitUntil(
|
||||
sw.clients
|
||||
.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(sw.location.origin) && 'focus' in client) {
|
||||
(client as WindowClient).navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return sw.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
sw.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
|
||||
Reference in New Issue
Block a user