Add recurring events, push notifications, config sync — and mobile layout fixes
Recurring event editing (rrule) with this/all occurrence scope, web push subscriptions (VAPID) with a cached config layer for notification settings, and email delivery via nodemailer. Also fixes the calendar view never actually stacking on mobile: the Calendars filter column used flex:1 with minWidth:0 on its sibling, so flex-wrap never triggered regardless of viewport width, squeezing the grid and view switcher into a sliver next to a fixed 220px sidebar. Adds a proper mobile breakpoint that stacks the layout, scrolls the week grid horizontally instead of compressing it, and enlarges touch targets.
This commit is contained in:
parent
dad3cc472c
commit
ca0dc9b070
28 changed files with 1517 additions and 155 deletions
|
|
@ -1,7 +1,18 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
||||
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
||||
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential } from '../api'
|
||||
import { fetchCaldavCredentials, createCaldavCredential, deleteCaldavCredential, fetchVapidKey, subscribePush, unsubscribePush } from '../api'
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(b64)
|
||||
const buf = new Uint8Array(raw.length)
|
||||
for (let i = 0; i < raw.length; i++) buf[i] = raw.charCodeAt(i)
|
||||
return buf.buffer
|
||||
}
|
||||
|
||||
type PushState = 'unsupported' | 'denied' | 'off' | 'on' | 'checking'
|
||||
|
||||
export default function CalDavSetup() {
|
||||
const [credentials, setCredentials] = useState<CaldavCredential[]>([])
|
||||
|
|
@ -11,6 +22,74 @@ export default function CalDavSetup() {
|
|||
const [creating, setCreating] = useState(false)
|
||||
const [revealed, setRevealed] = useState<CaldavCredentialCreated | null>(null)
|
||||
|
||||
const [pushState, setPushState] = useState<PushState>('checking')
|
||||
const [pushBusy, setPushBusy] = useState(false)
|
||||
const [pushError, setPushError] = useState<string | null>(null)
|
||||
|
||||
const refreshPushState = useCallback(async () => {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
setPushState('unsupported')
|
||||
return
|
||||
}
|
||||
if (Notification.permission === 'denied') {
|
||||
setPushState('denied')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
setPushState(sub ? 'on' : 'off')
|
||||
} catch {
|
||||
setPushState('off')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refreshPushState() }, [refreshPushState])
|
||||
|
||||
async function enablePush() {
|
||||
setPushBusy(true)
|
||||
setPushError(null)
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
if (Notification.permission === 'default') {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') { setPushState('denied'); return }
|
||||
} else if (Notification.permission === 'denied') {
|
||||
setPushState('denied')
|
||||
return
|
||||
}
|
||||
const { publicKey } = await fetchVapidKey()
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
})
|
||||
await subscribePush(sub.toJSON())
|
||||
setPushState('on')
|
||||
} catch (err) {
|
||||
setPushError(err instanceof Error ? err.message : 'Failed to enable push notifications')
|
||||
} finally {
|
||||
setPushBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePush() {
|
||||
setPushBusy(true)
|
||||
setPushError(null)
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await unsubscribePush(sub.endpoint)
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
setPushState('off')
|
||||
} catch (err) {
|
||||
setPushError(err instanceof Error ? err.message : 'Failed to disable push notifications')
|
||||
} finally {
|
||||
setPushBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const caldavUrl = `${window.location.origin}/calendar/caldav/`
|
||||
|
||||
const reload = useCallback(() => {
|
||||
|
|
@ -72,6 +151,34 @@ export default function CalDavSetup() {
|
|||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div className="section-title">Push notifications</div>
|
||||
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{pushState === 'on' ? (
|
||||
<Bell size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||
) : (
|
||||
<BellOff size={18} strokeWidth={1.75} style={{ color: 'var(--text-mid)' }} />
|
||||
)}
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13.5 }}>
|
||||
{pushState === 'unsupported' && 'Not supported on this device/browser'}
|
||||
{pushState === 'denied' && 'Blocked — enable notifications for this site in your browser settings'}
|
||||
{pushState === 'checking' && 'Checking status…'}
|
||||
{pushState === 'off' && 'Notifications are off on this device'}
|
||||
{pushState === 'on' && 'Notifications are on for this device'}
|
||||
</div>
|
||||
{pushError && <div className="field-hint" style={{ color: 'var(--danger)' }}>{pushError}</div>}
|
||||
</div>
|
||||
{(pushState === 'off' || pushState === 'on') && (
|
||||
<button
|
||||
className={`btn btn-sm ${pushState === 'off' ? 'btn-primary' : ''}`}
|
||||
onClick={pushState === 'off' ? enablePush : disablePush}
|
||||
disabled={pushBusy}
|
||||
>
|
||||
{pushBusy ? 'Working…' : pushState === 'off' ? 'Enable' : 'Disable'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-title">Your CalDAV devices</div>
|
||||
{loading ? (
|
||||
<div className="empty-state">Loading…</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue