Release 202506020353
This commit is contained in:
@@ -6,7 +6,7 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
||||
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
|
||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||
VariantProps<typeof button> & {
|
||||
as: Tag
|
||||
@@ -249,7 +249,7 @@ const button = tv({
|
||||
})
|
||||
|
||||
const {
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label',
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
|
||||
label,
|
||||
icon,
|
||||
endIcon,
|
||||
@@ -286,8 +286,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
|
||||
<ActualTag
|
||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||
role={role ??
|
||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
|
||||
@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hideCancel?: boolean
|
||||
icon?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
color?: ComponentProps<typeof Button>['color']
|
||||
}
|
||||
|
||||
const {
|
||||
hideCancel = false,
|
||||
icon = 'ri:send-plane-2-line',
|
||||
label = 'Submit',
|
||||
disabled = false,
|
||||
class: className,
|
||||
color = 'success',
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
|
||||
</div>
|
||||
|
||||
374
web/src/components/PushNotificationBanner.astro
Normal file
374
web/src/components/PushNotificationBanner.astro
Normal file
@@ -0,0 +1,374 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
dismissable?: boolean
|
||||
hideIfEnabled?: boolean
|
||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||
select: {
|
||||
endpoint: true
|
||||
userAgent: true
|
||||
}
|
||||
}>[]
|
||||
}
|
||||
|
||||
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
||||
|
||||
// TODO: Feature flag, enabled only for admins
|
||||
if (!Astro.locals.user?.admin) {
|
||||
return null
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
data-push-notification-banner
|
||||
data-dismissed={undefined /* Updated by client script */}
|
||||
data-supports-push-notifications={undefined /* Updated by client script */}
|
||||
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
||||
data-is-enabled={undefined /* Updated by client script */}
|
||||
class={cn(
|
||||
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
|
||||
'data-dismissed:hidden',
|
||||
hideIfEnabled && 'data-is-enabled:hidden',
|
||||
'not-data-supports-push-notifications:hidden',
|
||||
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="rounded-md bg-blue-800/30 p-2">
|
||||
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-blue-100">
|
||||
<span data-show-if-enabled>Push notifications enabled</span>
|
||||
<span data-show-if-disabled>Turn on push notifications?</span>
|
||||
</h3>
|
||||
<p class="text-sm text-blue-200/80">
|
||||
<span data-show-if-enabled>Turn notifications off for this device?</span>
|
||||
<span data-show-if-disabled>Get notifications on this device.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
||||
<Button
|
||||
as="span"
|
||||
label="Yes, notify me"
|
||||
color="white"
|
||||
data-push-action="subscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-disabled
|
||||
/>
|
||||
<Button
|
||||
as="span"
|
||||
label="Stop notifications"
|
||||
color="white"
|
||||
variant="faded"
|
||||
data-push-action="unsubscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-enabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification banner dismissal. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
|
||||
import { typedLocalStorage } from '../lib/localstorage'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
|
||||
|
||||
if (
|
||||
pushNotificationsBannerDismissedAt &&
|
||||
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
|
||||
) {
|
||||
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
|
||||
pushNotificationsBannerDismissedAt = undefined
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
|
||||
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
|
||||
if (!skipButton) return
|
||||
|
||||
if (pushNotificationsBannerDismissedAt) {
|
||||
banner.dataset.dismissed = ''
|
||||
}
|
||||
|
||||
skipButton.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
banner.dataset.dismissed = ''
|
||||
|
||||
const now = new Date()
|
||||
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
|
||||
pushNotificationsBannerDismissedAt = now
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////
|
||||
// Script to style when notifications enabled. //
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
/** Parse push subscriptions from string */
|
||||
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||
try {
|
||||
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
||||
|
||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||
|
||||
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
||||
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
|
||||
throw new Error('Push subscriptions must be an array of objects with endpoint property')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects with userAgent property')
|
||||
}
|
||||
|
||||
return subscriptions as ServerSubscription[]
|
||||
} catch (error) {
|
||||
console.error('Failed to parse push subscriptions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current device has an active push subscription */
|
||||
async function getCurrentPushSubscription() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return null
|
||||
|
||||
return await registration.pushManager.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('Error getting current push subscription:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current subscription matches any server subscription */
|
||||
function isCurrentDeviceSubscribed(
|
||||
currentSubscription: PushSubscription | null,
|
||||
serverSubscriptions: ServerSubscription[]
|
||||
) {
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', async () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
||||
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||
const currentSubscription = await getCurrentPushSubscription()
|
||||
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
||||
|
||||
if (isSubscribed) banner.dataset.isEnabled = ''
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification subscription. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
|
||||
import type { actions } from 'astro:actions'
|
||||
import type { ActionInput } from '../lib/astroActions'
|
||||
|
||||
/** Utility function to convert VAPID key */
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||
const base64 = cleaned + padding
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
/** Check for browser support */
|
||||
function checkSupport() {
|
||||
const isSecure =
|
||||
window.isSecureContext ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js')
|
||||
console.log('Service Worker registered:', registration)
|
||||
|
||||
const readyRegistration = await navigator.serviceWorker.ready
|
||||
console.log('Service Worker is active and ready:', readyRegistration)
|
||||
|
||||
return readyRegistration
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPush(vapidPublicKey: string) {
|
||||
try {
|
||||
if (!checkSupport()) return
|
||||
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
alert('Push notifications permission denied')
|
||||
return
|
||||
}
|
||||
|
||||
const registration = await registerServiceWorker()
|
||||
|
||||
// Subscribe to push manager
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push subscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error)
|
||||
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeFromPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) {
|
||||
console.log('No service worker registration found')
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
console.log('No push subscription found')
|
||||
return
|
||||
}
|
||||
|
||||
// Unsubscribe from browser
|
||||
await subscription.unsubscribe()
|
||||
|
||||
// Remove from server
|
||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push unsubscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push unsubscription failed:', error)
|
||||
alert('Failed to unsubscribe from push notifications')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const supportsPushNotifications = checkSupport()
|
||||
if (supportsPushNotifications) {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
|
||||
element.dataset.supportsPushNotifications = ''
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||
const vapidPublicKey = button.dataset.vapidPublicKey
|
||||
if (!vapidPublicKey) {
|
||||
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
|
||||
return
|
||||
}
|
||||
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const action = button.dataset.pushAction
|
||||
if (action === 'subscribe') {
|
||||
await subscribeToPush(vapidPublicKey)
|
||||
} else if (action === 'unsubscribe') {
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user