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.
220 lines
9.0 KiB
TypeScript
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>
|
|
)
|
|
}
|