Files
mail-client/src/components/CaldavModal.tsx
T
KanjiG 20bfd2cf6b Initial commit: Outlook-competitor desktop mail client
Tauri v2 (Rust backend, React/TypeScript frontend). IMAP/POP3/SMTP core,
Google OAuth (mail/calendar/contacts sync), CalDAV, local calendar with
recurrence, rules engine, contacts, tasks, unified inbox, context menus,
drag & drop, snooze, templates, and more.
2026-07-28 20:56:05 +02:00

220 lines
9.0 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Globe, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../lib/api'
import type { CaldavAccount, NewCaldavAccount } from '../types'
const inputClass =
'w-full rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-[13px] text-zinc-800 outline-none transition focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:ring-indigo-500/20'
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="flex flex-col gap-1">
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
{children}
</label>
)
}
export function CaldavModal({
open,
onClose,
onToast,
onSynced,
}: {
open: boolean
onClose: () => void
onToast: (msg: string) => void
onSynced: () => void
}) {
const [accounts, setAccounts] = useState<CaldavAccount[]>([])
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState<string | null>(null)
const [showForm, setShowForm] = useState(false)
const [label, setLabel] = useState('')
const [url, setUrl] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
function refresh() {
setLoading(true)
api
.listCaldavAccounts()
.then(setAccounts)
.catch((e) => onToast(`Kalender konnten nicht geladen werden: ${e}`))
.finally(() => setLoading(false))
}
useEffect(() => {
if (open) {
refresh()
setShowForm(false)
setLabel('')
setUrl('')
setUsername('')
setPassword('')
setError(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
if (!open) return null
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSaving(true)
try {
const newAccount: NewCaldavAccount = { label: label.trim() || url.trim(), url: url.trim(), username: username.trim(), password }
const created = await api.addCaldavAccount(newAccount)
setShowForm(false)
refresh()
onToast('Kalender verbunden — synchronisiere…')
await handleSync(created.id)
} catch (err) {
setError(String(err))
} finally {
setSaving(false)
}
}
async function handleSync(id: string) {
setSyncing(id)
try {
const count = await api.syncCaldavCalendar(id)
onToast(`${count} Termine synchronisiert`)
onSynced()
} catch (e) {
onToast(`Synchronisierung fehlgeschlagen: ${e}`)
} finally {
setSyncing(null)
}
}
async function handleDelete(a: CaldavAccount) {
if (!window.confirm(`Kalender „${a.label}“ trennen? Lokal zwischengespeicherte Termine werden entfernt.`)) return
setAccounts((prev) => prev.filter((x) => x.id !== a.id))
try {
await api.deleteCaldavAccount(a.id)
onSynced()
} catch (e) {
onToast(String(e))
}
}
return (
<div className="animate-overlay-in fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/40 p-6 backdrop-blur-sm dark:bg-black/60">
<div className="animate-panel-in flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-[28px] border border-white/70 bg-white/95 shadow-[0_24px_70px_-12px_rgba(15,15,25,0.35)] ring-1 ring-black/[0.03] backdrop-blur-2xl dark:border-white/10 dark:bg-zinc-900/90 dark:shadow-[0_24px_70px_-12px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<h2 className="flex items-center gap-2 text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
<Globe size={16} className="text-zinc-400" /> CalDAV-Kalender
</h2>
<button
onClick={onClose}
className="rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<X size={16} />
</button>
</div>
<div className="flex-1 space-y-4 overflow-y-auto px-6 py-5">
<p className="text-[11.5px] text-zinc-400">
Für Nextcloud, iCloud, Radicale & Co. trag die direkte Kalender-Adresse ein (kein automatisches Erkennen
der Kalenderliste). Bei iCloud/Nextcloud brauchst du meist ein App-Passwort statt deines normalen Passworts.
</p>
{showForm ? (
<form onSubmit={handleAdd} className="space-y-2.5 rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-950/40">
<Field label="Name">
<input className={inputClass} value={label} onChange={(e) => setLabel(e.target.value)} placeholder="z. B. Privat" />
</Field>
<Field label="Kalender-URL">
<input
className={inputClass}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://cloud.example.com/remote.php/dav/calendars/user/personal/"
required
/>
</Field>
<Field label="Benutzername">
<input className={inputClass} value={username} onChange={(e) => setUsername(e.target.value)} required />
</Field>
<Field label="Passwort / App-Passwort">
<input className={inputClass} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-[11.5px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
)}
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !url.trim() || !username.trim() || !password}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-1.5 text-[12.5px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
>
{saving ? 'Verbinde…' : 'Verbinden'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
className="rounded-full px-4 py-1.5 text-[12.5px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
</form>
) : (
<button
type="button"
onClick={() => setShowForm(true)}
className="flex w-full items-center justify-center gap-1.5 rounded-2xl border border-dashed border-zinc-300 py-2.5 text-[12.5px] font-medium text-zinc-500 transition hover:border-indigo-300 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800"
>
<Plus size={14} /> Kalender verbinden
</button>
)}
<div>
{loading ? (
<p className="text-xs text-zinc-400">Lade</p>
) : accounts.length === 0 ? (
<p className="text-xs text-zinc-400">Noch kein CalDAV-Kalender verbunden.</p>
) : (
<div className="space-y-1.5">
{accounts.map((a) => (
<div
key={a.id}
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
>
<div className="min-w-0 flex-1">
<p className="truncate text-[12.5px] font-medium text-zinc-700 dark:text-zinc-300">{a.label}</p>
<p className="truncate text-[11px] text-zinc-400">{a.url}</p>
</div>
<button
onClick={() => handleSync(a.id)}
disabled={syncing === a.id}
title="Jetzt synchronisieren"
className="shrink-0 rounded-full p-1.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
>
<RefreshCw size={13} className={clsx(syncing === a.id && 'animate-spin')} />
</button>
<button
onClick={() => handleDelete(a)}
className="shrink-0 rounded-full p-1.5 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/40"
>
<Trash2 size={13} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}