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.
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { Bell, BellOff, Copy, Plus, Smartphone, Trash2, TriangleAlert } from 'lucide-react'
|
|
import type { CaldavCredential, CaldavCredentialCreated } from '../types'
|
|
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[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [label, setLabel] = useState('')
|
|
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(() => {
|
|
setLoading(true)
|
|
setError(null)
|
|
fetchCaldavCredentials().then(setCredentials).catch(err => setError(err.message)).finally(() => setLoading(false))
|
|
}, [])
|
|
|
|
useEffect(() => { reload() }, [reload])
|
|
|
|
async function handleCreate() {
|
|
setCreating(true)
|
|
setError(null)
|
|
try {
|
|
const created = await createCaldavCredential(label.trim() || undefined)
|
|
setRevealed(created)
|
|
setLabel('')
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to create credential')
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
async function handleRevoke(id: number) {
|
|
if (!confirm('Revoke this device? It will stop syncing immediately.')) return
|
|
try {
|
|
await deleteCaldavCredential(id)
|
|
if (revealed?.id === id) setRevealed(null)
|
|
reload()
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to revoke credential')
|
|
}
|
|
}
|
|
|
|
function copy(text: string) {
|
|
navigator.clipboard?.writeText(text).catch(() => {})
|
|
}
|
|
|
|
return (
|
|
<div className="page" style={{ maxWidth: 760 }}>
|
|
<div className="page-header">
|
|
<h1>Phone Sync</h1>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<p style={{ marginTop: 0 }}>
|
|
Subscribe to this calendar from your phone or computer's own calendar app (Apple Calendar,
|
|
Google Calendar, Outlook, …) using <strong>CalDAV</strong>. Once set up, events created here
|
|
show up on your device automatically, and new device-created events sync back — no separate app
|
|
needed.
|
|
</p>
|
|
<p style={{ marginBottom: 0 }}>
|
|
Each device needs its own generated username and password below — never share your normal
|
|
hotel login for this.
|
|
</p>
|
|
</div>
|
|
|
|
{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>
|
|
) : credentials.length === 0 ? (
|
|
<div className="empty-state">No devices set up yet.</div>
|
|
) : (
|
|
<div className="table-wrap">
|
|
<table className="data">
|
|
<thead>
|
|
<tr>
|
|
<th>Label</th>
|
|
<th>Username</th>
|
|
<th>Created</th>
|
|
<th>Last used</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{credentials.map(c => (
|
|
<tr key={c.id}>
|
|
<td>{c.label || '—'}</td>
|
|
<td>{c.username}</td>
|
|
<td>{new Date(c.created_at).toLocaleDateString()}</td>
|
|
<td>{c.last_used_at ? new Date(c.last_used_at).toLocaleString() : 'Never'}</td>
|
|
<td style={{ textAlign: 'right' }}>
|
|
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => handleRevoke(c.id)}>
|
|
<Trash2 size={13} strokeWidth={1.75} /> Revoke
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
<div className="field-row" style={{ alignItems: 'flex-end', marginTop: 12 }}>
|
|
<div className="field" style={{ flex: 1 }}>
|
|
<label>Device label (optional)</label>
|
|
<input type="text" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Sarah's iPhone" />
|
|
</div>
|
|
<button className="btn btn-primary" onClick={handleCreate} disabled={creating} style={{ marginBottom: 12 }}>
|
|
<Plus size={14} strokeWidth={1.75} />
|
|
{creating ? 'Generating…' : 'Generate new'}
|
|
</button>
|
|
</div>
|
|
|
|
{revealed && (
|
|
<div className="card" style={{ borderColor: 'var(--gold)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--danger)', fontWeight: 600, marginBottom: 8 }}>
|
|
<TriangleAlert size={16} strokeWidth={1.75} />
|
|
Save this now — the password won't be shown again.
|
|
</div>
|
|
<div className="cal-credential-box">
|
|
<div>Username: {revealed.username} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.username)} /></div>
|
|
<div>Password: {revealed.password} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(revealed.password)} /></div>
|
|
<div>Server URL: {caldavUrl} <Copy size={13} strokeWidth={1.75} style={{ cursor: 'pointer', verticalAlign: 'middle' }} onClick={() => copy(caldavUrl)} /></div>
|
|
</div>
|
|
<button className="btn btn-sm" onClick={() => setRevealed(null)}>I've saved it, hide this</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="section-title">Set-up instructions</div>
|
|
|
|
<div className="card">
|
|
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<Smartphone size={15} strokeWidth={1.75} /> Apple Calendar (iPhone / Mac)
|
|
</div>
|
|
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
|
<li>Settings → Calendar → Accounts → Add Account → Other → Add CalDAV Account.</li>
|
|
<li>Server: <code>{caldavUrl}</code></li>
|
|
<li>User Name / Password: the credentials generated above.</li>
|
|
<li>Tap Next, then Save.</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<Smartphone size={15} strokeWidth={1.75} /> Google Calendar
|
|
</div>
|
|
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
|
<li>Google Calendar doesn't support direct CalDAV subscriptions on the free tier — easiest is to
|
|
use a CalDAV-sync app such as "CalDAV-Sync" (Android) with the server URL and credentials above.</li>
|
|
<li>Alternatively, on desktop, add it as a "secondary" calendar in a CalDAV-aware client and it
|
|
will appear alongside Google Calendar.</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div style={{ fontWeight: 600, marginBottom: 6, display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<Smartphone size={15} strokeWidth={1.75} /> Outlook
|
|
</div>
|
|
<ol style={{ marginTop: 0, paddingLeft: 20 }}>
|
|
<li>Outlook (desktop): File → Account Settings → Internet Calendars → New, then paste <code>{caldavUrl}</code>.</li>
|
|
<li>When prompted, enter the username and password generated above.</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|