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.
This commit is contained in:
2026-07-28 20:56:05 +02:00
commit 20bfd2cf6b
107 changed files with 25728 additions and 0 deletions
+2069
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, useState } from 'react'
import { emit, emitTo, listen } from '@tauri-apps/api/event'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { ComposePane, type ComposeInitial } from './components/ComposePane'
import { api } from './lib/api'
import { applyTheme, loadTheme } from './lib/theme'
import type { Account, ComposeMessage } from './types'
const LOCAL_ACCOUNT_ID = 'local'
const DRAFTS_FOLDER = 'Entwürfe'
/// Root component for a detached "popup" compose/reply window (a real OS
/// window, not an overlay) — see main.tsx's `?compose=1` routing. Runs in a
/// fully separate JS/React context from the main window, so it fetches its
/// own account list and receives its initial content via a `compose-init`
/// event emitted by the main window once this window signals `compose-ready`.
export function ComposeWindow() {
const [accounts, setAccounts] = useState<Account[]>([])
const [ready, setReady] = useState(false)
const [initial, setInitial] = useState<ComposeInitial | null>(null)
const [accountId, setAccountId] = useState<string | null>(null)
const [sending, setSending] = useState(false)
const draftUidRef = useRef<number | null>(null)
useEffect(() => {
applyTheme(loadTheme())
api.listAccounts().then(setAccounts).catch(() => {})
const win = getCurrentWebviewWindow()
const unlistenPromise = listen<{ initial: ComposeInitial | null; accountId: string | null }>(
'compose-init',
(event) => {
setInitial(event.payload.initial)
setAccountId(event.payload.accountId)
draftUidRef.current = event.payload.initial?.draftUid ?? null
setReady(true)
},
)
emit('compose-ready', { label: win.label })
return () => {
unlistenPromise.then((f) => f())
}
}, [])
function cleanupDraft() {
const uid = draftUidRef.current
if (uid == null) return
api.deleteMessage(LOCAL_ACCOUNT_ID, DRAFTS_FOLDER, uid).catch(() => {})
}
async function handleSend(msg: ComposeMessage, sendAt: number) {
setSending(true)
try {
const id = await api.sendMessageAt(msg, sendAt)
// Actual sending (and draft cleanup) happens backend-side on its own
// timer, independent of this window — the main window picks it up via
// `scheduled-send-completed` and owns the undo banner from here on.
await emitTo('main', 'compose-scheduled', {
id,
deadline: sendAt,
draftUid: draftUidRef.current,
accountId: msg.account_id,
})
getCurrentWebviewWindow().close()
} finally {
setSending(false)
}
}
function handleDiscard() {
cleanupDraft()
getCurrentWebviewWindow().close()
}
function handleDraftSaved(uid: number) {
draftUidRef.current = uid
}
const account = accounts.find((a) => a.id === accountId) ?? null
if (!ready || accounts.length === 0) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-white text-sm text-zinc-400 dark:bg-zinc-950 dark:text-zinc-500">
Lade
</div>
)
}
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-white text-zinc-800 dark:bg-zinc-950 dark:text-zinc-100">
<ComposePane
account={account}
accounts={accounts}
onAccountChange={setAccountId}
initial={initial}
onDiscard={handleDiscard}
onSend={handleSend}
onDraftSaved={handleDraftSaved}
sending={sending}
/>
</div>
)
}
+293
View File
@@ -0,0 +1,293 @@
import { useEffect, useState } from 'react'
import { Globe, Info, X } from 'lucide-react'
import clsx from 'clsx'
import { providerPresets, type ProviderPreset } from '../lib/providers'
import type { NewAccount, Protocol } from '../types'
import { ProviderIcon } from './ProviderIcons'
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>
)
}
const inputClass =
'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'
export function AccountModal({
open,
onClose,
onAdd,
onGoogleAdd,
}: {
open: boolean
onClose: () => void
onAdd: (account: NewAccount) => Promise<void>
onGoogleAdd: () => Promise<void>
}) {
const [googleLoading, setGoogleLoading] = useState(false)
const [preset, setPreset] = useState<ProviderPreset>(providerPresets[0])
const [label, setLabel] = useState('')
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [protocol, setProtocol] = useState<Protocol>(preset.protocol)
const [incomingHost, setIncomingHost] = useState(preset.incoming_host)
const [incomingPort, setIncomingPort] = useState(preset.incoming_port)
const [smtpHost, setSmtpHost] = useState(preset.smtp_host)
const [smtpPort, setSmtpPort] = useState(preset.smtp_port)
const [smtpStartTls, setSmtpStartTls] = useState(preset.smtp_starttls)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
const first = providerPresets[0]
setPreset(first)
applyPreset(first)
setLabel('')
setEmail('')
setUsername('')
setPassword('')
setError(null)
}
}, [open])
function applyPreset(p: ProviderPreset) {
setPreset(p)
setProtocol(p.protocol)
setIncomingHost(p.incoming_host)
setIncomingPort(p.incoming_port)
setSmtpHost(p.smtp_host)
setSmtpPort(p.smtp_port)
setSmtpStartTls(p.smtp_starttls)
}
if (!open) return null
async function handleGoogleLogin() {
setError(null)
setGoogleLoading(true)
try {
await onGoogleAdd()
} catch (err) {
setError(String(err))
} finally {
setGoogleLoading(false)
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSaving(true)
try {
await onAdd({
label: label.trim() || email.trim(),
email: email.trim(),
username: username.trim() || email.trim(),
password,
protocol,
incoming_host: incomingHost.trim(),
incoming_port: incomingPort,
smtp_host: smtpHost.trim(),
smtp_port: smtpPort,
smtp_starttls: smtpStartTls,
})
} catch (err) {
setError(String(err))
setSaving(false)
return
}
setSaving(false)
}
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">
<form
onSubmit={handleSubmit}
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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
Konto hinzufügen
</h2>
<button
type="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">
<div className="grid grid-cols-3 gap-2">
{providerPresets.map((p) => (
<button
type="button"
key={p.id}
onClick={() => applyPreset(p)}
className={clsx(
'flex flex-col items-center gap-1.5 rounded-2xl border px-2 py-3 text-center transition',
preset.id === p.id
? 'border-indigo-300 bg-indigo-50 shadow-[0_0_0_3px] shadow-indigo-100 dark:border-indigo-500 dark:bg-indigo-500/10 dark:shadow-indigo-500/10'
: 'border-zinc-200/80 hover:border-zinc-300 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800',
)}
>
<ProviderIcon id={p.id} />
<span className="text-[10.5px] leading-tight font-medium text-zinc-600 dark:text-zinc-300">
{p.label}
</span>
</button>
))}
</div>
{preset.id === 'gmail' && (
<div className="space-y-3">
<button
type="button"
onClick={handleGoogleLogin}
disabled={googleLoading}
className="flex w-full items-center justify-center gap-2 rounded-full border border-zinc-200 bg-white px-4 py-2.5 text-[13px] font-medium text-zinc-700 shadow-sm transition hover:bg-zinc-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<Globe size={15} className={clsx(googleLoading && 'animate-spin')} />
{googleLoading ? 'Öffne Browser…' : 'Mit Google anmelden'}
</button>
<div className="flex items-center gap-2 text-[11px] text-zinc-400">
<div className="h-px flex-1 bg-zinc-200 dark:bg-zinc-800" />
oder manuell mit App-Passwort
<div className="h-px flex-1 bg-zinc-200 dark:bg-zinc-800" />
</div>
</div>
)}
{preset.hint && (
<div className="flex items-start gap-2 rounded-lg bg-amber-50 px-3 py-2 text-[11.5px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
<Info size={13} className="mt-0.5 shrink-0" />
<span>{preset.hint}</span>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Anzeigename">
<input className={inputClass} value={label} onChange={(e) => setLabel(e.target.value)} placeholder="Privat" />
</Field>
<Field label="E-Mail-Adresse">
<input
className={inputClass}
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="du@example.com"
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Benutzername (optional)">
<input
className={inputClass}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="= E-Mail-Adresse"
/>
</Field>
<Field label="Passwort / App-Passwort">
<input
className={inputClass}
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field>
</div>
<div className="rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-950/40">
<p className="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
Server-Einstellungen
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Protokoll">
<select
className={inputClass}
value={protocol}
onChange={(e) => setProtocol(e.target.value as Protocol)}
>
<option value="imap">IMAP</option>
<option value="pop3">POP3</option>
</select>
</Field>
<div />
<Field label={protocol === 'imap' ? 'IMAP-Server' : 'POP3-Server'}>
<input className={inputClass} value={incomingHost} onChange={(e) => setIncomingHost(e.target.value)} />
</Field>
<Field label="Port">
<input
className={inputClass}
type="number"
value={incomingPort}
onChange={(e) => setIncomingPort(Number(e.target.value))}
/>
</Field>
<Field label="SMTP-Server">
<input className={inputClass} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} />
</Field>
<Field label="SMTP-Port">
<input
className={inputClass}
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
/>
</Field>
</div>
<label className="mt-2.5 flex items-center gap-2 text-[12px] text-zinc-500 dark:text-zinc-400">
<input
type="checkbox"
checked={smtpStartTls}
onChange={(e) => setSmtpStartTls(e.target.checked)}
className="rounded border-zinc-300"
/>
SMTP über STARTTLS (Port 587) statt direktem TLS (Port 465)
</label>
</div>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
type="button"
onClick={onClose}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
>
{saving ? 'Verbinde…' : 'Konto hinzufügen'}
</button>
</div>
</form>
</div>
)
}
+413
View File
@@ -0,0 +1,413 @@
import { useEffect, useState } from 'react'
import { AlertTriangle, PlaneTakeoff, Trash2, X } from 'lucide-react'
import type { Account, Protocol } from '../types'
import { loadSignature, saveSignature } from '../lib/signature'
import { api } from '../lib/api'
function toDateTimeLocal(ts: number | null): string {
if (!ts) return ''
const d = new Date(ts * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function fromDateTimeLocal(v: string): number | null {
if (!v) return null
const ts = Math.floor(new Date(v).getTime() / 1000)
return Number.isNaN(ts) ? null : ts
}
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>
)
}
const inputClass =
'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'
export function AccountSettingsModal({
account,
onClose,
onSave,
onDelete,
}: {
account: Account | null
onClose: () => void
onSave: (account: Account, newPassword?: string) => Promise<void>
onDelete: (account: Account) => Promise<void>
}) {
const [label, setLabel] = useState('')
const [username, setUsername] = useState('')
const [protocol, setProtocol] = useState<Protocol>('imap')
const [incomingHost, setIncomingHost] = useState('')
const [incomingPort, setIncomingPort] = useState(0)
const [smtpHost, setSmtpHost] = useState('')
const [smtpPort, setSmtpPort] = useState(0)
const [smtpStartTls, setSmtpStartTls] = useState(false)
const [newPassword, setNewPassword] = useState('')
const [signature, setSignature] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
const [vacationEnabled, setVacationEnabled] = useState(false)
const [vacationStart, setVacationStart] = useState('')
const [vacationEnd, setVacationEnd] = useState('')
const [vacationSubject, setVacationSubject] = useState('')
const [vacationBody, setVacationBody] = useState('')
const [vacationSaving, setVacationSaving] = useState(false)
useEffect(() => {
if (account) {
setLabel(account.label)
setUsername(account.username)
setProtocol(account.protocol)
setIncomingHost(account.incoming_host)
setIncomingPort(account.incoming_port)
setSmtpHost(account.smtp_host)
setSmtpPort(account.smtp_port)
setSmtpStartTls(account.smtp_starttls)
setNewPassword('')
setSignature(loadSignature(account.id))
setError(null)
setConfirmDelete(false)
if (account.protocol === 'imap') {
api
.getVacationSettings(account.id)
.then((s) => {
setVacationEnabled(s.enabled)
setVacationStart(toDateTimeLocal(s.start_ts))
setVacationEnd(toDateTimeLocal(s.end_ts))
setVacationSubject(s.subject)
setVacationBody(s.body)
})
.catch(() => {})
}
}
}, [account])
async function handleSaveVacation() {
if (!account) return
setVacationSaving(true)
try {
await api.saveVacationSettings(account.id, {
enabled: vacationEnabled,
start_ts: fromDateTimeLocal(vacationStart),
end_ts: fromDateTimeLocal(vacationEnd),
subject: vacationSubject,
body: vacationBody,
})
} catch (err) {
setError(String(err))
} finally {
setVacationSaving(false)
}
}
if (!account) return null
const isOAuth = account.auth_method === 'google_oauth'
const isLocal = account.protocol === 'local'
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!account) return
setError(null)
setSaving(true)
try {
saveSignature(account.id, signature)
await onSave(
{
...account,
label: label.trim() || account.email,
username: username.trim(),
protocol,
incoming_host: incomingHost.trim(),
incoming_port: incomingPort,
smtp_host: smtpHost.trim(),
smtp_port: smtpPort,
smtp_starttls: smtpStartTls,
},
newPassword.trim() || undefined,
)
} catch (err) {
setError(String(err))
setSaving(false)
return
}
setSaving(false)
}
async function handleDelete() {
if (!account) return
setDeleting(true)
try {
await onDelete(account)
} catch (err) {
setError(String(err))
setDeleting(false)
}
}
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">
<form
onSubmit={handleSubmit}
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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
Kontoeinstellungen
</h2>
<button
type="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">
{isLocal ? (
<p className="rounded-lg bg-zinc-50 px-3 py-2.5 text-[12.5px] text-zinc-500 dark:bg-zinc-950/40 dark:text-zinc-400">
Die lokale Ablage ist ein Systemkonto und kann nicht bearbeitet oder gelöscht werden.
</p>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Anzeigename">
<input className={inputClass} value={label} onChange={(e) => setLabel(e.target.value)} />
</Field>
<Field label="E-Mail-Adresse">
<input className={inputClass} value={account.email} disabled />
</Field>
</div>
{isOAuth ? (
<div className="rounded-lg bg-indigo-50 px-3 py-2.5 text-[12px] text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300">
Dieses Konto verwendet Google-Anmeldung (OAuth). Server-Einstellungen werden automatisch verwaltet.
</div>
) : (
<>
<div className="grid grid-cols-2 gap-3">
<Field label="Benutzername">
<input className={inputClass} value={username} onChange={(e) => setUsername(e.target.value)} />
</Field>
<Field label="Neues Passwort (optional)">
<input
className={inputClass}
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="unverändert lassen"
/>
</Field>
</div>
<div className="rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-950/40">
<p className="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
Server-Einstellungen
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Protokoll">
<select
className={inputClass}
value={protocol}
onChange={(e) => setProtocol(e.target.value as Protocol)}
>
<option value="imap">IMAP</option>
<option value="pop3">POP3</option>
</select>
</Field>
<div />
<Field label={protocol === 'imap' ? 'IMAP-Server' : 'POP3-Server'}>
<input className={inputClass} value={incomingHost} onChange={(e) => setIncomingHost(e.target.value)} />
</Field>
<Field label="Port">
<input
className={inputClass}
type="number"
value={incomingPort}
onChange={(e) => setIncomingPort(Number(e.target.value))}
/>
</Field>
<Field label="SMTP-Server">
<input className={inputClass} value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} />
</Field>
<Field label="SMTP-Port">
<input
className={inputClass}
type="number"
value={smtpPort}
onChange={(e) => setSmtpPort(Number(e.target.value))}
/>
</Field>
</div>
<label className="mt-2.5 flex items-center gap-2 text-[12px] text-zinc-500 dark:text-zinc-400">
<input
type="checkbox"
checked={smtpStartTls}
onChange={(e) => setSmtpStartTls(e.target.checked)}
className="rounded border-zinc-300"
/>
SMTP über STARTTLS statt direktem TLS
</label>
</div>
</>
)}
<Field label="Signatur">
<textarea
value={signature}
onChange={(e) => setSignature(e.target.value)}
rows={3}
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
placeholder="z. B. Viele Grüße&#10;Dein Name"
/>
</Field>
{protocol === 'imap' && (
<div className="rounded-2xl border border-zinc-200/80 bg-zinc-50/60 p-3.5 dark:border-zinc-800 dark:bg-zinc-950/40">
<div className="mb-2 flex items-center justify-between">
<p className="flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
<PlaneTakeoff size={12} /> Abwesenheitsnotiz
</p>
<label className="flex items-center gap-1.5 text-[12px] text-zinc-500 dark:text-zinc-400">
<input
type="checkbox"
checked={vacationEnabled}
onChange={(e) => setVacationEnabled(e.target.checked)}
className="rounded border-zinc-300"
/>
Aktiv
</label>
</div>
{vacationEnabled && (
<div className="space-y-2.5">
<div className="grid grid-cols-2 gap-2.5">
<Field label="Von">
<input
type="datetime-local"
className={inputClass}
value={vacationStart}
onChange={(e) => setVacationStart(e.target.value)}
/>
</Field>
<Field label="Bis">
<input
type="datetime-local"
className={inputClass}
value={vacationEnd}
onChange={(e) => setVacationEnd(e.target.value)}
/>
</Field>
</div>
<Field label="Betreff (optional, sonst „Automatische Antwort: …“)">
<input
className={inputClass}
value={vacationSubject}
onChange={(e) => setVacationSubject(e.target.value)}
placeholder="Automatische Antwort"
/>
</Field>
<Field label="Nachricht">
<textarea
value={vacationBody}
onChange={(e) => setVacationBody(e.target.value)}
rows={3}
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
placeholder="Ich bin bis zum ... abwesend und antworte nach meiner Rückkehr."
/>
</Field>
<p className="text-[11px] text-zinc-400">
Antwortet automatisch einmal pro Absender im gewählten Zeitraum. Reagiert nicht auf
No-Reply-Adressen oder erkennbare automatische Antworten.
</p>
</div>
)}
<button
type="button"
onClick={handleSaveVacation}
disabled={vacationSaving}
className="mt-2.5 rounded-full bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-zinc-700 disabled:opacity-60 dark:bg-zinc-200 dark:text-zinc-900 dark:hover:bg-zinc-300"
>
{vacationSaving ? 'Speichere…' : 'Abwesenheitsnotiz speichern'}
</button>
</div>
)}
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-3.5 dark:border-red-950/60 dark:bg-red-950/20">
{confirmDelete ? (
<div className="space-y-2">
<p className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-700 dark:text-red-400">
<AlertTriangle size={13} /> Konto {account.label} wirklich löschen?
</p>
<p className="text-[11.5px] text-red-600/80 dark:text-red-400/70">
Lokal zwischengespeicherte Nachrichten dieses Kontos werden entfernt. Der Server-Posteingang bleibt unberührt.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded-full bg-red-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-red-500 disabled:opacity-60"
>
{deleting ? 'Lösche…' : 'Endgültig löschen'}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="rounded-full px-3.5 py-1.5 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-600 transition hover:text-red-700 dark:text-red-400"
>
<Trash2 size={13} /> Konto löschen
</button>
)}
</div>
</>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
type="button"
onClick={onClose}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
{isLocal ? 'Schließen' : 'Abbrechen'}
</button>
{!isLocal && (
<button
type="submit"
disabled={saving}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
)}
</div>
</form>
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { Download, X } from 'lucide-react'
import type { AttachmentMeta } from '../types'
import { formatBytes } from '../lib/format'
export function isPreviewable(att: AttachmentMeta) {
const mime = att.mime_type.toLowerCase()
return mime.startsWith('image/') || mime === 'application/pdf'
}
export function AttachmentPreviewModal({
attachment,
onClose,
onDownload,
}: {
attachment: AttachmentMeta | null
onClose: () => void
onDownload: (att: AttachmentMeta) => void
}) {
if (!attachment) return null
const dataUrl = `data:${attachment.mime_type || 'application/octet-stream'};base64,${attachment.content_base64}`
const isImage = attachment.mime_type.toLowerCase().startsWith('image/')
const isPdf = attachment.mime_type.toLowerCase() === 'application/pdf'
return (
<div
className="animate-overlay-in fixed inset-0 z-50 flex items-center justify-center bg-zinc-900/60 p-6 backdrop-blur-sm"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="animate-panel-in flex h-[85vh] w-full max-w-3xl flex-col overflow-hidden rounded-[24px] border border-white/10 bg-zinc-900/95 shadow-2xl"
>
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-3">
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-zinc-100">{attachment.filename}</p>
<p className="text-[11px] text-zinc-400">{formatBytes(attachment.size)}</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => onDownload(attachment)}
title="Herunterladen"
className="rounded-full p-2 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
>
<Download size={16} />
</button>
<button
onClick={onClose}
title="Schließen"
className="rounded-full p-2 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
</div>
<div className="flex flex-1 items-center justify-center overflow-auto bg-zinc-950/60 p-4">
{isImage && <img src={dataUrl} alt={attachment.filename} className="max-h-full max-w-full object-contain" />}
{isPdf && <iframe title={attachment.filename} src={dataUrl} className="h-full w-full rounded-lg bg-white" />}
{!isImage && !isPdf && <p className="text-sm text-zinc-400">Keine Vorschau verfügbar.</p>}
</div>
</div>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { avatarColor, initials } from '../lib/format'
export function Avatar({ name, size = 36 }: { name: string; size?: number }) {
return (
<div
className="flex shrink-0 items-center justify-center rounded-full font-medium text-white select-none"
style={{
width: size,
height: size,
fontSize: size * 0.38,
backgroundColor: avatarColor(name || '?'),
}}
>
{initials(name)}
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react'
import { Plus, ShieldOff, Trash2, X } from 'lucide-react'
import { api } from '../lib/api'
import type { NewRule, Rule } from '../types'
const inputClass =
'flex-1 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'
/// A "blocked sender" is just a global (all-accounts) rule of the shape
/// {field: from, action: delete}, so this reuses the existing rules engine
/// (already applied automatically on every sync) instead of a parallel mechanism.
function isBlockRule(r: Rule): boolean {
return (
!r.account_id &&
r.action === 'delete' &&
r.conditions.length === 1 &&
r.conditions[0].field === 'from' &&
r.conditions[0].contains.trim() !== ''
)
}
export function BlockedSendersModal({
open,
onClose,
onToast,
}: {
open: boolean
onClose: () => void
onToast: (msg: string) => void
}) {
const [rules, setRules] = useState<Rule[]>([])
const [loading, setLoading] = useState(false)
const [email, setEmail] = useState('')
const [saving, setSaving] = useState(false)
function refresh() {
setLoading(true)
api
.listRules()
.then((all) => setRules(all.filter(isBlockRule)))
.catch((e) => onToast(`Liste konnte nicht geladen werden: ${e}`))
.finally(() => setLoading(false))
}
useEffect(() => {
if (open) refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
if (!open) return null
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
const value = email.trim()
if (!value) return
setSaving(true)
try {
const newRule: NewRule = {
account_id: null,
conditions: [{ field: 'from', contains: value }],
match_type: 'all',
action: 'delete',
}
await api.createRule(newRule)
setEmail('')
refresh()
onToast(`${value}“ wird jetzt blockiert`)
} catch (err) {
onToast(`Fehlgeschlagen: ${err}`)
} finally {
setSaving(false)
}
}
async function handleRemove(r: Rule) {
setRules((prev) => prev.filter((x) => x.id !== r.id))
try {
await api.deleteRule(r.id)
} catch (err) {
onToast(String(err))
}
}
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-[80vh] 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">
<ShieldOff size={16} className="text-zinc-400" /> Blockierte Absender
</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">
<form onSubmit={handleAdd} className="flex gap-2">
<input
className={inputClass}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse oder Domain, z. B. spam@x.com"
/>
<button
type="submit"
disabled={saving || !email.trim()}
className="flex shrink-0 items-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-2 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"
>
<Plus size={14} /> Blockieren
</button>
</form>
<p className="text-[11.5px] text-zinc-400">
Mails von blockierten Absendern werden bei allen Konten automatisch beim Abrufen gelöscht.
</p>
<div>
{loading ? (
<p className="text-xs text-zinc-400">Lade</p>
) : rules.length === 0 ? (
<p className="text-xs text-zinc-400">Noch niemand blockiert.</p>
) : (
<div className="space-y-1.5">
{rules.map((r) => (
<div
key={r.id}
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
>
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-700 dark:text-zinc-300">
{r.conditions[0].contains}
</span>
<button
onClick={() => handleRemove(r)}
className="shrink-0 rounded-full p-1 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>
)
}
+219
View File
@@ -0,0 +1,219 @@
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>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { useMemo } from 'react'
import type { Event } from '../types'
import { categoryHex } from '../lib/categories'
const HOUR_HEIGHT = 52
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
function minutesSinceMidnight(ts: number, day: Date) {
const dayStart = Math.floor(startOfDay(day).getTime() / 1000)
return Math.max(0, Math.min(24 * 60, Math.round((ts - dayStart) / 60)))
}
export function CalendarDayView({
date,
events,
onEventClick,
onEventContextMenu,
onEmptyClick,
}: {
date: Date
events: Event[]
onEventClick: (event: Event) => void
onEventContextMenu?: (e: React.MouseEvent, event: Event) => void
onEmptyClick: () => void
}) {
const dayStart = Math.floor(startOfDay(date).getTime() / 1000)
const dayEnd = dayStart + 86400
const { allDay, timed } = useMemo(() => {
const inDay = events.filter((e) => e.start_ts < dayEnd && e.end_ts > dayStart)
return {
allDay: inDay.filter((e) => e.all_day),
timed: inDay.filter((e) => !e.all_day).sort((a, b) => a.start_ts - b.start_ts),
}
}, [events, dayStart, dayEnd])
const now = new Date()
const showNowLine = startOfDay(now).getTime() === startOfDay(date).getTime()
const nowTop = (minutesSinceMidnight(Math.floor(now.getTime() / 1000), date) / 60) * HOUR_HEIGHT
return (
<div className="flex flex-1 flex-col overflow-hidden">
{allDay.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-b border-zinc-200/80 px-4 py-2 dark:border-zinc-800/80">
{allDay.map((e) => {
const hex = categoryHex(e.category)
return (
<span
key={e.id}
onClick={() => onEventClick(e)}
onContextMenu={(ev) => onEventContextMenu?.(ev, e)}
className="cursor-default truncate rounded-md px-2 py-0.5 text-[11.5px] font-medium bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25"
style={hex ? { backgroundColor: `${hex}26`, color: hex } : undefined}
>
{e.title}
</span>
)
})}
</div>
)}
<div className="relative flex-1 overflow-y-auto">
<div className="relative" style={{ height: HOUR_HEIGHT * 24 }}>
{Array.from({ length: 24 }, (_, h) => (
<div
key={h}
onClick={onEmptyClick}
className="absolute inset-x-0 flex cursor-default items-start gap-2 border-t border-zinc-100 hover:bg-zinc-50/60 dark:border-zinc-900 dark:hover:bg-zinc-900/40"
style={{ top: h * HOUR_HEIGHT, height: HOUR_HEIGHT }}
>
<span className="w-12 shrink-0 -translate-y-2 pl-2 text-right text-[10.5px] text-zinc-400 select-none">
{h.toString().padStart(2, '0')}:00
</span>
</div>
))}
{showNowLine && (
<div className="pointer-events-none absolute inset-x-0 z-10 flex items-center" style={{ top: nowTop }}>
<span className="ml-12 h-1.5 w-1.5 shrink-0 rounded-full bg-red-500" />
<span className="h-px flex-1 bg-red-500" />
</div>
)}
<div className="pointer-events-none absolute inset-y-0 left-14 right-2">
{timed.map((e) => {
const top = (minutesSinceMidnight(e.start_ts, date) / 60) * HOUR_HEIGHT
const bottom = (minutesSinceMidnight(e.end_ts, date) / 60) * HOUR_HEIGHT
const height = Math.max(20, bottom - top)
const hex = categoryHex(e.category)
return (
<div
key={e.id}
onClick={(ev) => {
ev.stopPropagation()
onEventClick(e)
}}
onContextMenu={(ev) => {
ev.stopPropagation()
onEventContextMenu?.(ev, e)
}}
className="pointer-events-auto absolute left-0 right-0 cursor-default overflow-hidden rounded-lg border-l-2 px-2 py-1 text-[11.5px] font-medium bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25"
style={{
top,
height,
borderLeftColor: hex ?? '#6366f1',
backgroundColor: hex ? `${hex}1a` : undefined,
color: hex ?? undefined,
}}
>
<p className="truncate">{e.title}</p>
{height > 32 && (
<p className="truncate text-[10.5px] opacity-70">
{new Date(e.start_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{' '}
{new Date(e.end_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</p>
)}
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
+231
View File
@@ -0,0 +1,231 @@
import { useState, useMemo } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import type { Event } from '../types'
import { categoryHex } from '../lib/categories'
import { CalendarDayView } from './CalendarDayView'
const WEEKDAY_NAMES = [
'Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag',
]
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const MONTH_NAMES = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
}
function isSameDay(a: Date, b: Date) {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
}
function buildGrid(monthDate: Date): Date[] {
const first = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1)
const mondayOffset = (first.getDay() + 6) % 7
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - mondayOffset)
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(gridStart)
d.setDate(gridStart.getDate() + i)
return d
})
}
export function CalendarView({
monthDate,
events,
loading,
onPrevMonth,
onNextMonth,
onToday,
onDayClick,
onEventClick,
onEventContextMenu,
}: {
monthDate: Date
events: Event[]
loading: boolean
onPrevMonth: () => void
onNextMonth: () => void
onToday: () => void
onDayClick: (date: Date) => void
onEventClick: (event: Event) => void
onEventContextMenu?: (e: React.MouseEvent, event: Event) => void
}) {
const grid = useMemo(() => buildGrid(monthDate), [monthDate])
const today = useMemo(() => new Date(), [])
const [mode, setMode] = useState<'month' | 'day'>('month')
const [dayDate, setDayDate] = useState(() => new Date())
function openDay(date: Date) {
setDayDate(date)
setMode('day')
}
const eventsByDay = useMemo(() => {
const map = new Map<string, Event[]>()
for (const day of grid) {
const dayStart = Math.floor(startOfDay(day).getTime() / 1000)
const dayEnd = dayStart + 86400
const items = events
.filter((e) => e.start_ts < dayEnd && e.end_ts > dayStart)
.sort((a, b) => a.start_ts - b.start_ts)
map.set(day.toDateString(), items)
}
return map
}, [grid, events])
return (
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-zinc-200/80 px-6 py-3.5 dark:border-zinc-800/80">
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{mode === 'month'
? `${MONTH_NAMES[monthDate.getMonth()]} ${monthDate.getFullYear()}`
: `${WEEKDAY_NAMES[dayDate.getDay()]}, ${dayDate.getDate()}. ${MONTH_NAMES[dayDate.getMonth()]} ${dayDate.getFullYear()}`}
</h2>
<div className="flex rounded-lg bg-zinc-100 p-0.5 text-[11.5px] dark:bg-zinc-800">
<button
onClick={() => setMode('month')}
className={clsx(
'rounded-md px-2.5 py-1 font-medium transition',
mode === 'month' ? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500',
)}
>
Monat
</button>
<button
onClick={() => setMode('day')}
className={clsx(
'rounded-md px-2.5 py-1 font-medium transition',
mode === 'day' ? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500',
)}
>
Tag
</button>
</div>
<div className="ml-auto flex items-center gap-1">
<button
onClick={() => {
if (mode === 'day') setDayDate(new Date())
else onToday()
}}
className="rounded-full px-3 py-1 text-[12px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Heute
</button>
<button
onClick={() => {
if (mode === 'day') setDayDate((d) => new Date(d.getFullYear(), d.getMonth(), d.getDate() - 1))
else onPrevMonth()
}}
className="rounded-full p-1.5 text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => {
if (mode === 'day') setDayDate((d) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1))
else onNextMonth()
}}
className="rounded-full p-1.5 text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{mode === 'day' ? (
<CalendarDayView
date={dayDate}
events={events}
onEventClick={onEventClick}
onEventContextMenu={onEventContextMenu}
onEmptyClick={() => onDayClick(dayDate)}
/>
) : (
<>
<div className="grid grid-cols-7 border-b border-zinc-200/80 dark:border-zinc-800/80">
{WEEKDAYS.map((w) => (
<div key={w} className="px-2 py-1.5 text-center text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
{w}
</div>
))}
</div>
<div className="grid flex-1 grid-cols-7 grid-rows-6 overflow-y-auto">
{grid.map((day) => {
const inMonth = day.getMonth() === monthDate.getMonth()
const isToday = isSameDay(day, today)
const dayEvents = eventsByDay.get(day.toDateString()) ?? []
return (
<button
key={day.toISOString()}
onClick={() => onDayClick(day)}
className={clsx(
'flex flex-col items-stretch gap-1 border-b border-r border-zinc-100 p-1.5 text-left transition hover:bg-zinc-50 dark:border-zinc-900 dark:hover:bg-zinc-900/60',
!inMonth && 'bg-zinc-50/50 dark:bg-zinc-950/40',
)}
>
<span
onClick={(ev) => {
ev.stopPropagation()
openDay(day)
}}
title="Tagesansicht öffnen"
className={clsx(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[11.5px] font-medium transition hover:ring-2 hover:ring-indigo-300',
isToday
? 'bg-indigo-600 text-white'
: inMonth
? 'text-zinc-700 dark:text-zinc-300'
: 'text-zinc-350 dark:text-zinc-700',
)}
>
{day.getDate()}
</span>
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden">
{dayEvents.slice(0, 3).map((e) => {
const hex = categoryHex(e.category)
return (
<span
key={e.id}
onClick={(ev) => {
ev.stopPropagation()
onEventClick(e)
}}
onContextMenu={(ev) => {
ev.stopPropagation()
onEventContextMenu?.(ev, e)
}}
className={clsx(
'truncate rounded-md px-1.5 py-0.5 text-[10.5px] font-medium',
!hex && 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:hover:bg-indigo-500/25',
)}
style={hex ? { backgroundColor: `${hex}26`, color: hex } : undefined}
>
{e.all_day ? '' : `${new Date(e.start_ts * 1000).getHours().toString().padStart(2, '0')}:${new Date(e.start_ts * 1000).getMinutes().toString().padStart(2, '0')} `}
{e.title}
</span>
)
})}
{dayEvents.length > 3 && (
<span className="px-1.5 text-[10px] text-zinc-400">+{dayEvents.length - 3} weitere</span>
)}
</div>
</button>
)
})}
</div>
</>
)}
{loading && (
<div className="border-t border-zinc-200/80 px-6 py-1.5 text-[11px] text-zinc-400 dark:border-zinc-800/80">
Lade Termine
</div>
)}
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import clsx from 'clsx'
import { Popover } from './Popover'
export function ClassicTabBar({
tabs,
active,
onChange,
rightSlot,
}: {
tabs: { id: string; label: string }[]
active: string
onChange: (id: string) => void
rightSlot?: React.ReactNode
}) {
return (
<div className="flex items-center gap-0.5 bg-[#1e5aa8] px-2 pt-1.5 dark:bg-zinc-900">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => onChange(t.id)}
className={clsx(
'rounded-t-sm px-3 py-1.5 text-[13px] transition',
active === t.id
? 'bg-[#f3f2f1] font-semibold text-[#1e5aa8] dark:bg-zinc-800 dark:text-blue-300'
: 'text-white/90 hover:bg-white/10',
)}
>
{t.label}
</button>
))}
{rightSlot && <div className="ml-auto flex items-center pb-1.5">{rightSlot}</div>}
</div>
)
}
export function ClassicRibbonGroup({
caption,
children,
onLauncher,
}: {
caption: string
children: React.ReactNode
onLauncher?: () => void
}) {
return (
<div className="flex h-[92px] shrink-0 flex-col justify-between border-r border-[#d1d1d1] px-1.5 last:border-r-0 dark:border-zinc-700">
<div className="flex flex-1 items-start gap-0.5 pt-1">{children}</div>
<div className="relative flex items-center justify-center pb-0.5">
<span className="text-[10.5px] text-[#605e5c] dark:text-zinc-400">{caption}</span>
{onLauncher && (
<button
onClick={onLauncher}
title={`${caption}: weitere Optionen`}
className="absolute right-0 text-[#605e5c] hover:text-[#1e5aa8] dark:text-zinc-400 dark:hover:text-blue-300"
>
<svg width="8" height="8" viewBox="0 0 9 9">
<path d="M0 9L9 0M9 0H3M9 0V6" stroke="currentColor" strokeWidth="1.2" fill="none" />
</svg>
</button>
)}
</div>
</div>
)
}
export function ClassicButton({
icon: Icon,
label,
onClick,
disabled,
active,
size = 'large',
title,
}: {
icon: React.ComponentType<{ size?: number }>
label: string
onClick?: () => void
disabled?: boolean
active?: boolean
size?: 'large' | 'small'
title?: string
}) {
if (size === 'small') {
return (
<button
onClick={onClick}
disabled={disabled}
title={title ?? label}
className={clsx(
'flex items-center gap-1.5 rounded-sm px-1.5 py-0.5 text-[11.5px] text-[#1f1f1f] transition disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-200',
active ? 'bg-[#cce4f7] dark:bg-blue-500/20' : 'hover:bg-[#e6f2fb] dark:hover:bg-zinc-700/60',
)}
>
<Icon size={14} />
<span className="whitespace-nowrap">{label}</span>
</button>
)
}
return (
<button
onClick={onClick}
disabled={disabled}
title={title ?? label}
className={clsx(
'flex w-[64px] shrink-0 flex-col items-center gap-1 rounded-sm px-1 py-1 text-center text-[11px] leading-tight text-[#1f1f1f] transition disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-200',
active ? 'bg-[#cce4f7] dark:bg-blue-500/20' : 'hover:bg-[#e6f2fb] dark:hover:bg-zinc-700/60',
)}
>
<Icon size={24} />
<span>{label}</span>
</button>
)
}
export function ClassicDropdown({
button,
children,
open,
onOpenChange,
align = 'left',
widthClass = 'w-56',
}: {
button: React.ReactNode
children: React.ReactNode
open: boolean
onOpenChange: (v: boolean) => void
align?: 'left' | 'right'
widthClass?: string
}) {
return (
<Popover
open={open}
onOpenChange={onOpenChange}
trigger={button}
align={align === 'right' ? 'end' : 'start'}
panelClassName={clsx(
'animate-panel-in overflow-hidden rounded-md border border-zinc-300 bg-white py-1 shadow-xl dark:border-zinc-700 dark:bg-zinc-900',
widthClass,
)}
>
{children}
</Popover>
)
}
export function ClassicMenuItem({
children,
onClick,
disabled,
}: {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-[#e6f2fb] disabled:cursor-not-allowed disabled:opacity-40 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
{children}
</button>
)
}
export function ClassicRibbonBody({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-[100px] items-stretch overflow-x-auto bg-[#f3f2f1] px-1.5 dark:bg-zinc-800/60">{children}</div>
)
}
+497
View File
@@ -0,0 +1,497 @@
import { useEffect, useRef, useState } from 'react'
import { Check, Pencil, PenLine, Plus, Send, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import type { Account, ComposeMessage, OutgoingAttachment } from '../types'
import { formatBytes } from '../lib/format'
import { escapeHtml, htmlIsEmpty } from '../lib/richtext'
import { getDefaultSignatureId, listSignatures, loadSignature, saveSignatureList, type Signature } from '../lib/signature'
import { api } from '../lib/api'
import { RecipientInput } from './RecipientInput'
import { RichTextEditor, type RichTextEditorHandle } from './RichTextEditor'
import { ComposeRibbon } from './ComposeRibbon'
import { TemplatesModal } from './TemplatesModal'
export interface ComposeInitial {
to?: string
cc?: string
subject?: string
body?: string
attachments?: OutgoingAttachment[]
inReplyTo?: string
draftUid?: number
skipSignature?: boolean
}
const AUTOSAVE_DELAY = 1500
const UNDO_WINDOW_SECS = 8
function fileToAttachment(file: File): Promise<OutgoingAttachment> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
const base64 = result.split(',')[1] ?? ''
resolve({ filename: file.name, mime_type: file.type || 'application/octet-stream', content_base64: base64 })
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function htmlToPlainText(html: string): string {
const tmp = document.createElement('div')
tmp.innerHTML = html
return tmp.textContent ?? ''
}
export function ComposePane({
account,
accounts,
onAccountChange,
initial,
onDiscard,
onSend,
onDraftSaved,
sending,
}: {
account: Account | null
accounts: Account[]
onAccountChange: (accountId: string) => void
initial: ComposeInitial | null
onDiscard: () => void
onSend: (msg: ComposeMessage, sendAt: number) => Promise<void>
onDraftSaved: (uid: number) => void
sending: boolean
}) {
const [to, setTo] = useState('')
const [cc, setCc] = useState('')
const [bcc, setBcc] = useState('')
const [showCc, setShowCc] = useState(false)
const [showBcc, setShowBcc] = useState(false)
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [attachments, setAttachments] = useState<OutgoingAttachment[]>([])
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [signatureOpen, setSignatureOpen] = useState(false)
const [signatures, setSignatures] = useState<Signature[]>([])
const [defaultSigId, setDefaultSigId] = useState<string | null>(null)
const [editingSigId, setEditingSigId] = useState<string | null>(null)
const [dragActive, setDragActive] = useState(false)
const dragCounter = useRef(0)
const [templatesOpen, setTemplatesOpen] = useState(false)
const [importance, setImportance] = useState<'high' | 'low' | null>(null)
const [requestReadReceipt, setRequestReadReceipt] = useState(false)
const [delayAt, setDelayAt] = useState('')
const [replyTo, setReplyTo] = useState('')
const [plainTextMode, setPlainTextMode] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const editorRef = useRef<RichTextEditorHandle>(null)
const draftUidRef = useRef<number | null>(null)
const isFirstRender = useRef(true)
const sendableAccounts = accounts.filter((a) => a.protocol !== 'local')
useEffect(() => {
const signature = account && !initial?.skipSignature ? loadSignature(account.id) : ''
const base = initial?.body ?? ''
const sigBlock = signature ? `<br><br>--<br>${signature.replace(/\n/g, '<br>')}` : ''
const withSignature = base + sigBlock
setTo(initial?.to ?? '')
setCc(initial?.cc ?? '')
setBcc('')
setShowCc(!!initial?.cc)
setShowBcc(false)
setSubject(initial?.subject ?? '')
setBody(withSignature)
setAttachments(initial?.attachments ?? [])
setError(null)
setImportance(null)
setRequestReadReceipt(false)
setDelayAt('')
setReplyTo('')
setPlainTextMode(false)
if (account) {
setSignatures(listSignatures(account.id))
setDefaultSigId(getDefaultSignatureId(account.id))
}
setEditingSigId(null)
draftUidRef.current = initial?.draftUid ?? null
isFirstRender.current = true
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initial])
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
if (!account) return
const hasContent = to.trim() || cc.trim() || bcc.trim() || subject.trim() || !htmlIsEmpty(body)
if (!hasContent) return
const timeout = setTimeout(() => {
api
.saveDraft(draftUidRef.current, {
account_id: account.id,
to: to.trim(),
cc: cc.trim() || undefined,
bcc: bcc.trim() || undefined,
subject: subject.trim(),
body_html: body,
attachments: [],
})
.then((uid) => {
draftUidRef.current = uid
onDraftSaved(uid)
})
.catch(() => {})
}, AUTOSAVE_DELAY)
return () => clearTimeout(timeout)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [to, cc, bcc, subject, body, account])
async function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return
const converted = await Promise.all(Array.from(files).map(fileToAttachment))
setAttachments((prev) => [...prev, ...converted])
}
function handleDragEnter(e: React.DragEvent) {
e.preventDefault()
if (!e.dataTransfer.types.includes('Files')) return
dragCounter.current += 1
setDragActive(true)
}
function handleDragLeave(e: React.DragEvent) {
e.preventDefault()
dragCounter.current -= 1
if (dragCounter.current <= 0) {
dragCounter.current = 0
setDragActive(false)
}
}
function handleDragOver(e: React.DragEvent) {
e.preventDefault()
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
dragCounter.current = 0
setDragActive(false)
handleFiles(e.dataTransfer.files)
}
function handleInsertTemplate(t: { subject: string; body: string }) {
if (t.subject && !subject.trim()) setSubject(t.subject)
editorRef.current?.exec('insertHTML', t.body.replace(/\n/g, '<br>'))
}
function showInfo(msg: string) {
setInfo(msg)
setTimeout(() => setInfo(null), 3000)
}
function persistSignatures(next: Signature[], nextDefaultId: string | null) {
if (!account) return
setSignatures(next)
setDefaultSigId(nextDefaultId)
saveSignatureList(account.id, next, nextDefaultId)
}
function handleAddSignature() {
const sig: Signature = { id: crypto.randomUUID(), name: `Signatur ${signatures.length + 1}`, body: '' }
const next = [...signatures, sig]
persistSignatures(next, defaultSigId ?? sig.id)
setEditingSigId(sig.id)
}
function handleRenameSignature(id: string, name: string) {
persistSignatures(
signatures.map((s) => (s.id === id ? { ...s, name } : s)),
defaultSigId,
)
}
function handleEditSignatureBody(id: string, sigBody: string) {
persistSignatures(
signatures.map((s) => (s.id === id ? { ...s, body: sigBody } : s)),
defaultSigId,
)
}
function handleDeleteSignature(id: string) {
const next = signatures.filter((s) => s.id !== id)
persistSignatures(next, defaultSigId === id ? null : defaultSigId)
if (editingSigId === id) setEditingSigId(null)
}
async function handleSend() {
if (!account) return
if (!to.trim()) {
setError('Bitte gib mindestens einen Empfänger an.')
return
}
setError(null)
try {
const finalBody = plainTextMode ? `<pre>${escapeHtml(htmlToPlainText(body))}</pre>` : body
const sendAt = delayAt ? Math.floor(new Date(delayAt).getTime() / 1000) : Math.floor(Date.now() / 1000) + UNDO_WINDOW_SECS
await onSend(
{
account_id: account.id,
to: to.trim(),
cc: cc.trim() || undefined,
bcc: bcc.trim() || undefined,
subject: subject.trim(),
body_html: finalBody,
attachments,
in_reply_to: initial?.inReplyTo,
reply_to: replyTo.trim() || undefined,
request_read_receipt: requestReadReceipt,
importance: importance ?? undefined,
},
sendAt,
)
} catch (e) {
setError(String(e))
}
}
return (
<div
className="relative flex min-w-0 flex-1 flex-col"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{dragActive && (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center gap-2 border-2 border-dashed border-indigo-400 bg-indigo-50/90 text-[13px] font-medium text-indigo-700 backdrop-blur-sm dark:border-indigo-500 dark:bg-indigo-950/80 dark:text-indigo-300">
<Plus size={16} /> Dateien hier ablegen, um sie anzuhängen
</div>
)}
<ComposeRibbon
editor={editorRef}
onAttach={() => fileInput.current?.click()}
onOpenSignature={() => setSignatureOpen((v) => !v)}
onOpenTemplates={() => setTemplatesOpen(true)}
importance={importance}
onImportanceChange={setImportance}
showBcc={showBcc}
onToggleBcc={() => setShowBcc((v) => !v)}
requestReadReceipt={requestReadReceipt}
onToggleReadReceipt={() => setRequestReadReceipt((v) => !v)}
delayAt={delayAt}
onDelayChange={setDelayAt}
replyTo={replyTo}
onReplyToChange={setReplyTo}
plainTextMode={plainTextMode}
onTogglePlainText={() => setPlainTextMode((v) => !v)}
onWordCount={(count) => showInfo(`${count} Wörter`)}
/>
{signatureOpen && account && (
<div className="border-b border-zinc-200/80 bg-zinc-50/60 px-7 py-3 dark:border-zinc-800/80 dark:bg-zinc-950/40">
<div className="flex items-center justify-between">
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Signaturen für dieses Konto</p>
<button
onClick={() => setSignatureOpen(false)}
className="rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<X size={13} />
</button>
</div>
<div className="mt-2 space-y-1.5">
{signatures.length === 0 && (
<p className="text-[12px] text-zinc-400">Noch keine Signaturen angelegt.</p>
)}
{signatures.map((s) => (
<div key={s.id} className="rounded-xl border border-zinc-200/80 bg-white/70 dark:border-zinc-800 dark:bg-zinc-900/40">
<div className="flex items-center gap-2 px-2.5 py-1.5">
<button
onClick={() => persistSignatures(signatures, s.id)}
title={s.id === defaultSigId ? 'Standard-Signatur' : 'Als Standard festlegen'}
className={clsx(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border transition',
s.id === defaultSigId
? 'border-indigo-500 bg-indigo-500 text-white'
: 'border-zinc-300 text-transparent hover:border-indigo-400 dark:border-zinc-600',
)}
>
<Check size={10} />
</button>
<input
value={s.name}
onChange={(e) => handleRenameSignature(s.id, e.target.value)}
className="min-w-0 flex-1 bg-transparent text-[12.5px] font-medium text-zinc-700 outline-none dark:text-zinc-200"
/>
<button
onClick={() => setEditingSigId(editingSigId === s.id ? null : s.id)}
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<Pencil size={12} />
</button>
<button
onClick={() => handleDeleteSignature(s.id)}
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950/40"
>
<Trash2 size={12} />
</button>
</div>
{editingSigId === s.id && (
<textarea
value={s.body}
onChange={(e) => handleEditSignatureBody(s.id, e.target.value)}
rows={3}
autoFocus
className="w-full resize-none border-t border-zinc-100 bg-transparent px-2.5 py-2 text-[12.5px] text-zinc-700 outline-none dark:border-zinc-800 dark:text-zinc-200"
placeholder="z. B. Viele Grüße&#10;Dein Name"
/>
)}
</div>
))}
</div>
<button
onClick={handleAddSignature}
className="mt-2 flex items-center gap-1 text-[11.5px] font-medium text-indigo-600 transition hover:text-indigo-700 dark:text-indigo-400"
>
<Plus size={12} /> Neue Signatur
</button>
</div>
)}
<div className="flex border-b border-zinc-200/80 dark:border-zinc-800/80">
<div className="flex w-24 shrink-0 items-center justify-center border-r border-zinc-200/80 dark:border-zinc-800/80">
<button
onClick={handleSend}
disabled={sending}
className={clsx(
'flex flex-col items-center gap-1 rounded-lg px-3 py-2.5 text-indigo-600 transition hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10',
)}
>
<Send size={22} />
<span className="text-[11px] font-medium">{sending ? 'Sende…' : 'Senden'}</span>
</button>
</div>
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-zinc-100/80 px-4 py-1.5 dark:border-zinc-800/50">
<select
value={account?.id ?? ''}
onChange={(e) => onAccountChange(e.target.value)}
className="w-28 shrink-0 rounded-md border border-zinc-300 bg-white px-2 py-1 text-[11.5px] font-medium text-zinc-600 outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
>
{sendableAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
</option>
))}
</select>
<span className="flex-1 truncate text-[13px] text-zinc-500 dark:text-zinc-400">{account?.email}</span>
</div>
<div className="flex items-center gap-3 border-b border-zinc-100/80 py-1.5 pr-4 dark:border-zinc-800/50">
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-zinc-300 py-1 text-[11.5px] text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
An
</div>
<RecipientInput value={to} onChange={setTo} placeholder="empfaenger@example.com" />
{!showCc && (
<button
onClick={() => setShowCc(true)}
className="shrink-0 rounded-full px-2 py-0.5 text-[11px] text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
Cc
</button>
)}
</div>
{showCc && (
<div className="flex items-center gap-3 border-b border-zinc-100/80 py-1.5 pr-4 dark:border-zinc-800/50">
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-zinc-300 py-1 text-[11.5px] text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
Cc
</div>
<RecipientInput value={cc} onChange={setCc} />
</div>
)}
{showBcc && (
<div className="flex items-center gap-3 border-b border-zinc-100/80 py-1.5 pr-4 dark:border-zinc-800/50">
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-zinc-300 py-1 text-[11.5px] text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
Bcc
</div>
<RecipientInput value={bcc} onChange={setBcc} />
</div>
)}
<div className="flex items-center gap-3 py-1.5 pr-4">
<div className="flex w-28 shrink-0 items-center justify-center rounded-md border border-transparent py-1 text-[11.5px] text-zinc-400">
Betreff
</div>
<input
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="flex-1 bg-transparent text-[13px] font-medium text-zinc-800 outline-none dark:text-zinc-100"
/>
</div>
</div>
</div>
<RichTextEditor ref={editorRef} html={body} resetKey={initial} onChange={setBody} placeholder="Schreib etwas Geiles…" />
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-t border-zinc-100/80 px-7 py-3 dark:border-zinc-800/50">
{attachments.map((att, i) => (
<span
key={i}
className="flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300"
>
{att.filename}
<span className="text-zinc-400">
{formatBytes(Math.ceil((att.content_base64.length * 3) / 4))}
</span>
<button
onClick={() => setAttachments((prev) => prev.filter((_, idx) => idx !== i))}
className="text-zinc-400 hover:text-red-500"
>
<X size={11} />
</button>
</span>
))}
</div>
)}
{info && (
<p className="border-t border-indigo-100 bg-indigo-50/80 px-7 py-2 text-[12px] text-indigo-600 dark:border-indigo-950 dark:bg-indigo-950/40 dark:text-indigo-400">
{info}
</p>
)}
{error && (
<p className="border-t border-red-100 bg-red-50/80 px-7 py-2.5 text-[12px] text-red-600 dark:border-red-950 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
)}
<div className="flex items-center justify-between border-t border-zinc-200/80 px-7 py-2.5 dark:border-zinc-800/80">
<input ref={fileInput} type="file" multiple hidden onChange={(e) => handleFiles(e.target.files)} />
<span className="flex items-center gap-1.5 text-[11px] text-zinc-400">
<PenLine size={12} /> {plainTextMode ? 'Nur-Text-Format' : 'HTML-Format'}
{delayAt && ` · Wird gesendet am ${new Date(delayAt).toLocaleString('de-DE')}`}
</span>
<button
onClick={onDiscard}
title="Verwerfen"
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>
<TemplatesModal
open={templatesOpen}
onClose={() => setTemplatesOpen(false)}
onToast={showInfo}
onInsert={handleInsertTemplate}
/>
</div>
)
}
+301
View File
@@ -0,0 +1,301 @@
import { useRef, useState } from 'react'
import {
ArrowDown,
ArrowUp,
BookUser,
Bold,
CalendarClock,
CheckCheck,
Clipboard,
Clock,
Code2,
Copy,
FileText,
Image as ImageIcon,
Italic,
Link2,
List,
ListOrdered,
Lock,
Mail,
Minus,
Paintbrush,
Palette,
Paperclip,
PenLine,
Quote,
RemoveFormatting,
Save,
Scissors,
Search,
SpellCheck,
Strikethrough,
Truck,
Type,
Underline,
User,
} from 'lucide-react'
import type { RichTextEditorHandle } from './RichTextEditor'
import { ClassicButton, ClassicDropdown, ClassicRibbonBody, ClassicRibbonGroup, ClassicTabBar } from './ClassicRibbonUI'
type ComposeTab = 'message' | 'insert' | 'options' | 'format' | 'review'
function DelayDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false)
return (
<ClassicDropdown open={open} onOpenChange={setOpen} widthClass="w-64" button={<ClassicButton icon={Clock} label="Übermittlung verzögern" active={!!value} />}>
<div className="space-y-2 px-3 py-2">
<label className="block text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Senden am</label>
<input
type="datetime-local"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-[12px] text-zinc-800 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
/>
{value && (
<button onClick={() => onChange('')} className="text-[11px] text-red-500 hover:text-red-600">
Verzögerung entfernen
</button>
)}
</div>
</ClassicDropdown>
)
}
function ReplyToDropdown({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false)
return (
<ClassicDropdown open={open} onOpenChange={setOpen} widthClass="w-64" button={<ClassicButton icon={Mail} label="Antworten richten an" active={!!value} />}>
<div className="space-y-2 px-3 py-2">
<label className="block text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Reply-To-Adresse</label>
<input
type="email"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="antworten@example.com"
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-[12px] text-zinc-800 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100"
/>
</div>
</ClassicDropdown>
)
}
export function ComposeRibbon({
editor,
onAttach,
onOpenSignature,
onOpenTemplates,
onOpenContactPicker,
importance,
onImportanceChange,
showBcc,
onToggleBcc,
requestReadReceipt,
onToggleReadReceipt,
delayAt,
onDelayChange,
replyTo,
onReplyToChange,
plainTextMode,
onTogglePlainText,
onWordCount,
}: {
editor: React.RefObject<RichTextEditorHandle | null>
onAttach: () => void
onOpenSignature: () => void
onOpenTemplates: () => void
onOpenContactPicker?: () => void
importance: 'high' | 'low' | null
onImportanceChange: (v: 'high' | 'low' | null) => void
showBcc: boolean
onToggleBcc: () => void
requestReadReceipt: boolean
onToggleReadReceipt: () => void
delayAt: string
onDelayChange: (v: string) => void
replyTo: string
onReplyToChange: (v: string) => void
plainTextMode: boolean
onTogglePlainText: () => void
onWordCount: (count: number) => void
}) {
const [tab, setTab] = useState<ComposeTab>('message')
const imageInputRef = useRef<HTMLInputElement>(null)
function exec(command: string, value?: string) {
editor.current?.exec(command, value)
}
function handleImagePick(file: File | undefined) {
if (!file) return
const reader = new FileReader()
reader.onload = () => exec('insertImage', reader.result as string)
reader.readAsDataURL(file)
}
const tabs = [
{ id: 'message', label: 'Nachricht' },
{ id: 'insert', label: 'Einfügen' },
{ id: 'options', label: 'Optionen' },
{ id: 'format', label: 'Text formatieren' },
{ id: 'review', label: 'Überprüfen' },
]
return (
<div className="w-full border-b border-[#0d3a6e]">
<ClassicTabBar tabs={tabs} active={tab} onChange={(id) => setTab(id as ComposeTab)} />
<ClassicRibbonBody>
{tab === 'message' && (
<>
<ClassicRibbonGroup caption="Zwischenablage">
<div className="flex flex-col gap-0.5 pt-1">
<ClassicButton size="small" icon={Clipboard} label="Einfügen" disabled title="Zwischenablage-Zugriff nicht verfügbar" />
<ClassicButton size="small" icon={Scissors} label="Ausschneiden" onClick={() => exec('cut')} />
<ClassicButton size="small" icon={Copy} label="Kopieren" onClick={() => exec('copy')} />
</div>
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Standardtext">
<div className="grid grid-cols-4 gap-0.5 pt-1">
<ClassicButton size="small" icon={Bold} label="" title="Fett" onClick={() => exec('bold')} />
<ClassicButton size="small" icon={Italic} label="" title="Kursiv" onClick={() => exec('italic')} />
<ClassicButton size="small" icon={Underline} label="" title="Unterstrichen" onClick={() => exec('underline')} />
<ClassicButton size="small" icon={Strikethrough} label="" title="Durchgestrichen" onClick={() => exec('strikeThrough')} />
<ClassicButton size="small" icon={List} label="" title="Liste" onClick={() => exec('insertUnorderedList')} />
<ClassicButton size="small" icon={ListOrdered} label="" title="Nummeriert" onClick={() => exec('insertOrderedList')} />
<ClassicButton size="small" icon={Quote} label="" title="Zitat" onClick={() => exec('formatBlock', 'blockquote')} />
<ClassicButton
size="small"
icon={Link2}
label=""
title="Link einfügen"
onClick={() => {
const url = window.prompt('Link-URL:', 'https://')
if (url) exec('createLink', url)
}}
/>
<ClassicButton size="small" icon={RemoveFormatting} label="" title="Formatierung entfernen" onClick={() => exec('removeFormat')} />
</div>
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Namen">
<ClassicButton icon={BookUser} label="Adressbuch" onClick={onOpenContactPicker} disabled={!onOpenContactPicker} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Einfügen">
<ClassicButton icon={Paperclip} label="Datei anfügen" onClick={onAttach} />
<ClassicButton icon={PenLine} label="Signatur" onClick={onOpenSignature} />
<ClassicButton icon={FileText} label="Vorlagen" onClick={onOpenTemplates} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Markierungen">
<ClassicButton
icon={ArrowUp}
label="Wichtigkeit: hoch"
onClick={() => onImportanceChange(importance === 'high' ? null : 'high')}
active={importance === 'high'}
/>
<ClassicButton
icon={ArrowDown}
label="Wichtigkeit: niedrig"
onClick={() => onImportanceChange(importance === 'low' ? null : 'low')}
active={importance === 'low'}
/>
</ClassicRibbonGroup>
</>
)}
{tab === 'insert' && (
<>
<ClassicRibbonGroup caption="Einschließen">
<ClassicButton icon={Paperclip} label="Datei anfügen" onClick={onAttach} />
<ClassicButton icon={PenLine} label="Signatur" onClick={onOpenSignature} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Illustrationen">
<input
ref={imageInputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => handleImagePick(e.target.files?.[0])}
/>
<ClassicButton icon={ImageIcon} label="Bilder" onClick={() => imageInputRef.current?.click()} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Links">
<ClassicButton
icon={Link2}
label="Link"
onClick={() => {
const url = window.prompt('Link-URL:', 'https://')
if (url) exec('createLink', url)
}}
/>
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Text">
<ClassicButton icon={Minus} label="Horizontale Linie" onClick={() => exec('insertHorizontalRule')} />
<ClassicButton
icon={CalendarClock}
label="Datum und Uhrzeit"
onClick={() => exec('insertText', new Date().toLocaleString('de-DE'))}
/>
</ClassicRibbonGroup>
</>
)}
{tab === 'options' && (
<>
<ClassicRibbonGroup caption="Designs">
<ClassicButton icon={Palette} label="Designs" disabled title="Nicht verfügbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Felder anzeigen">
<ClassicButton icon={Mail} label="Bcc" onClick={onToggleBcc} active={showBcc} />
<ClassicButton icon={User} label="Von" active disabled title="Von-Feld ist immer sichtbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Verschlüsseln">
<ClassicButton icon={Lock} label="Verschlüsseln" disabled title="S/MIME: in dieser Version nicht verfügbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Verlauf">
<ClassicButton icon={CheckCheck} label="Lesebestät. anfordern" onClick={onToggleReadReceipt} active={requestReadReceipt} />
<ClassicButton icon={Truck} label="Zustellungsbestät." disabled title="Nicht zuverlässig standardisiert: nicht verfügbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Weitere Optionen">
<DelayDropdown value={delayAt} onChange={onDelayChange} />
<ReplyToDropdown value={replyTo} onChange={onReplyToChange} />
<ClassicButton icon={Save} label="Speichern unter" disabled title="Nicht verfügbar" />
</ClassicRibbonGroup>
</>
)}
{tab === 'format' && (
<>
<ClassicRibbonGroup caption="Format">
<ClassicButton icon={Code2} label={plainTextMode ? 'Nur Text' : 'HTML'} onClick={onTogglePlainText} active={plainTextMode} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Schriftart">
<div className="grid grid-cols-3 gap-0.5 pt-1">
<ClassicButton size="small" icon={Bold} label="" title="Fett" onClick={() => exec('bold')} />
<ClassicButton size="small" icon={Italic} label="" title="Kursiv" onClick={() => exec('italic')} />
<ClassicButton size="small" icon={Underline} label="" title="Unterstrichen" onClick={() => exec('underline')} />
</div>
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Formatvorlagen">
<ClassicButton size="small" icon={Paintbrush} label="Standard" onClick={() => exec('formatBlock', 'P')} />
<ClassicButton size="small" icon={Paintbrush} label="Überschrift 1" onClick={() => exec('formatBlock', 'H1')} />
<ClassicButton size="small" icon={Paintbrush} label="Überschrift 2" onClick={() => exec('formatBlock', 'H2')} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Bearbeiten">
<ClassicButton icon={Search} label="Suchen" disabled title="Nicht verfügbar" />
</ClassicRibbonGroup>
</>
)}
{tab === 'review' && (
<>
<ClassicRibbonGroup caption="Rechtschreibung">
<ClassicButton icon={SpellCheck} label="Rechtschreibung und Grammatik" active disabled title="Automatische Prüfung des Browsers ist aktiv" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Sprache">
<ClassicButton icon={Type} label="Wörter zählen" onClick={() => onWordCount(editor.current?.wordCount() ?? 0)} />
</ClassicRibbonGroup>
</>
)}
</ClassicRibbonBody>
</div>
)
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react'
import { ArrowLeft, Pencil, Plus, Trash2, X } from 'lucide-react'
import type { Contact, ContactGroup, NewContactGroup } from '../types'
const inputClass =
'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'
export function ContactGroupModal({
open,
contacts,
groups,
onClose,
onSave,
onDelete,
}: {
open: boolean
contacts: Contact[]
groups: ContactGroup[]
onClose: () => void
onSave: (data: NewContactGroup, existingId: string | null) => Promise<void>
onDelete: (group: ContactGroup) => Promise<void>
}) {
const [editing, setEditing] = useState<ContactGroup | 'new' | null>(null)
const [name, setName] = useState('')
const [memberIds, setMemberIds] = useState<Set<string>>(new Set())
const [saving, setSaving] = useState(false)
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setEditing(null)
setConfirmDeleteId(null)
}
}, [open])
if (!open) return null
function startNew() {
setEditing('new')
setName('')
setMemberIds(new Set())
}
function startEdit(g: ContactGroup) {
setEditing(g)
setName(g.name)
setMemberIds(new Set(g.member_ids))
}
function toggleMember(id: string) {
setMemberIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return
setSaving(true)
try {
await onSave({ name: name.trim(), member_ids: Array.from(memberIds) }, editing === 'new' ? null : (editing?.id ?? null))
setEditing(null)
} finally {
setSaving(false)
}
}
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">
<div className="flex items-center gap-2">
{editing && (
<button
onClick={() => setEditing(null)}
className="rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<ArrowLeft size={15} />
</button>
)}
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{editing === 'new' ? 'Neue Gruppe' : editing ? 'Gruppe bearbeiten' : 'Kontaktgruppen'}
</h2>
</div>
<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>
{editing ? (
<form onSubmit={handleSubmit} className="flex flex-1 flex-col overflow-hidden">
<div className="space-y-3 px-6 py-4">
<label className="flex flex-col gap-1">
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Name</span>
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} autoFocus required />
</label>
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Mitglieder</p>
</div>
<div className="flex-1 space-y-0.5 overflow-y-auto px-6 pb-4">
{contacts.length === 0 ? (
<p className="text-[12.5px] text-zinc-400">Keine Kontakte vorhanden.</p>
) : (
contacts.map((c) => (
<label
key={c.id}
className="flex items-center gap-2.5 rounded-xl px-2 py-1.5 text-[12.5px] text-zinc-700 transition hover:bg-zinc-50 dark:text-zinc-200 dark:hover:bg-zinc-800/60"
>
<input
type="checkbox"
checked={memberIds.has(c.id)}
onChange={() => toggleMember(c.id)}
className="rounded border-zinc-300"
/>
<span className="min-w-0 flex-1 truncate">{c.name || c.email}</span>
<span className="shrink-0 truncate text-[11px] text-zinc-400">{c.email}</span>
</label>
))
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
type="button"
onClick={() => setEditing(null)}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</form>
) : (
<>
<div className="flex-1 space-y-1.5 overflow-y-auto px-6 py-4">
{groups.length === 0 ? (
<p className="text-[12.5px] text-zinc-400">Noch keine Gruppen angelegt.</p>
) : (
groups.map((g) => (
<div
key={g.id}
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
>
<span className="min-w-0 flex-1 truncate text-[13px] text-zinc-700 dark:text-zinc-200">
{g.name} <span className="text-zinc-400">({g.member_ids.length})</span>
</span>
<button
onClick={() => startEdit(g)}
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<Pencil size={13} />
</button>
{confirmDeleteId === g.id ? (
<button
onClick={() => onDelete(g)}
className="shrink-0 rounded-full bg-red-600 px-2.5 py-1 text-[11px] font-medium text-white transition hover:bg-red-500"
>
Wirklich löschen?
</button>
) : (
<button
onClick={() => setConfirmDeleteId(g.id)}
className="shrink-0 rounded-full p-1 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 className="border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
onClick={startNew}
className="flex w-full items-center justify-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500"
>
<Plus size={14} /> Neue Gruppe
</button>
</div>
</>
)}
</div>
</div>
)
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import type { Contact, NewContact } from '../types'
const inputClass =
'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 ContactModal({
open,
contact,
onClose,
onSave,
onDelete,
}: {
open: boolean
contact: Contact | null
onClose: () => void
onSave: (data: NewContact, existingId: string | null) => Promise<void>
onDelete: (contact: Contact) => Promise<void>
}) {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [address, setAddress] = useState('')
const [company, setCompany] = useState('')
const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (open) {
setName(contact?.name ?? '')
setEmail(contact?.email ?? '')
setPhone(contact?.phone ?? '')
setAddress(contact?.address ?? '')
setCompany(contact?.company ?? '')
setNotes(contact?.notes ?? '')
setError(null)
}
}, [open, contact])
if (!open) return null
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSaving(true)
try {
await onSave(
{
name: name.trim() || email.trim() || phone.trim() || address.trim() || 'Unbenannt',
email: email.trim() || undefined,
phone: phone.trim() || undefined,
address: address.trim() || undefined,
company: company.trim() || undefined,
notes: notes.trim() || undefined,
},
contact?.id ?? null,
)
} catch (err) {
setError(String(err))
setSaving(false)
return
}
setSaving(false)
}
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">
<form
onSubmit={handleSubmit}
className="animate-panel-in flex w-full max-w-sm 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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{contact ? 'Kontakt bearbeiten' : 'Kontakt hinzufügen'}
</h2>
<button
type="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-3.5 px-6 py-5">
<Field label="Name">
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Ada Lovelace" />
</Field>
<Field label="E-Mail-Adresse (optional)">
<input
className={inputClass}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="ada@example.com"
/>
</Field>
<Field label="Telefon (optional)">
<input
className={inputClass}
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+49 30 1234567"
/>
</Field>
<Field label="Adresse (optional)">
<input
className={inputClass}
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="Musterstraße 1, 12345 Berlin"
/>
</Field>
<Field label="Firma (optional)">
<input className={inputClass} value={company} onChange={(e) => setCompany(e.target.value)} />
</Field>
<Field label="Notizen (optional)">
<textarea
className={`${inputClass} min-h-16 resize-none`}
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</Field>
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex items-center justify-between border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
{contact ? (
<button
type="button"
onClick={() => onDelete(contact)}
className="rounded-full px-3 py-2 text-[12.5px] font-medium text-red-500 transition hover:bg-red-50 dark:hover:bg-red-950/40"
>
Löschen
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</form>
</div>
)
}
+334
View File
@@ -0,0 +1,334 @@
import { useMemo, useState } from 'react'
import { open, save } from '@tauri-apps/plugin-dialog'
import { Cloud, Download, HardDrive, Mail, Plus, RefreshCw, Search, Star, Upload, UsersRound, UserRound } from 'lucide-react'
import clsx from 'clsx'
import type { Account, Contact, ContactGroup } from '../types'
import { Avatar } from './Avatar'
import { api } from '../lib/api'
import { GoogleContactsMenu } from './GoogleContactsMenu'
function contactSourceKey(c: Contact): string {
return c.google_account_id ? `google:${c.google_account_id}` : 'local'
}
function contactSourceLabel(c: Contact, accounts: Account[]): string {
if (!c.google_account_id) return 'Lokal'
const acc = accounts.find((a) => a.id === c.google_account_id)
return acc ? `Google ${acc.label}` : 'Google'
}
export function ContactsView({
contacts,
groups,
loading,
onAdd,
onEdit,
onToggleFavorite,
onCompose,
onContextMenu,
onComposeToEmails,
onManageGroups,
onRefresh,
onToast,
googleAccounts,
onSyncGoogleContacts,
onReconnectGoogleAccount,
}: {
contacts: Contact[]
groups: ContactGroup[]
loading: boolean
onAdd: () => void
onEdit: (c: Contact) => void
onToggleFavorite: (c: Contact) => void
onCompose: (c: Contact) => void
onContextMenu?: (e: React.MouseEvent, c: Contact) => void
onComposeToEmails: (emails: string[]) => void
onManageGroups: () => void
onRefresh: () => void
onToast: (msg: string) => void
googleAccounts: Account[]
onSyncGoogleContacts: (accountId: string) => Promise<void>
onReconnectGoogleAccount: (accountId: string) => Promise<void>
}) {
const [query, setQuery] = useState('')
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)
const selectedGroup = groups.find((g) => g.id === selectedGroupId) ?? null
const [selectedSourceKey, setSelectedSourceKey] = useState<string | null>(null)
const sourceOptions = useMemo(() => {
const map = new Map<string, string>()
for (const c of contacts) {
const key = contactSourceKey(c)
if (!map.has(key)) map.set(key, contactSourceLabel(c, googleAccounts))
}
return [...map.entries()]
.sort(([a, la], [b, lb]) => (a === 'local' ? -1 : b === 'local' ? 1 : la.localeCompare(lb)))
.map(([key, label]) => ({ key, label }))
}, [contacts, googleAccounts])
async function handleExport() {
const path = await save({ defaultPath: 'kontakte.vcf', filters: [{ name: 'vCard', extensions: ['vcf'] }] })
if (!path) return
try {
const count = await api.exportContactsVcard(path)
onToast(`${count} Kontakte exportiert`)
} catch (e) {
onToast(`Export fehlgeschlagen: ${e}`)
}
}
async function handleImport() {
const path = await open({ multiple: false, filters: [{ name: 'vCard', extensions: ['vcf'] }] })
if (!path || Array.isArray(path)) return
try {
const count = await api.importContactsVcard(path)
onToast(`${count} Kontakte importiert`)
onRefresh()
} catch (e) {
onToast(`Import fehlgeschlagen: ${e}`)
}
}
const filtered = useMemo(() => {
let base = contacts
if (selectedGroup) {
const ids = new Set(selectedGroup.member_ids)
base = base.filter((c) => ids.has(c.id))
}
if (selectedSourceKey) {
base = base.filter((c) => contactSourceKey(c) === selectedSourceKey)
}
const q = query.trim().toLowerCase()
if (!q) return base
return base.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
(c.email ?? '').toLowerCase().includes(q) ||
(c.phone ?? '').toLowerCase().includes(q) ||
(c.address ?? '').toLowerCase().includes(q) ||
(c.company ?? '').toLowerCase().includes(q),
)
}, [contacts, query, selectedGroup, selectedSourceKey])
const groupedBySource = useMemo(() => {
const map = new Map<string, Contact[]>()
for (const c of filtered) {
const key = contactSourceKey(c)
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(c)
}
return sourceOptions.filter((o) => map.has(o.key)).map((o) => ({ ...o, items: map.get(o.key)! }))
}, [filtered, sourceOptions])
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-zinc-200/80 px-7 py-4 dark:border-zinc-800/80">
<h1 className="text-[17px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kontakte
</h1>
<div className="flex flex-1 items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 dark:border-zinc-800 dark:bg-zinc-900">
<Search size={13} className="shrink-0 text-zinc-400" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Kontakte durchsuchen…"
className="w-full bg-transparent text-[12.5px] text-zinc-700 outline-none placeholder:text-zinc-400 dark:text-zinc-200"
/>
</div>
<button
onClick={onRefresh}
title="Kontakte aktualisieren"
disabled={loading}
className="shrink-0 rounded-full border border-zinc-200 p-2 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 disabled:opacity-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<RefreshCw size={14} className={clsx(loading && 'animate-spin')} />
</button>
<button
onClick={handleExport}
title="Als vCard exportieren"
className="shrink-0 rounded-full border border-zinc-200 p-2 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<Download size={14} />
</button>
<button
onClick={handleImport}
title="vCard importieren"
className="shrink-0 rounded-full border border-zinc-200 p-2 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<Upload size={14} />
</button>
<GoogleContactsMenu accounts={googleAccounts} onSync={onSyncGoogleContacts} onReconnect={onReconnectGoogleAccount} />
<button
onClick={onManageGroups}
title="Gruppen verwalten"
className="shrink-0 rounded-full border border-zinc-200 p-2 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<UsersRound size={14} />
</button>
<button
onClick={onAdd}
className="flex shrink-0 items-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-2 text-[12.5px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 active:scale-[0.98]"
>
<Plus size={14} /> Kontakt
</button>
</div>
{groups.length > 0 && (
<div className="flex items-center gap-2 border-b border-zinc-200/80 px-7 py-2.5 dark:border-zinc-800/80">
<button
onClick={() => setSelectedGroupId(null)}
className={clsx(
'shrink-0 rounded-full px-3 py-1 text-[12px] font-medium transition',
!selectedGroup
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
)}
>
Alle
</button>
{groups.map((g) => (
<button
key={g.id}
onClick={() => setSelectedGroupId(g.id === selectedGroupId ? null : g.id)}
className={clsx(
'shrink-0 rounded-full px-3 py-1 text-[12px] font-medium transition',
g.id === selectedGroupId
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
)}
>
{g.name} ({g.member_ids.length})
</button>
))}
{selectedGroup && selectedGroup.member_ids.length > 0 && (
<button
onClick={() => {
const emails = contacts
.filter((c) => selectedGroup.member_ids.includes(c.id))
.map((c) => c.email)
.filter((e): e is string => !!e)
onComposeToEmails(emails)
}}
className="ml-auto flex shrink-0 items-center gap-1.5 rounded-full border border-indigo-200 px-3 py-1 text-[12px] font-medium text-indigo-700 transition hover:bg-indigo-50 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
>
<Mail size={12} /> Gruppe anschreiben
</button>
)}
</div>
)}
{sourceOptions.length > 1 && (
<div className="flex items-center gap-2 border-b border-zinc-200/80 px-7 py-2.5 dark:border-zinc-800/80">
<button
onClick={() => setSelectedSourceKey(null)}
className={clsx(
'shrink-0 rounded-full px-3 py-1 text-[12px] font-medium transition',
!selectedSourceKey
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
)}
>
Alle Quellen
</button>
{sourceOptions.map((o) => (
<button
key={o.key}
onClick={() => setSelectedSourceKey(o.key === selectedSourceKey ? null : o.key)}
className={clsx(
'flex shrink-0 items-center gap-1 rounded-full px-3 py-1 text-[12px] font-medium transition',
o.key === selectedSourceKey
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400',
)}
>
{o.key === 'local' ? <HardDrive size={11} /> : <Cloud size={11} />}
{o.label}
</button>
))}
</div>
)}
<div className="flex-1 overflow-y-auto p-6">
{loading ? (
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-20 animate-pulse rounded-2xl bg-zinc-100 dark:bg-zinc-900" />
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 text-zinc-300 dark:bg-zinc-900 dark:text-zinc-700">
<UserRound size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">
{query ? 'Keine Treffer' : 'Noch keine Kontakte sie werden auch automatisch aus deinen E-Mails übernommen'}
</p>
</div>
) : (
<div className="flex flex-col gap-6">
{groupedBySource.map((group) => (
<div key={group.key}>
{!selectedSourceKey && (
<div className="mb-2.5 flex items-center gap-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
{group.key === 'local' ? <HardDrive size={11} /> : <Cloud size={11} />}
{group.label}
<span className="font-normal normal-case text-zinc-300 dark:text-zinc-600">
({group.items.length})
</span>
</div>
)}
<div className="grid grid-cols-2 gap-3 xl:grid-cols-3">
{group.items.map((c) => (
<div
key={c.id}
onClick={() => onEdit(c)}
onContextMenu={(e) => onContextMenu?.(e, c)}
className="group flex cursor-default items-start gap-3 rounded-2xl border border-zinc-200/80 bg-white p-4 shadow-sm transition hover:border-indigo-200 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900 dark:hover:border-indigo-800"
>
<Avatar name={c.name || c.email || c.phone || '?'} size={40} />
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-zinc-800 dark:text-zinc-100">
{c.name || c.email || c.phone || c.address}
</p>
<p className="truncate text-[12px] text-zinc-400">{c.email ?? c.phone ?? c.address}</p>
{c.company && <p className="truncate text-[11.5px] text-zinc-400">{c.company}</p>}
</div>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<button
onClick={(e) => {
e.stopPropagation()
onToggleFavorite(c)
}}
className={clsx(
'rounded-full p-1 transition',
c.favorite
? 'text-amber-400'
: 'text-zinc-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 dark:text-zinc-700',
)}
>
<Star size={14} fill={c.favorite ? 'currentColor' : 'none'} />
</button>
{c.email && (
<button
onClick={(e) => {
e.stopPropagation()
onCompose(c)
}}
title="Mail schreiben"
className="rounded-full p-1 text-zinc-300 opacity-0 transition group-hover:opacity-100 hover:text-indigo-500 dark:text-zinc-700"
>
<Mail size={14} />
</button>
)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronRight } from 'lucide-react'
import clsx from 'clsx'
export interface ContextMenuItem {
label: string
icon?: React.ReactNode
onClick?: () => void
danger?: boolean
disabled?: boolean
submenu?: ContextMenuItem[]
}
export type ContextMenuSection = ContextMenuItem[]
export interface ContextMenuState {
x: number
y: number
sections: ContextMenuSection[]
}
/// Hook that owns {x,y,sections} state for a right-click menu. Call `openMenu`
/// from an `onContextMenu` handler with the sections to show; render the
/// returned state through <ContextMenu>.
export function useContextMenu() {
const [menuState, setMenuState] = useState<ContextMenuState | null>(null)
function openMenu(e: React.MouseEvent, sections: ContextMenuSection[]) {
e.preventDefault()
e.stopPropagation()
setMenuState({ x: e.clientX, y: e.clientY, sections })
}
function closeMenu() {
setMenuState(null)
}
return { menuState, openMenu, closeMenu }
}
function MenuItemRow({ item, onDone }: { item: ContextMenuItem; onDone: () => void }) {
const itemClass = (danger?: boolean, disabled?: boolean) =>
clsx(
'flex w-full items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-[12.5px] transition',
disabled
? 'cursor-default text-zinc-300 dark:text-zinc-700'
: danger
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
: 'text-zinc-700 hover:bg-zinc-100 dark:text-zinc-200 dark:hover:bg-zinc-800',
)
if (item.submenu) {
return (
<div className="group/sub relative">
<button type="button" disabled={item.disabled} className={clsx(itemClass(item.danger, item.disabled), 'justify-between')}>
<span className="flex items-center gap-2.5">
{item.icon}
{item.label}
</span>
<ChevronRight size={12} className="shrink-0 text-zinc-400" />
</button>
{!item.disabled && item.submenu.length > 0 && (
<div className="invisible absolute left-full top-0 z-10 ml-1 w-56 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-1.5 opacity-0 shadow-xl backdrop-blur-xl transition-opacity group-hover/sub:visible group-hover/sub:opacity-100 dark:border-zinc-800 dark:bg-zinc-900/95">
{item.submenu.map((sub, i) => (
<MenuItemRow key={i} item={sub} onDone={onDone} />
))}
</div>
)}
</div>
)
}
return (
<button
type="button"
disabled={item.disabled}
onClick={() => {
onDone()
item.onClick?.()
}}
className={itemClass(item.danger, item.disabled)}
>
{item.icon}
{item.label}
</button>
)
}
export function ContextMenu({ state, onClose }: { state: ContextMenuState | null; onClose: () => void }) {
const panelRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!state) return
function onClickOutside(e: MouseEvent) {
if (panelRef.current?.contains(e.target as Node)) return
onClose()
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', onClickOutside)
document.addEventListener('contextmenu', onClickOutside)
document.addEventListener('keydown', onKeyDown)
window.addEventListener('scroll', onClose, true)
window.addEventListener('blur', onClose)
return () => {
document.removeEventListener('mousedown', onClickOutside)
document.removeEventListener('contextmenu', onClickOutside)
document.removeEventListener('keydown', onKeyDown)
window.removeEventListener('scroll', onClose, true)
window.removeEventListener('blur', onClose)
}
}, [state, onClose])
if (!state) return null
const menuWidth = 224
const left = Math.min(state.x, window.innerWidth - menuWidth - 8)
const top = Math.min(state.y, window.innerHeight - 40)
return createPortal(
<div
ref={panelRef}
style={{ position: 'fixed', top, left, zIndex: 200, width: menuWidth }}
className="animate-panel-in overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
{state.sections.map((section, si) => (
<div key={si}>
{si > 0 && <div className="my-1 h-px bg-zinc-200/80 dark:bg-zinc-800" />}
{section.map((item, ii) => (
<MenuItemRow key={ii} item={item} onDone={onClose} />
))}
</div>
))}
</div>,
document.body,
)
}
+395
View File
@@ -0,0 +1,395 @@
import { useEffect, useState } from 'react'
import { Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import type { Account, CaldavAccount, Event, NewEvent } from '../types'
import { CATEGORY_COLORS } from '../lib/categories'
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
const FREQ_MAP: Record<Exclude<RecurrenceOption, 'none'>, string> = {
daily: 'DAILY',
weekly: 'WEEKLY',
monthly: 'MONTHLY',
yearly: 'YEARLY',
}
function parseRrule(rrule: string | null): { recurrence: RecurrenceOption; until: string } {
if (!rrule) return { recurrence: 'none', until: '' }
const freqMatch = /FREQ=([A-Z]+)/.exec(rrule)
const untilMatch = /UNTIL=(\d{8})/.exec(rrule)
const freq = freqMatch?.[1]
const recurrence = (Object.entries(FREQ_MAP).find(([, v]) => v === freq)?.[0] as RecurrenceOption) ?? 'none'
const until = untilMatch ? `${untilMatch[1].slice(0, 4)}-${untilMatch[1].slice(4, 6)}-${untilMatch[1].slice(6, 8)}` : ''
return { recurrence, until }
}
const REMINDER_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: 'Keine' },
{ value: '10', label: '10 Minuten vorher' },
{ value: '30', label: '30 Minuten vorher' },
{ value: '60', label: '1 Stunde vorher' },
{ value: '1440', label: '1 Tag vorher' },
]
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>
)
}
const inputClass =
'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 toDateStr(d: Date) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function toTimeStr(d: Date) {
const h = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${h}:${min}`
}
export function EventModal({
open,
event,
defaultDate,
googleAccounts,
caldavAccounts,
isOccurrenceEdit,
onClose,
onSave,
onDelete,
}: {
open: boolean
event: Event | null
defaultDate: Date | null
googleAccounts: Account[]
caldavAccounts?: CaldavAccount[]
isOccurrenceEdit?: boolean
onClose: () => void
onSave: (data: NewEvent, existingId: string | null) => Promise<void>
onDelete: (event: Event) => Promise<void>
}) {
const [title, setTitle] = useState('')
const [date, setDate] = useState('')
const [startTime, setStartTime] = useState('09:00')
const [endTime, setEndTime] = useState('10:00')
const [allDay, setAllDay] = useState(false)
const [location, setLocation] = useState('')
const [description, setDescription] = useState('')
const [calendarTarget, setCalendarTarget] = useState('')
const [recurrence, setRecurrence] = useState<RecurrenceOption>('none')
const [recurrenceUntil, setRecurrenceUntil] = useState('')
const [reminder, setReminder] = useState('')
const [category, setCategory] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
useEffect(() => {
if (!open) return
setConfirmDelete(false)
if (event) {
const start = new Date(event.start_ts * 1000)
const end = new Date(event.end_ts * 1000)
const { recurrence: rec, until } = parseRrule(event.rrule)
setTitle(event.title)
setDate(toDateStr(start))
setStartTime(toTimeStr(start))
setEndTime(toTimeStr(end))
setAllDay(event.all_day)
setLocation(event.location ?? '')
setDescription(event.description ?? '')
setCalendarTarget(event.account_id ?? '')
setRecurrence(rec)
setRecurrenceUntil(until)
setReminder(event.reminder_minutes != null ? String(event.reminder_minutes) : '')
setCategory(event.category)
} else {
const base = defaultDate ?? new Date()
setTitle('')
setDate(toDateStr(base))
setStartTime('09:00')
setEndTime('10:00')
setAllDay(false)
setLocation('')
setDescription('')
setCalendarTarget('')
setRecurrence('none')
setRecurrenceUntil('')
setReminder('')
setCategory(null)
}
}, [open, event, defaultDate])
if (!open) return null
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!title.trim() || !date) return
setSaving(true)
try {
let start_ts: number
let end_ts: number
if (allDay) {
start_ts = Math.floor(new Date(`${date}T00:00:00`).getTime() / 1000)
end_ts = start_ts + 86400
} else {
start_ts = Math.floor(new Date(`${date}T${startTime}:00`).getTime() / 1000)
end_ts = Math.floor(new Date(`${date}T${endTime}:00`).getTime() / 1000)
if (end_ts <= start_ts) end_ts = start_ts + 3600
}
let rrule: string | undefined
if (recurrence !== 'none') {
rrule = `FREQ=${FREQ_MAP[recurrence]}`
if (recurrenceUntil) rrule += `;UNTIL=${recurrenceUntil.replace(/-/g, '')}`
}
await onSave(
{
title: title.trim(),
location: location.trim() || undefined,
description: description.trim() || undefined,
start_ts,
end_ts,
all_day: allDay,
account_id: event ? undefined : calendarTarget || null,
rrule,
reminder_minutes: reminder ? Number(reminder) : undefined,
category,
},
event?.base_id ?? null,
)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!event) return
setDeleting(true)
try {
await onDelete(event)
} finally {
setDeleting(false)
}
}
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">
<form
onSubmit={handleSubmit}
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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{isOccurrenceEdit ? 'Diesen Termin bearbeiten' : event ? 'Termin bearbeiten' : 'Neuer Termin'}
</h2>
<button
type="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-3.5 overflow-y-auto px-6 py-5">
{isOccurrenceEdit && (
<p className="rounded-lg bg-indigo-50 px-3 py-2 text-[11.5px] text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-300">
Änderungen gelten nur für diesen einen Termin, nicht für die ganze Serie.
</p>
)}
<Field label="Titel">
<input
className={inputClass}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z. B. Teammeeting"
autoFocus
required
/>
</Field>
<Field label="Datum">
<input className={inputClass} type="date" value={date} onChange={(e) => setDate(e.target.value)} required />
</Field>
<label className="flex items-center gap-2 text-[12px] text-zinc-500 dark:text-zinc-400">
<input
type="checkbox"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="rounded border-zinc-300"
/>
Ganztägig
</label>
{!allDay && (
<div className="grid grid-cols-2 gap-3">
<Field label="Beginn">
<input className={inputClass} type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} />
</Field>
<Field label="Ende">
<input className={inputClass} type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} />
</Field>
</div>
)}
<Field label="Kalender">
<select
className={inputClass}
value={calendarTarget}
onChange={(e) => setCalendarTarget(e.target.value)}
disabled={!!event}
>
<option value="">Lokal (nur in dieser App)</option>
{googleAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label} (Google)
</option>
))}
{(caldavAccounts ?? []).map((a) => (
<option key={a.id} value={a.id}>
{a.label} (CalDAV)
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
{!isOccurrenceEdit && (
<Field label="Wiederholung">
<select className={inputClass} value={recurrence} onChange={(e) => setRecurrence(e.target.value as RecurrenceOption)}>
<option value="none">Keine</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
<option value="yearly">Jährlich</option>
</select>
</Field>
)}
<Field label="Erinnerung">
<select className={inputClass} value={reminder} onChange={(e) => setReminder(e.target.value)}>
{REMINDER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
</div>
{!isOccurrenceEdit && recurrence !== 'none' && (
<Field label="Endet am (optional)">
<input
className={inputClass}
type="date"
value={recurrenceUntil}
onChange={(e) => setRecurrenceUntil(e.target.value)}
/>
</Field>
)}
<Field label="Kategorie">
<div className="flex items-center gap-1.5">
{CATEGORY_COLORS.map((c) => (
<button
key={c.id}
type="button"
title={c.label}
onClick={() => setCategory(c.id === category ? null : c.id)}
className={clsx('h-5 w-5 rounded-full transition', c.id === category && 'ring-2 ring-offset-1 ring-zinc-400')}
style={{ backgroundColor: c.hex }}
/>
))}
<button
type="button"
title="Keine"
onClick={() => setCategory(null)}
className="flex h-5 w-5 items-center justify-center rounded-full border border-zinc-300 text-zinc-400 dark:border-zinc-600"
>
<X size={11} />
</button>
</div>
</Field>
{event?.rrule && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-[11.5px] text-amber-700 dark:bg-amber-500/10 dark:text-amber-400">
Teil einer Serie Änderungen und Löschen gelten für die gesamte Serie.
</p>
)}
<Field label="Ort (optional)">
<input className={inputClass} value={location} onChange={(e) => setLocation(e.target.value)} />
</Field>
<Field label="Beschreibung (optional)">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
/>
</Field>
{event && (
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-3 dark:border-red-950/60 dark:bg-red-950/20">
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-[12px] text-red-700 dark:text-red-400">Termin wirklich löschen?</span>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="ml-auto rounded-full bg-red-600 px-3 py-1 text-[11.5px] font-medium text-white transition hover:bg-red-500 disabled:opacity-60"
>
{deleting ? 'Lösche…' : 'Löschen'}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="rounded-full px-3 py-1 text-[11.5px] font-medium text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-600 transition hover:text-red-700 dark:text-red-400"
>
<Trash2 size={13} /> Termin löschen
</button>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
type="button"
onClick={onClose}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</form>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { CalendarSync, Link2, RefreshCw } from 'lucide-react'
import clsx from 'clsx'
import type { Account } from '../types'
import { RibbonButton } from './RibbonButton'
import { Popover } from './Popover'
export function GoogleCalendarMenu({
accounts,
onSync,
onReconnect,
}: {
accounts: Account[]
onSync: (accountId: string) => Promise<void>
onReconnect: (accountId: string) => Promise<void>
}) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
if (accounts.length === 0) return null
return (
<Popover
open={open}
onOpenChange={setOpen}
trigger={<RibbonButton icon={CalendarSync} label="Google-Konten" onClick={() => {}} />}
panelClassName="animate-panel-in w-72 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-2 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
<p className="px-2 py-1 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Google Kalender</p>
{accounts.map((a) => (
<div key={a.id} className="flex items-center gap-1.5 rounded-xl px-2 py-1.5">
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-700 dark:text-zinc-300">{a.label}</span>
<button
type="button"
title="Verbinden / Zugriff erneuern"
disabled={busy === a.id}
onClick={async () => {
setBusy(a.id)
try {
await onReconnect(a.id)
await onSync(a.id)
} finally {
setBusy(null)
}
}}
className="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"
>
<Link2 size={13} />
</button>
<button
type="button"
title="Jetzt synchronisieren"
disabled={busy === a.id}
onClick={async () => {
setBusy(a.id)
try {
await onSync(a.id)
} finally {
setBusy(null)
}
}}
className="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(busy === a.id && 'animate-spin')} />
</button>
</div>
))}
<p className="px-2 pt-1 text-[11px] text-zinc-400">
Neu hinzugefügte Google-Konten synchronisieren automatisch. Bereits vorhandene Konten brauchen einmalig Verbinden (synchronisiert danach sofort mit), um Kalenderzugriff zu erlauben.
</p>
</Popover>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { Link2, RefreshCw, UserCog } from 'lucide-react'
import clsx from 'clsx'
import type { Account } from '../types'
import { Popover } from './Popover'
export function GoogleContactsMenu({
accounts,
onSync,
onReconnect,
}: {
accounts: Account[]
onSync: (accountId: string) => Promise<void>
onReconnect: (accountId: string) => Promise<void>
}) {
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
if (accounts.length === 0) return null
return (
<Popover
open={open}
onOpenChange={setOpen}
trigger={
<button
title="Google-Kontakte"
className="shrink-0 rounded-full border border-zinc-200 p-2 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<UserCog size={14} />
</button>
}
align="end"
panelClassName="animate-panel-in w-72 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 p-2 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
<p className="px-2 py-1 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Google Kontakte</p>
{accounts.map((a) => (
<div key={a.id} className="flex items-center gap-1.5 rounded-xl px-2 py-1.5">
<span className="min-w-0 flex-1 truncate text-[12.5px] text-zinc-700 dark:text-zinc-300">{a.label}</span>
<button
type="button"
title="Verbinden / Zugriff erneuern"
disabled={busy === a.id}
onClick={async () => {
setBusy(a.id)
try {
await onReconnect(a.id)
await onSync(a.id)
} finally {
setBusy(null)
}
}}
className="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"
>
<Link2 size={13} />
</button>
<button
type="button"
title="Jetzt synchronisieren"
disabled={busy === a.id}
onClick={async () => {
setBusy(a.id)
try {
await onSync(a.id)
} finally {
setBusy(null)
}
}}
className="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(busy === a.id && 'animate-spin')} />
</button>
</div>
))}
<p className="px-2 pt-1 text-[11px] text-zinc-400">
Holt Kontakte aus Google (nur Import, kein Zurückschreiben). Bereits vorhandene Google-Konten brauchen
einmalig Verbinden (synchronisiert danach sofort mit), um Kontaktzugriff zu erlauben.
</p>
</Popover>
)
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react'
import { open } from '@tauri-apps/plugin-dialog'
import { HardDrive, Inbox, Upload, X } from 'lucide-react'
import type { Account, Folder } from '../types'
import { api } from '../lib/api'
const inputClass =
'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'
export function ImportModal({
open: isOpen,
accounts,
onClose,
onImported,
}: {
open: boolean
accounts: Account[]
onClose: () => void
onImported: (accountId: string, folder: string) => void
}) {
const targets = accounts.filter((a) => a.protocol === 'local' || a.protocol === 'imap')
const [accountId, setAccountId] = useState('')
const [folders, setFolders] = useState<Folder[]>([])
const [folder, setFolder] = useState('')
const [loadingFolders, setLoadingFolders] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
const first = targets[0]?.id ?? ''
setAccountId(first)
setError(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen])
useEffect(() => {
if (!accountId) return
const account = accounts.find((a) => a.id === accountId)
if (!account) return
if (account.protocol === 'local') {
setFolders([{ name: 'Importiert', display_name: 'Importiert', unread: 0 }])
setFolder('Importiert')
return
}
setLoadingFolders(true)
api
.listFolders(accountId)
.then((f) => {
setFolders(f)
setFolder(f[0]?.name ?? '')
})
.catch((e) => setError(String(e)))
.finally(() => setLoadingFolders(false))
}, [accountId, accounts])
if (!isOpen) return null
async function handlePickAndImport() {
if (!accountId || !folder) return
setError(null)
const path = await open({
multiple: false,
filters: [{ name: 'E-Mail', extensions: ['eml', 'mbox', 'mbx'] }],
})
if (!path || Array.isArray(path)) return
setImporting(true)
try {
if (path.toLowerCase().endsWith('.eml')) {
await api.importEmlFile(accountId, folder, path)
} else {
await api.importMboxFile(accountId, folder, path)
}
onImported(accountId, folder)
onClose()
} catch (e) {
setError(String(e))
} finally {
setImporting(false)
}
}
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 w-full max-w-sm 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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
Mail importieren
</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="space-y-3.5 px-6 py-5">
<p className="text-[12.5px] text-zinc-500 dark:text-zinc-400">
Unterstützt .eml (einzelne Nachricht) und .mbox (mehrere Nachrichten). Wähle zuerst, wohin importiert
werden soll.
</p>
<label className="flex flex-col gap-1">
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Ziel</span>
<select className={inputClass} value={accountId} onChange={(e) => setAccountId(e.target.value)}>
{targets.map((a) => (
<option key={a.id} value={a.id}>
{a.protocol === 'local' ? 'Lokal (nur in dieser App)' : `${a.label} (${a.email})`}
</option>
))}
</select>
</label>
{accounts.find((a) => a.id === accountId)?.protocol === 'imap' && (
<label className="flex flex-col gap-1">
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Zielordner</span>
<select
className={inputClass}
value={folder}
onChange={(e) => setFolder(e.target.value)}
disabled={loadingFolders}
>
{folders.map((f) => (
<option key={f.name} value={f.name}>
{f.display_name}
</option>
))}
</select>
</label>
)}
{accounts.find((a) => a.id === accountId)?.protocol === 'local' && (
<div className="flex items-center gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-[12px] text-zinc-500 dark:bg-zinc-800/60 dark:text-zinc-400">
<HardDrive size={13} className="shrink-0" />
Nachrichten werden nur lokal gespeichert, nicht auf einen Server hochgeladen.
</div>
)}
{accounts.find((a) => a.id === accountId)?.protocol === 'imap' && (
<div className="flex items-center gap-2 rounded-lg bg-zinc-50 px-3 py-2 text-[12px] text-zinc-500 dark:bg-zinc-800/60 dark:text-zinc-400">
<Inbox size={13} className="shrink-0" />
Nachrichten werden per IMAP wirklich in dieses Postfach hochgeladen.
</div>
)}
{error && (
<p className="rounded-lg bg-red-50 px-3 py-2 text-[12px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
onClick={onClose}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
<button
onClick={handlePickAndImport}
disabled={importing || !accountId || !folder}
className="flex items-center gap-2 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-4 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 active:scale-[0.98] disabled:opacity-60"
>
<Upload size={14} />
{importing ? 'Importiere…' : 'Datei wählen…'}
</button>
</div>
</div>
</div>
)
}
+414
View File
@@ -0,0 +1,414 @@
import { useMemo, useRef, useState } from 'react'
import { isToday, isYesterday } from 'date-fns'
import clsx from 'clsx'
import { Inbox, Paperclip, Search, SlidersHorizontal, Star, X } from 'lucide-react'
import type { Account, AdvancedSearchFilters, MessageSummary } from '../types'
import type { SortBy } from './Ribbon'
import { Avatar } from './Avatar'
import { Popover } from './Popover'
import { formatListDate } from '../lib/format'
import { CATEGORY_COLORS, categoryHex } from '../lib/categories'
type ThreadRow = MessageSummary & { threadCount: number }
function sortRows<T extends MessageSummary>(rows: T[], sortBy: SortBy, desc: boolean): T[] {
const sorted = [...rows].sort((a, b) => {
let cmp: number
if (sortBy === 'date') cmp = a.timestamp - b.timestamp
else if (sortBy === 'from') cmp = (a.from_name || a.from_addr).localeCompare(b.from_name || b.from_addr)
else cmp = a.subject.localeCompare(b.subject)
return desc ? -cmp : cmp
})
return sorted
}
function keyOf(m: MessageSummary) {
return `${m.account_id}-${m.folder}-${m.uid}`
}
/// Groups by thread_id (root of the References chain, or the message's own id
/// when it has no parent) — real header-based threading, not a subject guess.
function groupConversationsFn(messages: MessageSummary[]): ThreadRow[] {
const map = new Map<string, MessageSummary[]>()
for (const m of messages) {
const key = m.thread_id || `__${m.folder}_${m.uid}`
const list = map.get(key)
if (list) list.push(m)
else map.set(key, [m])
}
const rows: ThreadRow[] = []
for (const list of map.values()) {
const latest = list.reduce((a, b) => (b.timestamp > a.timestamp ? b : a))
const anyUnread = list.some((x) => !x.is_read)
rows.push({ ...latest, is_read: !anyUnread, threadCount: list.length })
}
rows.sort((a, b) => b.timestamp - a.timestamp)
return rows
}
function CategoryDot({
category,
onChange,
}: {
category: string | null
onChange: (category: string | null) => void
}) {
const [open, setOpen] = useState(false)
const hex = categoryHex(category)
return (
<Popover
open={open}
onOpenChange={setOpen}
align="end"
trigger={
<button
title="Kategorie"
className={clsx(
'flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded-full border transition',
hex ? 'border-transparent' : 'border-zinc-300 opacity-0 group-hover:opacity-100 dark:border-zinc-600',
)}
style={hex ? { backgroundColor: hex } : undefined}
/>
}
panelClassName="animate-panel-in flex items-center gap-1 rounded-full border border-zinc-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
{CATEGORY_COLORS.map((c) => (
<button
key={c.id}
title={c.label}
onClick={() => {
onChange(c.id === category ? null : c.id)
setOpen(false)
}}
className={clsx('h-4 w-4 rounded-full transition', c.id === category && 'ring-2 ring-offset-1 ring-zinc-400')}
style={{ backgroundColor: c.hex }}
/>
))}
<button
title="Keine"
onClick={() => {
onChange(null)
setOpen(false)
}}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full border border-zinc-300 text-zinc-400 dark:border-zinc-600"
>
<X size={9} />
</button>
</Popover>
)
}
function groupByDate(messages: ThreadRow[]) {
const groups: { label: string; items: ThreadRow[] }[] = []
for (const m of messages) {
const date = new Date(m.timestamp * 1000)
const label = !m.timestamp ? 'Unbekannt' : isToday(date) ? 'Heute' : isYesterday(date) ? 'Gestern' : 'Älter'
const last = groups[groups.length - 1]
if (last && last.label === label) {
last.items.push(m)
} else {
groups.push({ label, items: [m] })
}
}
return groups
}
export function MessageList({
messages,
selectedKeys,
onRowClick,
onToggleFlag,
onSetCategory,
onContextMenu,
onDragStartMessage,
loading,
searchQuery,
onSearchChange,
advancedSearch,
onAdvancedSearchChange,
compact,
groupConversations,
accounts,
sortBy,
sortDesc,
forceAccountBadge,
}: {
messages: MessageSummary[]
selectedKeys: Set<string>
onRowClick: (m: MessageSummary, index: number, e: React.MouseEvent) => void
onToggleFlag: (m: MessageSummary) => void
onSetCategory: (m: MessageSummary, category: string | null) => void
onContextMenu?: (e: React.MouseEvent, m: MessageSummary, index: number) => void
onDragStartMessage?: (m: MessageSummary, index: number) => void
loading: boolean
searchQuery: string
onSearchChange: (q: string) => void
advancedSearch: AdvancedSearchFilters
onAdvancedSearchChange: (f: AdvancedSearchFilters) => void
compact: boolean
groupConversations: boolean
accounts: Account[]
sortBy: SortBy
sortDesc: boolean
forceAccountBadge?: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const [advancedOpen, setAdvancedOpen] = useState(false)
const accountLabels = useMemo(() => new Map(accounts.map((a) => [a.id, a.label])), [accounts])
const showAccountBadge = forceAccountBadge || searchQuery.trim().length > 0
const rows = useMemo(() => {
const base = groupConversations ? groupConversationsFn(messages) : messages.map((m) => ({ ...m, threadCount: 1 }))
return sortRows(base, sortBy, sortDesc)
}, [messages, groupConversations, sortBy, sortDesc])
const groups = useMemo(
() => (sortBy === 'date' ? groupByDate(rows) : [{ label: '', items: rows }]),
[rows, sortBy],
)
function handleKeyDown(e: React.KeyboardEvent) {
if (rows.length === 0) return
const currentIndex = selectedKeys.size === 1 ? rows.findIndex((m) => selectedKeys.has(keyOf(m))) : -1
if (e.key === 'ArrowDown') {
e.preventDefault()
const idx = Math.min(currentIndex + 1, rows.length - 1)
onRowClick(rows[idx] ?? rows[0], idx, e as unknown as React.MouseEvent)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const idx = Math.max(currentIndex - 1, 0)
onRowClick(rows[idx] ?? rows[0], idx, e as unknown as React.MouseEvent)
} else if (e.key === 'Enter' && currentIndex === -1 && rows[0]) {
onRowClick(rows[0], 0, e as unknown as React.MouseEvent)
}
}
let runningIndex = -1
const hasAdvancedFilters = !!(
advancedSearch.from.trim() ||
advancedSearch.dateFrom ||
advancedSearch.dateTo ||
advancedSearch.hasAttachment
)
return (
<div className="flex w-96 shrink-0 flex-col border-r border-zinc-200 dark:border-zinc-800">
<div className="flex items-center gap-2 border-b border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800/80">
<Search size={14} className="shrink-0 text-zinc-400" />
<input
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Suchen…"
className="flex-1 bg-transparent text-[12.5px] text-zinc-700 outline-none placeholder:text-zinc-400 dark:text-zinc-200"
/>
{searchQuery && (
<button
onClick={() => onSearchChange('')}
className="rounded-full p-0.5 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<X size={13} />
</button>
)}
<Popover
open={advancedOpen}
onOpenChange={setAdvancedOpen}
align="end"
trigger={
<button
title="Erweiterte Suche"
className={clsx(
'relative rounded-full p-1 transition',
hasAdvancedFilters
? 'text-indigo-600 dark:text-indigo-400'
: 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800',
)}
>
<SlidersHorizontal size={13} />
{hasAdvancedFilters && (
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-indigo-500" />
)}
</button>
}
panelClassName="animate-panel-in w-64 space-y-2.5 rounded-2xl border border-zinc-200/80 bg-white/95 p-3.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Erweiterte Suche</p>
<label className="flex flex-col gap-1">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Absender</span>
<input
value={advancedSearch.from}
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, from: e.target.value })}
placeholder="name@example.com"
className="rounded-lg border border-zinc-200 bg-white px-2.5 py-1 text-[12px] text-zinc-800 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
/>
</label>
<div className="flex gap-2">
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Von</span>
<input
type="date"
value={advancedSearch.dateFrom}
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, dateFrom: e.target.value })}
className="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-[12px] text-zinc-800 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
/>
</label>
<label className="flex flex-1 flex-col gap-1">
<span className="text-[11px] text-zinc-500 dark:text-zinc-400">Bis</span>
<input
type="date"
value={advancedSearch.dateTo}
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, dateTo: e.target.value })}
className="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-[12px] text-zinc-800 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100"
/>
</label>
</div>
<label className="flex items-center gap-2 text-[12px] text-zinc-600 dark:text-zinc-300">
<input
type="checkbox"
checked={advancedSearch.hasAttachment}
onChange={(e) => onAdvancedSearchChange({ ...advancedSearch, hasAttachment: e.target.checked })}
className="rounded border-zinc-300"
/>
Nur mit Anhang
</label>
{hasAdvancedFilters && (
<button
onClick={() => onAdvancedSearchChange({ from: '', dateFrom: '', dateTo: '', hasAttachment: false })}
className="text-[11.5px] font-medium text-indigo-600 transition hover:text-indigo-700 dark:text-indigo-400"
>
Filter zurücksetzen
</button>
)}
</Popover>
</div>
{loading && rows.length === 0 ? (
<div className="flex flex-col gap-2 p-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-xl bg-zinc-100 dark:bg-zinc-900" />
))}
</div>
) : rows.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
<Inbox size={28} className="text-zinc-300 dark:text-zinc-700" />
<p className="text-sm text-zinc-400">
{searchQuery ? 'Keine Treffer' : 'Keine Nachrichten in diesem Ordner'}
</p>
</div>
) : (
<div
ref={containerRef}
tabIndex={0}
onKeyDown={handleKeyDown}
className="flex-1 overflow-y-auto outline-none"
>
{groups.map((group) => (
<div key={group.label || 'flat'}>
{group.label && (
<p className="sticky top-0 z-10 bg-zinc-50/90 px-4 py-1.5 text-[10.5px] font-semibold tracking-wide text-zinc-400 uppercase backdrop-blur dark:bg-zinc-950/90">
{group.label}
</p>
)}
{group.items.map((m) => {
runningIndex += 1
const index = runningIndex
const active = selectedKeys.has(keyOf(m))
return (
<div
key={keyOf(m)}
role="button"
tabIndex={-1}
onClick={(e) => onRowClick(m, index, e)}
onContextMenu={(e) => onContextMenu?.(e, m, index)}
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
onDragStartMessage?.(m, index)
}}
className={clsx(
'group relative flex w-full cursor-default items-start gap-3 border-b border-zinc-100 px-4 text-left transition dark:border-zinc-900',
compact ? 'py-1.5' : 'py-3',
active
? 'bg-indigo-50/80 dark:bg-indigo-500/10'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-900/60',
)}
>
{active && (
<span className="absolute inset-y-2 left-0 w-[3px] rounded-r-full bg-indigo-500" />
)}
<Avatar name={m.from_name || m.from_addr} size={compact ? 26 : 34} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span
className={clsx(
'truncate text-[13px]',
!m.is_read
? 'font-semibold text-zinc-900 dark:text-zinc-50'
: 'font-medium text-zinc-600 dark:text-zinc-400',
)}
>
{m.from_name || m.from_addr}
</span>
<span className="shrink-0 text-[11px] text-zinc-400">
{formatListDate(m.timestamp)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-1.5">
{!m.is_read && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-500" />
)}
<span
className={clsx(
'truncate text-[12.5px]',
!m.is_read
? 'font-medium text-zinc-800 dark:text-zinc-200'
: 'text-zinc-500 dark:text-zinc-500',
)}
>
{m.subject || '(kein Betreff)'}
</span>
{m.threadCount > 1 && (
<span className="shrink-0 rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold text-zinc-500 tabular-nums dark:bg-zinc-800 dark:text-zinc-400">
{m.threadCount}
</span>
)}
{m.has_attachments && (
<Paperclip size={11} className="shrink-0 text-zinc-400" />
)}
{showAccountBadge && (
<span className="shrink-0 truncate rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] font-medium text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
{accountLabels.get(m.account_id) ?? m.account_id}
</span>
)}
</div>
{!compact && m.snippet && (
<p className="mt-0.5 truncate text-[12px] text-zinc-400">{m.snippet}</p>
)}
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<button
onClick={(e) => {
e.stopPropagation()
onToggleFlag(m)
}}
title={m.flagged ? 'Markierung entfernen' : 'Markieren'}
className={clsx(
'rounded-full p-1 transition',
m.flagged
? 'text-amber-400'
: 'text-zinc-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 dark:text-zinc-700',
)}
>
<Star size={14} fill={m.flagged ? 'currentColor' : 'none'} />
</button>
<CategoryDot category={m.category} onChange={(c) => onSetCategory(m, c)} />
</div>
</div>
)
})}
</div>
))}
</div>
)}
</div>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from 'react'
import { Mail, X } from 'lucide-react'
export interface NewMailNotification {
accountId: string
accountLabel: string
folder: string
}
const AUTO_DISMISS_MS = 6000
export function NewMailPopup({
notification,
onOpen,
onDismiss,
}: {
notification: NewMailNotification | null
onOpen: () => void
onDismiss: () => void
}) {
useEffect(() => {
if (!notification) return
const t = setTimeout(onDismiss, AUTO_DISMISS_MS)
return () => clearTimeout(t)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [notification])
if (!notification) return null
return (
<div className="animate-toast-in fixed right-6 top-6 z-50 w-80 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 shadow-[0_24px_70px_-12px_rgba(15,15,25,0.35)] backdrop-blur-2xl dark:border-zinc-800 dark:bg-zinc-900/95 relative">
<button
onClick={onOpen}
className="flex w-full items-start gap-3 px-4 py-3.5 text-left transition hover:bg-zinc-50 dark:hover:bg-zinc-800/60"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-50 text-indigo-500 dark:bg-indigo-500/15 dark:text-indigo-300">
<Mail size={16} />
</div>
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-zinc-800 dark:text-zinc-100">Neue E-Mail</p>
<p className="truncate text-[12px] text-zinc-500 dark:text-zinc-400">{notification.accountLabel}</p>
</div>
</button>
<button
onClick={onDismiss}
title="Schließen"
className="absolute right-2 top-2 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<X size={13} />
</button>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
interface Pos {
top?: number
bottom?: number
left?: number
right?: number
}
/// Trigger + panel pair where the panel is portaled to document.body and
/// positioned via a fixed-position rect computed from the trigger element.
/// Needed because several triggers live inside scrollable/overflow-clipped
/// containers (ribbon body, message list, sidebar) — a plain `absolute`
/// child would get clipped by that ancestor instead of floating above it.
export function Popover({
open,
onOpenChange,
trigger,
children,
align = 'start',
side = 'bottom',
panelClassName,
}: {
open: boolean
onOpenChange: (v: boolean) => void
trigger: React.ReactNode
children: React.ReactNode
align?: 'start' | 'end'
side?: 'bottom' | 'top'
panelClassName?: string
}) {
const triggerRef = useRef<HTMLDivElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<Pos | null>(null)
function computePos() {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const next: Pos = {}
if (side === 'bottom') next.top = rect.bottom + 4
else next.bottom = window.innerHeight - rect.top + 4
if (align === 'start') next.left = rect.left
else next.right = window.innerWidth - rect.right
setPos(next)
}
useLayoutEffect(() => {
if (open) computePos()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, align, side])
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (triggerRef.current?.contains(target)) return
if (panelRef.current?.contains(target)) return
onOpenChange(false)
}
function onReposition() {
computePos()
}
document.addEventListener('mousedown', onClickOutside)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
return () => {
document.removeEventListener('mousedown', onClickOutside)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
return (
<>
<div
ref={triggerRef}
onClick={(e) => {
e.stopPropagation()
onOpenChange(!open)
}}
>
{trigger}
</div>
{open &&
pos &&
createPortal(
<div ref={panelRef} style={{ position: 'fixed', zIndex: 100, ...pos }} className={panelClassName}>
{children}
</div>,
document.body,
)}
</>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { Server } from 'lucide-react'
function IconShell({
children,
bg,
}: {
children: React.ReactNode
bg: string
}) {
return (
<div
className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-full shadow-inner"
style={{ background: bg }}
>
{children}
</div>
)
}
function GmailIcon() {
return (
<IconShell bg="#ffffff">
<svg viewBox="0 0 24 24" width="21" height="21">
<path d="M2 6.5A2.5 2.5 0 0 1 4.5 4h15A2.5 2.5 0 0 1 22 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-1V9.1l-6.2 4.9a1.8 1.8 0 0 1-2.2 0L3.9 9.1V20h-1A2.5 2.5 0 0 1 0 17.5v-11z" fill="none" />
<path d="M4.5 4h1.9l5.6 4.5L17.6 4h1.9A2.5 2.5 0 0 1 22 6.5v.4l-9.1 7.2a1.5 1.5 0 0 1-1.8 0L2 6.9v-.4A2.5 2.5 0 0 1 4.5 4z" fill="#ea4335" />
<path d="M2 6.9l7.4 5.9v7.1H4.5A2.5 2.5 0 0 1 2 17.4V6.9z" fill="#4285f4" opacity="0.001" />
<path d="M2 7.3l7.7 6.1v6.5H4.5A2.5 2.5 0 0 1 2 17.4V7.3z" fill="#34a853" />
<path d="M22 7.3v10.1a2.5 2.5 0 0 1-2.5 2.5h-5.2v-6.5L22 7.3z" fill="#4285f4" />
<path d="M2 6.9v.4l7.7 6.1V7.9L6.4 4H4.5A2.5 2.5 0 0 0 2 6.5v.4z" fill="#fbbc04" />
<path d="M22 6.9v.4l-7.7 6.1V7.9L17.6 4h1.9A2.5 2.5 0 0 1 22 6.5v.4z" fill="#ea4335" />
</svg>
</IconShell>
)
}
function OutlookIcon() {
return (
<IconShell bg="linear-gradient(135deg,#0a2767,#1a5cd6)">
<svg viewBox="0 0 24 24" width="19" height="19">
<rect x="12.2" y="5" width="9.3" height="8.4" rx="1" fill="#28a8ea" />
<rect x="12.2" y="5" width="9.3" height="8.4" rx="1" fill="#0364b8" opacity="0.001" />
<path d="M12.2 5h9.3v8.4h-9.3z" fill="#28a8ea" />
<path d="M21.5 13.4V19a1 1 0 0 1-1 1h-8.3v-6.6z" fill="#0078d4" opacity=".001" />
<circle cx="6.3" cy="12" r="5.3" fill="#0364b8" />
<path
d="M6.3 8.6c-1.7 0-3 1.5-3 3.4s1.3 3.4 3 3.4 3-1.5 3-3.4-1.3-3.4-3-3.4zm0 5.3c-.9 0-1.6-.9-1.6-1.9s.7-1.9 1.6-1.9 1.6.9 1.6 1.9-.7 1.9-1.6 1.9z"
fill="#fff"
/>
</svg>
</IconShell>
)
}
function YahooIcon() {
return (
<IconShell bg="#5f01d1">
<span className="text-[13px] font-black tracking-tighter text-white">y!</span>
</IconShell>
)
}
function ICloudIcon() {
return (
<IconShell bg="linear-gradient(135deg,#e6f4ff,#cfeaff)">
<svg viewBox="0 0 24 24" width="21" height="21">
<path
d="M7.5 17.5a4 4 0 0 1-.4-7.98 5 5 0 0 1 9.62-1.9 3.8 3.8 0 0 1 3.78 3.8c0 2.1-1.7 3.8-3.8 3.8V17.5z"
fill="#3693f3"
/>
<rect x="3.1" y="14.8" width="14.8" height="2.7" rx="1.35" fill="#3693f3" />
</svg>
</IconShell>
)
}
function CustomIcon() {
return (
<IconShell bg="linear-gradient(135deg,#71717a,#52525b)">
<Server size={16} className="text-white" />
</IconShell>
)
}
export function ProviderIcon({ id }: { id: string }) {
switch (id) {
case 'gmail':
return <GmailIcon />
case 'outlook':
return <OutlookIcon />
case 'yahoo':
return <YahooIcon />
case 'icloud':
return <ICloudIcon />
default:
return <CustomIcon />
}
}
+306
View File
@@ -0,0 +1,306 @@
import { useEffect, useMemo, useState } from 'react'
import { save } from '@tauri-apps/plugin-dialog'
import {
CalendarCheck,
CalendarClock,
CalendarPlus,
CalendarX,
CheckSquare,
Download,
ListPlus,
Mail,
Paperclip,
} from 'lucide-react'
import type { AttachmentMeta, InviteInfo, InviteResponse, MessageFull } from '../types'
import { Avatar } from './Avatar'
import { api } from '../lib/api'
import { formatBytes, formatFullDate } from '../lib/format'
import { AttachmentPreviewModal, isPreviewable } from './AttachmentPreviewModal'
function isIcsAttachment(att: AttachmentMeta) {
return att.mime_type.toLowerCase() === 'text/calendar' || att.filename.toLowerCase().endsWith('.ics')
}
function InviteBanner({
info,
onRespond,
}: {
info: InviteInfo
onRespond: (response: InviteResponse) => Promise<void>
}) {
const [responding, setResponding] = useState<InviteResponse | null>(null)
const [responded, setResponded] = useState<InviteResponse | null>(null)
async function respond(r: InviteResponse) {
setResponding(r)
try {
await onRespond(r)
setResponded(r)
} finally {
setResponding(null)
}
}
const dateLabel = `${new Date(info.start_ts * 1000).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}${
info.all_day ? '' : `, ${new Date(info.start_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ${new Date(info.end_ts * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
}`
return (
<div className="border-b border-indigo-100 bg-indigo-50/60 px-7 py-3.5 dark:border-indigo-950 dark:bg-indigo-950/30">
<p className="text-[12.5px] font-medium text-indigo-800 dark:text-indigo-200">
Termineinladung: {info.summary}
</p>
<p className="text-[11.5px] text-indigo-500 dark:text-indigo-400">
{dateLabel}
{info.organizer_email && ` · von ${info.organizer_name || info.organizer_email}`}
</p>
{responded ? (
<p className="mt-2 text-[12px] font-medium text-indigo-700 dark:text-indigo-300">
{responded === 'accepted' && 'Angenommen — Antwort wurde gesendet.'}
{responded === 'declined' && 'Abgelehnt — Antwort wurde gesendet.'}
{responded === 'tentative' && 'Mit Vorbehalt beantwortet — Antwort wurde gesendet.'}
</p>
) : (
<div className="mt-2 flex gap-2">
<button
onClick={() => respond('accepted')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-green-600 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-green-500 disabled:opacity-60"
>
<CalendarCheck size={13} /> {responding === 'accepted' ? 'Sende…' : 'Annehmen'}
</button>
<button
onClick={() => respond('tentative')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-amber-500 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-amber-400 disabled:opacity-60"
>
<CalendarClock size={13} /> {responding === 'tentative' ? 'Sende…' : 'Vorbehalt'}
</button>
<button
onClick={() => respond('declined')}
disabled={responding !== null}
className="flex items-center gap-1.5 rounded-full bg-zinc-500 px-3 py-1.5 text-[12px] font-medium text-white shadow-sm transition hover:bg-zinc-400 disabled:opacity-60"
>
<CalendarX size={13} /> {responding === 'declined' ? 'Sende…' : 'Ablehnen'}
</button>
</div>
)}
</div>
)
}
function downloadAttachment(filename: string, mimeType: string, base64: string) {
const byteChars = atob(base64)
const bytes = new Uint8Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) bytes[i] = byteChars.charCodeAt(i)
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
export function ReadingPane({
message,
loading,
selectionCount = 0,
onImportIcsAttachment,
onCreateTask,
onReplyToInvite,
}: {
message: MessageFull | null
loading: boolean
selectionCount?: number
onImportIcsAttachment?: (base64: string) => Promise<void>
onCreateTask?: (message: MessageFull) => void
onReplyToInvite?: (accountId: string, icsBase64: string, response: InviteResponse) => Promise<void>
}) {
const [importingIcs, setImportingIcs] = useState<number | null>(null)
const [previewAttachment, setPreviewAttachment] = useState<AttachmentMeta | null>(null)
const [invite, setInvite] = useState<{ base64: string; info: InviteInfo } | null>(null)
useEffect(() => {
setInvite(null)
if (!message || !onReplyToInvite) return
const icsAtt = message.attachments.find(isIcsAttachment)
if (!icsAtt) return
let cancelled = false
api
.previewInvite(icsAtt.content_base64)
.then((info) => {
if (!cancelled && info) setInvite({ base64: icsAtt.content_base64, info })
})
.catch(() => {})
return () => {
cancelled = true
}
}, [message, onReplyToInvite])
const srcDoc = useMemo(() => {
if (!message) return ''
if (message.body_html) {
return `<!doctype html><html><head><meta charset="utf-8"><base target="_blank"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.55;color:#18181b;margin:0;padding:0;word-wrap:break-word;}
img{max-width:100%;height:auto;}
a{color:#4f46e5;}
@media (prefers-color-scheme: dark){ body{color:#e4e4e7;} a{color:#a5b4fc;} }
</style></head><body>${message.body_html}</body></html>`
}
const escaped = (message.body_text || '').replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
return `<!doctype html><html><head><meta charset="utf-8"><style>
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:14px;line-height:1.55;color:#18181b;margin:0;white-space:pre-wrap;word-wrap:break-word;}
@media (prefers-color-scheme: dark){ body{color:#e4e4e7;} }
</style></head><body>${escaped}</body></html>`
}, [message])
async function handleExportEml(m: MessageFull) {
const suggested = `${m.subject.replace(/[\\/:*?"<>|]/g, '_').slice(0, 60) || 'nachricht'}.eml`
const path = await save({ defaultPath: suggested, filters: [{ name: 'E-Mail', extensions: ['eml'] }] })
if (!path) return
await api.exportMessageEml(m.account_id, m.folder, m.uid, path)
}
if (loading) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-zinc-400">
Lade Nachricht
</div>
)
}
if (selectionCount > 1) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-indigo-50 text-indigo-400 dark:bg-indigo-500/10 dark:text-indigo-300">
<CheckSquare size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">{selectionCount} Nachrichten ausgewählt</p>
</div>
)
}
if (!message) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 text-zinc-300 dark:bg-zinc-900 dark:text-zinc-700">
<Mail size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">Wähle eine Nachricht aus</p>
</div>
)
}
return (
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start justify-between gap-4 border-b border-zinc-200/80 px-7 py-5 dark:border-zinc-800/80">
<div className="min-w-0">
<h1 className="truncate text-[18px] font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{message.subject || '(kein Betreff)'}
</h1>
<div className="mt-2.5 flex items-center gap-3">
<Avatar name={message.from_name || message.from_addr} size={38} />
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-200">
{message.from_name || message.from_addr}
<span className="ml-1.5 font-normal text-zinc-400">&lt;{message.from_addr}&gt;</span>
</p>
<p className="truncate text-[12px] text-zinc-400">
an {message.to || 'mich'} · {formatFullDate(message.date)}
</p>
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5">
{onCreateTask && (
<button
onClick={() => onCreateTask(message)}
title="Als Aufgabe markieren"
className="rounded-full border border-zinc-200 p-1.5 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<ListPlus size={14} />
</button>
)}
<button
onClick={() => handleExportEml(message)}
title="Als .eml exportieren"
className="rounded-full border border-zinc-200 p-1.5 text-zinc-500 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:text-zinc-400 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<Download size={14} />
</button>
</div>
</div>
{invite && onReplyToInvite && (
<InviteBanner
info={invite.info}
onRespond={(response) => onReplyToInvite(message.account_id, invite.base64, response)}
/>
)}
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 border-b border-zinc-200/80 px-7 py-3.5 dark:border-zinc-800/80">
{message.attachments.map((att, i) => (
<div key={i} className="flex items-center gap-1">
<button
onClick={() =>
isPreviewable(att) ? setPreviewAttachment(att) : downloadAttachment(att.filename, att.mime_type, att.content_base64)
}
className="flex items-center gap-2 rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1.5 text-[12px] text-zinc-600 shadow-sm transition hover:border-indigo-200 hover:bg-indigo-50 hover:text-indigo-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
>
<Paperclip size={12} />
<span className="max-w-40 truncate">{att.filename}</span>
<span className="text-zinc-400">{formatBytes(att.size)}</span>
{isPreviewable(att) ? (
<span
onClick={(e) => {
e.stopPropagation()
downloadAttachment(att.filename, att.mime_type, att.content_base64)
}}
title="Herunterladen"
className="text-zinc-400 hover:text-indigo-600"
>
<Download size={11} />
</span>
) : (
<Download size={11} className="text-zinc-400" />
)}
</button>
{isIcsAttachment(att) && onImportIcsAttachment && (
<button
onClick={async () => {
setImportingIcs(i)
try {
await onImportIcsAttachment(att.content_base64)
} finally {
setImportingIcs(null)
}
}}
disabled={importingIcs === i}
title="Zum Kalender hinzufügen"
className="flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-[12px] font-medium text-indigo-700 shadow-sm transition hover:bg-indigo-100 disabled:opacity-60 dark:border-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-300"
>
<CalendarPlus size={12} />
{importingIcs === i ? 'Füge hinzu…' : 'Zum Kalender'}
</button>
)}
</div>
))}
</div>
)}
<iframe
title="message-body"
srcDoc={srcDoc}
sandbox=""
className="w-full flex-1 bg-white dark:bg-zinc-950"
/>
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
onDownload={(att) => downloadAttachment(att.filename, att.mime_type, att.content_base64)}
/>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react'
import type { Contact } from '../types'
import { Avatar } from './Avatar'
import { api } from '../lib/api'
export function RecipientInput({
value,
onChange,
placeholder,
}: {
value: string
onChange: (v: string) => void
placeholder?: string
}) {
const [suggestions, setSuggestions] = useState<Contact[]>([])
const [open, setOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const token = value.split(',').pop()?.trim() ?? ''
if (token.length < 2) {
setSuggestions([])
return
}
const t = setTimeout(() => {
api
.searchContacts(token)
.then((res) => {
const withEmail = res.filter((c) => !!c.email)
setSuggestions(withEmail)
setOpen(withEmail.length > 0)
})
.catch(() => {})
}, 150)
return () => clearTimeout(t)
}, [value])
useEffect(() => {
if (!open) return
function onClickOutside(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', onClickOutside)
return () => document.removeEventListener('mousedown', onClickOutside)
}, [open])
function pick(c: Contact) {
const parts = value.split(',')
parts[parts.length - 1] = ` ${c.name ? `${c.name} <${c.email}>` : c.email}`
onChange(
parts
.join(',')
.replace(/^,\s*/, '')
.trimStart() + ', ',
)
setOpen(false)
inputRef.current?.focus()
}
return (
<div ref={wrapRef} className="relative flex-1">
<input
ref={inputRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => suggestions.length > 0 && setOpen(true)}
placeholder={placeholder}
className="w-full bg-transparent text-[13px] text-zinc-800 outline-none placeholder:text-zinc-300 dark:text-zinc-100"
/>
{open && (
<div className="animate-panel-in absolute top-full left-0 right-0 z-20 mt-1.5 max-h-52 overflow-y-auto rounded-2xl border border-zinc-200/80 bg-white/95 py-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95">
{suggestions.map((c) => (
<button
key={c.id}
type="button"
onClick={() => pick(c)}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left transition hover:bg-indigo-50 dark:hover:bg-indigo-500/10"
>
<Avatar name={c.name || c.email || '?'} size={24} />
<div className="min-w-0">
<p className="truncate text-[12.5px] text-zinc-700 dark:text-zinc-200">{c.name || c.email}</p>
<p className="truncate text-[11px] text-zinc-400">{c.email}</p>
</div>
</button>
))}
</div>
)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { CalendarDays, Repeat, X } from 'lucide-react'
export function RecurrenceScopeModal({
open,
title,
onChoose,
onCancel,
}: {
open: boolean
title: string
onChoose: (scope: 'occurrence' | 'series') => void
onCancel: () => void
}) {
if (!open) return null
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"
onClick={onCancel}
>
<div
onClick={(e) => e.stopPropagation()}
className="animate-panel-in w-full max-w-sm 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"
>
<div className="flex items-center justify-between border-b border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">{title}</h2>
<button
onClick={onCancel}
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="space-y-2 p-4">
<button
onClick={() => onChoose('occurrence')}
className="flex w-full items-center gap-3 rounded-2xl border border-zinc-200/80 px-4 py-3 text-left transition hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-zinc-800 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<CalendarDays size={17} className="shrink-0 text-indigo-500" />
<span className="text-[13px] font-medium text-zinc-700 dark:text-zinc-200">Nur diesen Termin</span>
</button>
<button
onClick={() => onChoose('series')}
className="flex w-full items-center gap-3 rounded-2xl border border-zinc-200/80 px-4 py-3 text-left transition hover:border-indigo-300 hover:bg-indigo-50/60 dark:border-zinc-800 dark:hover:border-indigo-800 dark:hover:bg-indigo-500/10"
>
<Repeat size={17} className="shrink-0 text-indigo-500" />
<span className="text-[13px] font-medium text-zinc-700 dark:text-zinc-200">Die ganze Serie</span>
</button>
</div>
</div>
</div>
)
}
+564
View File
@@ -0,0 +1,564 @@
import { useState } from 'react'
import {
AlignJustify,
ArrowDownAZ,
ArrowUpDown,
Archive,
Bell,
BellRing,
BookUser,
CalendarDays,
CalendarPlus,
Download,
FilePlus2,
Filter,
FolderCog,
FolderInput,
FolderPlus,
FolderX,
Forward,
Globe,
Keyboard,
Info,
ListFilter,
ListPlus,
MailCheck,
MailOpen,
MailPlus,
MailSearch,
MessagesSquare,
Monitor,
Moon,
Pencil,
RefreshCcw,
Reply,
ReplyAll,
RefreshCw,
RotateCcw,
Rows3,
Rows4,
Settings,
ShieldOff,
Star,
Sun,
Tags,
Trash2,
Upload,
UserPlus,
Volume2,
WifiOff,
} from 'lucide-react'
import clsx from 'clsx'
import type { Account, Event as CalendarEvent, Folder, MessageFull, MessageSummary } from '../types'
import type { SidebarView } from './Sidebar'
import type { Density, Theme } from '../lib/theme'
import { CATEGORY_COLORS } from '../lib/categories'
import { api } from '../lib/api'
import { ClassicButton, ClassicDropdown, ClassicMenuItem, ClassicRibbonBody, ClassicRibbonGroup, ClassicTabBar } from './ClassicRibbonUI'
import { GoogleCalendarMenu } from './GoogleCalendarMenu'
type RibbonTab = 'start' | 'sendrecv' | 'folder' | 'view' | 'help'
export type SortBy = 'date' | 'from' | 'subject'
export type EmailFilter = 'none' | 'unread' | 'flagged' | 'attachments'
function MoveMenu({
folders,
currentFolder,
onMove,
disabled,
}: {
folders: Folder[]
currentFolder: string | null
onMove: (target: string) => void
disabled: boolean
}) {
const [open, setOpen] = useState(false)
const targets = folders.filter((f) => f.name !== currentFolder)
return (
<ClassicDropdown
open={open}
onOpenChange={setOpen}
button={<ClassicButton icon={FolderInput} label="Verschieben" disabled={disabled || targets.length === 0} />}
>
{targets.map((f) => (
<ClassicMenuItem
key={f.name}
onClick={() => {
onMove(f.name)
setOpen(false)
}}
>
{f.display_name}
</ClassicMenuItem>
))}
</ClassicDropdown>
)
}
function CategoryMenu({ onSet, disabled }: { onSet: (c: string | null) => void; disabled: boolean }) {
const [open, setOpen] = useState(false)
return (
<ClassicDropdown
open={open}
onOpenChange={setOpen}
button={<ClassicButton icon={Tags} label="Kategorisieren" disabled={disabled} />}
>
{CATEGORY_COLORS.map((c) => (
<ClassicMenuItem
key={c.id}
onClick={() => {
onSet(c.id)
setOpen(false)
}}
>
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: c.hex }} />
{c.label}
</ClassicMenuItem>
))}
<ClassicMenuItem
onClick={() => {
onSet(null)
setOpen(false)
}}
>
Keine Kategorie
</ClassicMenuItem>
</ClassicDropdown>
)
}
function NewItemMenu({ onEmail, onEvent, onContact }: { onEmail: () => void; onEvent: () => void; onContact: () => void }) {
const [open, setOpen] = useState(false)
return (
<ClassicDropdown
open={open}
onOpenChange={setOpen}
button={<ClassicButton icon={FilePlus2} label="Neue Elemente" />}
>
<ClassicMenuItem onClick={() => { onEmail(); setOpen(false) }}>
<MailPlus size={13} /> E-Mail-Nachricht
</ClassicMenuItem>
<ClassicMenuItem onClick={() => { onEvent(); setOpen(false) }}>
<CalendarPlus size={13} /> Termin
</ClassicMenuItem>
<ClassicMenuItem onClick={() => { onContact(); setOpen(false) }}>
<UserPlus size={13} /> Kontakt
</ClassicMenuItem>
</ClassicDropdown>
)
}
function FilterMenu({ value, onChange }: { value: EmailFilter; onChange: (f: EmailFilter) => void }) {
const [open, setOpen] = useState(false)
const labels: Record<EmailFilter, string> = { none: 'Kein Filter', unread: 'Ungelesen', flagged: 'Markiert', attachments: 'Hat Anhänge' }
return (
<ClassicDropdown open={open} onOpenChange={setOpen} button={<ClassicButton icon={ListFilter} label="E-Mail filtern" active={value !== 'none'} />}>
{(Object.keys(labels) as EmailFilter[]).map((k) => (
<ClassicMenuItem key={k} onClick={() => { onChange(k); setOpen(false) }}>
{value === k ? '✓ ' : ''}{labels[k]}
</ClassicMenuItem>
))}
</ClassicDropdown>
)
}
function HelpMenu({ onShowShortcuts }: { onShowShortcuts: () => void }) {
return <ClassicButton icon={Keyboard} label="Tastenkürzel" onClick={onShowShortcuts} />
}
function AboutMenu() {
const [open, setOpen] = useState(false)
return (
<ClassicDropdown open={open} onOpenChange={setOpen} widthClass="w-72" button={<ClassicButton icon={Info} label="Über" />}>
<div className="px-3 py-2 text-[12.5px] text-zinc-600 dark:text-zinc-300">
<p className="mb-1 font-semibold text-zinc-800 dark:text-zinc-100">Mail Client</p>
<p>Ein nativer Desktop-Mail-Client mit eigenem IMAP-, POP3- und SMTP-Backend.</p>
</div>
</ClassicDropdown>
)
}
function RemindersMenu() {
const [open, setOpen] = useState(false)
const [items, setItems] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(false)
function load() {
setLoading(true)
api.listUpcomingReminders(24).then(setItems).catch(() => {}).finally(() => setLoading(false))
}
return (
<ClassicDropdown
open={open}
onOpenChange={(v) => { setOpen(v); if (v) load() }}
widthClass="w-72"
button={<ClassicButton icon={Bell} label="Erinnerungsfenster" />}
>
<p className="px-3 pb-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Nächste 24 Stunden</p>
{loading ? (
<p className="px-3 py-2 text-[12px] text-zinc-400">Lade</p>
) : items.length === 0 ? (
<p className="px-3 py-2 text-[12px] text-zinc-400">Keine anstehenden Erinnerungen.</p>
) : (
items.map((e) => (
<div key={e.id} className="px-3 py-1.5 text-[12.5px] text-zinc-700 dark:text-zinc-200">
<p className="font-medium">{e.title}</p>
<p className="text-[11px] text-zinc-400">{new Date(e.start_ts * 1000).toLocaleString('de-DE')}</p>
</div>
))
)}
</ClassicDropdown>
)
}
export function Ribbon({
view,
onChangeView,
selectedMessage,
selectedSummary,
selectionCount = 0,
folders,
currentAccountId,
accounts,
onCompose,
composeDisabled,
onReply,
onReplyAll,
onForward,
onDelete,
onArchive,
onMove,
onToggleRead,
onMarkDone,
onToggleFlag,
onSetCategory,
onRefresh,
onRefreshAll,
refreshing,
offlineMode,
onToggleOffline,
emailFilter,
onEmailFilterChange,
onAddContact,
onNewFolder,
onRenameFolder,
onDeleteFolder,
onMarkFolderRead,
onApplyRulesToFolder,
onManageRules,
onManageBlocked,
onShowSnoozed,
onShowShortcuts,
onSortFoldersAZ,
onAddFavorite,
onNewSearchFolder,
groupConversations,
onGroupConversationsChange,
density,
onDensityChange,
sidebarCollapsed,
onToggleSidebar,
sortBy,
sortDesc,
onSortByChange,
onToggleSortDesc,
onResetView,
onNewTask,
onNewEvent,
onGoToToday,
onImportIcs,
onExportIcs,
googleAccounts,
onSyncGoogleCalendar,
onReconnectGoogleAccount,
onOpenCaldav,
theme,
onThemeChange,
onOpenSettings,
}: {
view: SidebarView
onChangeView: (v: SidebarView) => void
selectedMessage: MessageFull | null
selectedSummary: MessageSummary | null
selectionCount?: number
folders: Folder[]
currentAccountId: string | null
accounts: Account[]
onCompose: () => void
composeDisabled: boolean
onReply: () => void
onReplyAll: () => void
onForward: () => void
onDelete: () => void
onArchive: () => void
onMove: (target: string) => void
onToggleRead: () => void
onMarkDone: () => void
onToggleFlag: () => void
onSetCategory: (c: string | null) => void
onRefresh: () => void
onRefreshAll: () => void
refreshing: boolean
offlineMode: boolean
onToggleOffline: () => void
emailFilter: EmailFilter
onEmailFilterChange: (f: EmailFilter) => void
onAddContact: () => void
onNewFolder: () => void
onRenameFolder: () => void
onDeleteFolder: () => void
onMarkFolderRead: () => void
onApplyRulesToFolder: () => void
onManageRules: () => void
onManageBlocked: () => void
onShowSnoozed: () => void
onShowShortcuts: () => void
onSortFoldersAZ: () => void
onAddFavorite: () => void
onNewSearchFolder: () => void
groupConversations: boolean
onGroupConversationsChange: (v: boolean) => void
density: Density
onDensityChange: (d: Density) => void
sidebarCollapsed: boolean
onToggleSidebar: () => void
sortBy: SortBy
sortDesc: boolean
onSortByChange: (s: SortBy) => void
onToggleSortDesc: () => void
onResetView: () => void
onNewTask?: () => void
onNewEvent?: () => void
onGoToToday?: () => void
onImportIcs?: () => void
onExportIcs?: () => void
googleAccounts?: Account[]
onSyncGoogleCalendar?: (accountId: string) => Promise<void>
onReconnectGoogleAccount?: (accountId: string) => Promise<void>
onOpenCaldav?: () => void
theme: Theme
onThemeChange: (t: Theme) => void
onOpenSettings: () => void
}) {
const [tab, setTab] = useState<RibbonTab>('start')
const hasSingle = !!selectedMessage
const hasSelection = hasSingle || selectionCount > 1
const account = accounts.find((a) => a.id === currentAccountId) ?? null
const isLocal = account?.protocol === 'local'
function readAloud() {
if (!selectedMessage) return
const text = selectedMessage.body_text || (selectedMessage.body_html ?? '').replace(/<[^>]+>/g, ' ')
if (!('speechSynthesis' in window) || !text.trim()) return
window.speechSynthesis.cancel()
const utter = new SpeechSynthesisUtterance(`${selectedMessage.subject}. ${text}`)
utter.lang = 'de-DE'
window.speechSynthesis.speak(utter)
}
const tabs = [
{ id: 'start', label: 'Start' },
{ id: 'sendrecv', label: 'Senden/Empfangen' },
{ id: 'folder', label: 'Ordner' },
{ id: 'view', label: 'Ansicht' },
{ id: 'help', label: 'Hilfe' },
]
return (
<div className="w-full border-b border-[#0d3a6e] shadow-sm">
<ClassicTabBar
tabs={view === 'mail' ? tabs : tabs.filter((t) => t.id !== 'sendrecv' && t.id !== 'folder')}
active={tab}
onChange={(id) => setTab(id as RibbonTab)}
rightSlot={
<button
onClick={onOpenSettings}
title="Einstellungen"
className="rounded-full p-1.5 text-white/80 transition hover:bg-white/10 hover:text-white"
>
<Settings size={15} />
</button>
}
/>
<ClassicRibbonBody>
{tab === 'start' && view === 'mail' && (
<>
<ClassicRibbonGroup caption="Neu">
<ClassicButton icon={MailPlus} label="Neue E-Mail" onClick={onCompose} disabled={composeDisabled} />
<NewItemMenu onEmail={onCompose} onEvent={() => { onChangeView('calendar'); onNewEvent?.() }} onContact={onAddContact} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Löschen">
<ClassicButton icon={Trash2} label={selectionCount > 1 ? `Löschen (${selectionCount})` : 'Löschen'} onClick={onDelete} disabled={!hasSelection} />
<ClassicButton icon={Archive} label="Archivieren" onClick={onArchive} disabled={!hasSelection || isLocal} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Antworten">
<ClassicButton icon={Reply} label="Antworten" onClick={onReply} disabled={!hasSingle} />
<ClassicButton icon={ReplyAll} label="Allen antw." onClick={onReplyAll} disabled={!hasSingle} />
<ClassicButton icon={Forward} label="Weiterleiten" onClick={onForward} disabled={!hasSingle} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="QuickSteps">
<MoveMenu folders={folders} currentFolder={selectionCount > 1 ? null : selectedMessage?.folder ?? null} onMove={onMove} disabled={!hasSelection} />
<ClassicButton icon={MailCheck} label="Erledigt" onClick={onMarkDone} disabled={!hasSelection} title="Als gelesen markieren und Kennzeichnung entfernen" />
<ClassicButton icon={Pencil} label="Neu erstellen" disabled title="Individuelle QuickSteps: in dieser Version nicht verfügbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Verschieben">
<MoveMenu folders={folders} currentFolder={selectionCount > 1 ? null : selectedMessage?.folder ?? null} onMove={onMove} disabled={!hasSelection} />
<ClassicButton icon={Filter} label="Regeln" onClick={onManageRules} />
<ClassicButton icon={ShieldOff} label="Blockiert" onClick={onManageBlocked} />
<ClassicButton icon={BellRing} label="Wiedervorlage" onClick={onShowSnoozed} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Kategorien">
<CategoryMenu onSet={onSetCategory} disabled={!selectedSummary && selectionCount <= 1} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Suchen">
<ClassicButton icon={BookUser} label="Adressbuch" onClick={() => onChangeView('contacts')} />
<FilterMenu value={emailFilter} onChange={onEmailFilterChange} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Sprache">
<ClassicButton icon={Volume2} label="Laut vorlesen" onClick={readAloud} disabled={!hasSingle} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Kennzeichnen">
<ClassicButton icon={MailOpen} label="Ungelesen" onClick={onToggleRead} disabled={!hasSelection} />
<ClassicButton icon={Star} label="Markieren" onClick={onToggleFlag} disabled={!hasSelection} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Add-Ins">
<ClassicButton icon={FolderCog} label="Add-Ins abrufen" disabled title="Add-In-Marktplatz: in dieser Version nicht verfügbar" />
</ClassicRibbonGroup>
</>
)}
{tab === 'start' && view === 'contacts' && (
<ClassicRibbonGroup caption="Neu">
<ClassicButton icon={UserPlus} label="Neuer Kontakt" onClick={onAddContact} />
</ClassicRibbonGroup>
)}
{tab === 'start' && view === 'calendar' && (
<>
<ClassicRibbonGroup caption="Neu">
<ClassicButton icon={CalendarPlus} label="Neuer Termin" onClick={() => onNewEvent?.()} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Ansicht">
<ClassicButton icon={CalendarDays} label="Heute" onClick={() => onGoToToday?.()} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Austausch">
<ClassicButton icon={Upload} label="Importieren (.ics)" onClick={() => onImportIcs?.()} />
<ClassicButton icon={Download} label="Exportieren (.ics)" onClick={() => onExportIcs?.()} />
</ClassicRibbonGroup>
{googleAccounts && googleAccounts.length > 0 && onSyncGoogleCalendar && onReconnectGoogleAccount && (
<ClassicRibbonGroup caption="Google">
<GoogleCalendarMenu accounts={googleAccounts} onSync={onSyncGoogleCalendar} onReconnect={onReconnectGoogleAccount} />
</ClassicRibbonGroup>
)}
{onOpenCaldav && (
<ClassicRibbonGroup caption="Andere Kalender">
<ClassicButton icon={Globe} label="CalDAV" onClick={onOpenCaldav} />
</ClassicRibbonGroup>
)}
</>
)}
{tab === 'start' && view === 'tasks' && (
<ClassicRibbonGroup caption="Neu">
<ClassicButton icon={ListPlus} label="Neue Aufgabe" onClick={() => onNewTask?.()} />
</ClassicRibbonGroup>
)}
{tab === 'sendrecv' && (
<>
<ClassicRibbonGroup caption="Senden und Empfangen">
<ClassicButton icon={RefreshCcw} label="Alle Ordner senden/empfangen" onClick={onRefreshAll} disabled={refreshing} />
<ClassicButton icon={RefreshCw} label="Ordner aktualisieren" onClick={onRefresh} disabled={refreshing || !currentAccountId} />
<ClassicButton icon={Upload} label="Alle senden" disabled title="Kein Offline-Postausgang in dieser Version" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Download">
<ClassicButton icon={Info} label="Status anzeigen" disabled title="Nicht verfügbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Server">
<ClassicButton icon={Download} label="Kopfzeilen herunterladen" disabled title="Nicht verfügbar für dieses Konto" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Einstellungen">
<ClassicButton icon={FolderCog} label="Downloadeinstellungen" disabled title="Nicht verfügbar" />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Offline">
<ClassicButton icon={WifiOff} label="Offline arbeiten" onClick={onToggleOffline} active={offlineMode} />
</ClassicRibbonGroup>
</>
)}
{tab === 'folder' && (
<>
<ClassicRibbonGroup caption="Neu">
<ClassicButton icon={FolderPlus} label="Neuer Ordner" onClick={onNewFolder} disabled={!currentAccountId || isLocal} />
<ClassicButton icon={MailSearch} label="Neuer Suchordner" onClick={onNewSearchFolder} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Aktionen">
<ClassicButton icon={Pencil} label="Ordner umbenennen" onClick={onRenameFolder} disabled={!currentAccountId || isLocal} />
<ClassicButton icon={FolderX} label="Ordner löschen" onClick={onDeleteFolder} disabled={!currentAccountId || isLocal} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Aufräumen">
<ClassicButton icon={MailCheck} label="Alles als gelesen markieren" onClick={onMarkFolderRead} disabled={!currentAccountId} />
<ClassicButton icon={Filter} label="Regeln jetzt anwenden" onClick={onApplyRulesToFolder} disabled={!currentAccountId || isLocal} />
<ClassicButton icon={ArrowDownAZ} label="Alle Ordner von A bis Z" onClick={onSortFoldersAZ} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Favoriten">
<ClassicButton icon={Star} label="Zu Favoriten hinzufügen" onClick={onAddFavorite} disabled={!currentAccountId} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Eigenschaften">
<ClassicButton icon={FolderCog} label="Ordnereigenschaften" disabled title="Nicht verfügbar" />
</ClassicRibbonGroup>
</>
)}
{tab === 'view' && (
<>
<ClassicRibbonGroup caption="Aktuelle Ansicht">
<ClassicButton icon={RotateCcw} label="Ansicht zurücksetzen" onClick={onResetView} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Nachrichten">
<ClassicButton icon={MessagesSquare} label="Als Unterhaltungen anzeigen" onClick={() => onGroupConversationsChange(!groupConversations)} active={groupConversations} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Anordnung">
<div className="flex flex-col gap-0.5 pt-1">
{(['date', 'from', 'subject'] as SortBy[]).map((s) => (
<button
key={s}
onClick={() => onSortByChange(s)}
className={clsx(
'rounded-sm px-1.5 py-0.5 text-left text-[11.5px]',
sortBy === s ? 'bg-[#cce4f7] dark:bg-blue-500/20' : 'hover:bg-[#e6f2fb] dark:hover:bg-zinc-700/60',
)}
>
{s === 'date' ? 'Datum' : s === 'from' ? 'Von' : 'Betreff'}
</button>
))}
</div>
<ClassicButton icon={ArrowUpDown} label="Sortierreihenfolge umkehren" onClick={onToggleSortDesc} active={!sortDesc} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Layout">
<ClassicButton icon={Rows4} label="Normal" onClick={() => onDensityChange('comfortable')} active={density === 'comfortable'} />
<ClassicButton icon={Rows3} label="Kompakt" onClick={() => onDensityChange('compact')} active={density === 'compact'} />
<ClassicButton icon={AlignJustify} label="Ordnerbereich" onClick={onToggleSidebar} active={sidebarCollapsed} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Design">
<ClassicButton icon={Sun} label="Hell" onClick={() => onThemeChange('light')} active={theme === 'light'} />
<ClassicButton icon={Moon} label="Dunkel" onClick={() => onThemeChange('dark')} active={theme === 'dark'} />
<ClassicButton icon={Monitor} label="System" onClick={() => onThemeChange('system')} active={theme === 'system'} />
</ClassicRibbonGroup>
<ClassicRibbonGroup caption="Fenster">
<RemindersMenu />
</ClassicRibbonGroup>
</>
)}
{tab === 'help' && (
<ClassicRibbonGroup caption="Support">
<HelpMenu onShowShortcuts={onShowShortcuts} />
<AboutMenu />
</ClassicRibbonGroup>
)}
</ClassicRibbonBody>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import clsx from 'clsx'
export function RibbonButton({
icon: Icon,
label,
onClick,
disabled,
active,
primary,
}: {
icon: React.ComponentType<{ size?: number }>
label: string
onClick: () => void
disabled?: boolean
active?: boolean
primary?: boolean
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className={clsx(
'flex h-14 w-16 flex-col items-center justify-center gap-1 rounded-lg text-[11px] font-medium transition disabled:cursor-not-allowed disabled:opacity-35',
primary
? 'text-indigo-600 hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10'
: active
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800',
)}
>
<Icon size={19} />
<span className="leading-none">{label}</span>
</button>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import { htmlIsEmpty } from '../lib/richtext'
export interface RichTextEditorHandle {
exec: (command: string, value?: string) => void
focus: () => void
wordCount: () => number
}
export const RichTextEditor = forwardRef<
RichTextEditorHandle,
{
html: string
resetKey: unknown
onChange: (html: string) => void
placeholder?: string
spellCheck?: boolean
}
>(function RichTextEditor({ html, resetKey, onChange, placeholder, spellCheck = true }, ref) {
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (editorRef.current) {
editorRef.current.innerHTML = html
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey])
function handleInput() {
onChange(editorRef.current?.innerHTML ?? '')
}
useImperativeHandle(ref, () => ({
exec(command: string, value?: string) {
editorRef.current?.focus()
document.execCommand(command, false, value)
handleInput()
},
focus() {
editorRef.current?.focus()
},
wordCount() {
const text = editorRef.current?.textContent ?? ''
return text.trim() ? text.trim().split(/\s+/).length : 0
},
// eslint-disable-next-line react-hooks/exhaustive-deps
}))
const empty = htmlIsEmpty(html)
return (
<div className="relative min-h-0 flex-1 overflow-y-auto">
{empty && (
<span className="pointer-events-none absolute top-5 left-7 text-[13.5px] text-zinc-300 select-none dark:text-zinc-600">
{placeholder}
</span>
)}
<div
ref={editorRef}
contentEditable
spellCheck={spellCheck}
onInput={handleInput}
suppressContentEditableWarning
className="compose-body min-h-full px-7 py-5 text-[13.5px] leading-relaxed text-zinc-800 outline-none dark:text-zinc-100"
/>
</div>
)
})
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2, X } from 'lucide-react'
import { api } from '../lib/api'
import type { Account, Folder, MatchType, NewRule, Rule, RuleAction, RuleCondition } from '../types'
const inputClass =
'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>
)
}
function conditionsSummary(conditions: RuleCondition[], matchType: MatchType) {
const parts = conditions.map((c) => `${c.field === 'subject' ? 'Betreff' : 'Absender'} enthält „${c.contains}`)
const joiner = matchType === 'all' ? ' UND ' : ' ODER '
return parts.join(joiner)
}
function actionLabel(r: Rule, accountFolders: Record<string, Folder[]>) {
switch (r.action) {
case 'move_to': {
const folders = r.account_id ? accountFolders[r.account_id] ?? [] : []
const folder = folders.find((f) => f.name === r.action_value)
return `verschieben nach „${folder?.display_name ?? r.action_value}`
}
case 'mark_read':
return 'als gelesen markieren'
case 'flag':
return 'markieren'
case 'delete':
return 'löschen'
}
}
const emptyCondition = (): RuleCondition => ({ field: 'from', contains: '' })
export function RulesModal({
open,
onClose,
accounts,
accountFolders,
onToast,
}: {
open: boolean
onClose: () => void
accounts: Account[]
accountFolders: Record<string, Folder[]>
onToast: (msg: string) => void
}) {
const [rules, setRules] = useState<Rule[]>([])
const [loading, setLoading] = useState(false)
const [accountId, setAccountId] = useState('')
const [conditions, setConditions] = useState<RuleCondition[]>([emptyCondition()])
const [matchType, setMatchType] = useState<MatchType>('all')
const [action, setAction] = useState<RuleAction>('move_to')
const [actionValue, setActionValue] = useState('')
const [saving, setSaving] = useState(false)
const ruleAccounts = accounts.filter((a) => a.protocol === 'imap' || a.protocol === 'pop3')
const targetFolders = accountId ? accountFolders[accountId] ?? [] : []
const validConditions = conditions.filter((c) => c.contains.trim())
const isPop3 = accounts.find((a) => a.id === accountId)?.protocol === 'pop3'
function refresh() {
setLoading(true)
api
.listRules()
.then(setRules)
.catch((e) => onToast(`Regeln konnten nicht geladen werden: ${e}`))
.finally(() => setLoading(false))
}
useEffect(() => {
if (open) refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
if (!open) return null
function updateCondition(index: number, patch: Partial<RuleCondition>) {
setConditions((prev) => prev.map((c, i) => (i === index ? { ...c, ...patch } : c)))
}
function addCondition() {
setConditions((prev) => [...prev, emptyCondition()])
}
function removeCondition(index: number) {
setConditions((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev))
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (validConditions.length === 0) return
if (action === 'move_to' && !actionValue) return
setSaving(true)
try {
const newRule: NewRule = {
account_id: accountId || null,
conditions: validConditions.map((c) => ({ field: c.field, contains: c.contains.trim() })),
match_type: matchType,
action,
action_value: action === 'move_to' ? actionValue : undefined,
}
await api.createRule(newRule)
setConditions([emptyCondition()])
setMatchType('all')
setActionValue('')
refresh()
onToast('Regel erstellt')
} catch (err) {
onToast(`Fehlgeschlagen: ${err}`)
} finally {
setSaving(false)
}
}
async function handleToggle(r: Rule) {
setRules((prev) => prev.map((x) => (x.id === r.id ? { ...x, enabled: !x.enabled } : x)))
try {
await api.setRuleEnabled(r.id, !r.enabled)
} catch (err) {
onToast(String(err))
}
}
async function handleDelete(r: Rule) {
setRules((prev) => prev.filter((x) => x.id !== r.id))
try {
await api.deleteRule(r.id)
} catch (err) {
onToast(String(err))
}
}
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-lg 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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
Regeln verwalten
</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">
<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">
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Neue Regel</p>
<Field label="Konto">
<select
className={inputClass}
value={accountId}
onChange={(e) => {
setAccountId(e.target.value)
const nowPop3 = accounts.find((a) => a.id === e.target.value)?.protocol === 'pop3'
if (nowPop3 && action === 'move_to') setAction('mark_read')
}}
>
<option value="">Alle Konten</option>
{ruleAccounts.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
{a.protocol === 'pop3' ? ' (POP3)' : ''}
</option>
))}
</select>
</Field>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-[11.5px] font-medium text-zinc-500 dark:text-zinc-400">Bedingungen</span>
{conditions.length > 1 && (
<div className="flex rounded-lg bg-zinc-200/60 p-0.5 text-[11px] dark:bg-zinc-800/60">
<button
type="button"
onClick={() => setMatchType('all')}
className={`rounded-md px-2 py-0.5 transition ${matchType === 'all' ? 'bg-white font-medium text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500'}`}
>
Alle (UND)
</button>
<button
type="button"
onClick={() => setMatchType('any')}
className={`rounded-md px-2 py-0.5 transition ${matchType === 'any' ? 'bg-white font-medium text-zinc-800 shadow-sm dark:bg-zinc-700 dark:text-zinc-100' : 'text-zinc-500'}`}
>
Eine (ODER)
</button>
</div>
)}
</div>
{conditions.map((c, i) => (
<div key={i} className="flex gap-2">
<select
className={`${inputClass} w-28 shrink-0`}
value={c.field}
onChange={(e) => updateCondition(i, { field: e.target.value })}
>
<option value="from">Absender</option>
<option value="subject">Betreff</option>
</select>
<input
className={`${inputClass} flex-1`}
value={c.contains}
onChange={(e) => updateCondition(i, { contains: e.target.value })}
placeholder="z. B. newsletter@ oder Rechnung"
/>
<button
type="button"
onClick={() => removeCondition(i)}
disabled={conditions.length === 1}
className="shrink-0 rounded-lg p-1.5 text-zinc-400 transition hover:bg-red-50 hover:text-red-600 disabled:opacity-30 dark:hover:bg-red-950/40"
>
<Trash2 size={13} />
</button>
</div>
))}
<button
type="button"
onClick={addCondition}
className="flex items-center gap-1 text-[11.5px] font-medium text-indigo-600 transition hover:text-indigo-700 dark:text-indigo-400"
>
<Plus size={12} /> Bedingung hinzufügen
</button>
</div>
<div className="grid grid-cols-2 gap-2.5">
<Field label="Aktion">
<select className={inputClass} value={action} onChange={(e) => setAction(e.target.value as RuleAction)}>
{!isPop3 && <option value="move_to">In Ordner verschieben</option>}
<option value="mark_read">Als gelesen markieren</option>
<option value="flag">Markieren</option>
<option value="delete">Löschen</option>
</select>
{isPop3 && (
<p className="mt-1 text-[10.5px] text-zinc-400">
POP3 hat keine Server-Ordner, Verschieben ist daher nicht verfügbar.
</p>
)}
</Field>
{action === 'move_to' && (
<Field label="Zielordner">
<select
className={inputClass}
value={actionValue}
onChange={(e) => setActionValue(e.target.value)}
disabled={!accountId}
>
<option value="">{accountId ? 'Ordner wählen…' : 'Erst Konto wählen'}</option>
{targetFolders.map((f) => (
<option key={f.name} value={f.name}>
{f.display_name}
</option>
))}
</select>
</Field>
)}
</div>
<button
type="submit"
disabled={saving || validConditions.length === 0 || (action === 'move_to' && !actionValue)}
className="w-full rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 disabled:opacity-60"
>
{saving ? 'Speichere…' : 'Regel hinzufügen'}
</button>
</form>
<div>
<p className="mb-1.5 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
Aktive Regeln
</p>
{loading ? (
<p className="text-xs text-zinc-400">Lade</p>
) : rules.length === 0 ? (
<p className="text-xs text-zinc-400">Noch keine Regeln angelegt.</p>
) : (
<div className="space-y-1.5">
{rules.map((r) => (
<div
key={r.id}
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
>
<input
type="checkbox"
checked={r.enabled}
onChange={() => handleToggle(r)}
className="rounded border-zinc-300"
/>
<div className="min-w-0 flex-1 text-[12.5px] text-zinc-700 dark:text-zinc-300">
<span className="font-medium">{conditionsSummary(r.conditions, r.match_type)}</span>
{' → '}
{actionLabel(r, accountFolders)}
{r.account_id && (
<span className="text-zinc-400">
{' '}
({accounts.find((a) => a.id === r.account_id)?.label ?? '?'})
</span>
)}
</div>
<button
onClick={() => handleDelete(r)}
className="shrink-0 rounded-full p-1 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>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { Bell, MonitorSmartphone, PictureInPicture2, X } from 'lucide-react'
import type { AppSettings } from '../lib/settings'
export function SettingsModal({
open,
settings,
onChange,
onClose,
}: {
open: boolean
settings: AppSettings
onChange: (v: AppSettings) => void
onClose: () => void
}) {
if (!open) return null
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 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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
Einstellungen
</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="space-y-4 px-6 py-5">
<div>
<p className="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
Benachrichtigungen bei neuer E-Mail
</p>
<div className="space-y-2.5">
<label className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800">
<span className="flex items-center gap-2.5 text-[13px] text-zinc-700 dark:text-zinc-200">
<Bell size={15} className="text-zinc-400" />
Popup in der App anzeigen
</span>
<input
type="checkbox"
checked={settings.popup}
onChange={(e) => onChange({ ...settings, popup: e.target.checked })}
className="h-4 w-4 rounded border-zinc-300"
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800">
<span className="flex items-center gap-2.5 text-[13px] text-zinc-700 dark:text-zinc-200">
<MonitorSmartphone size={15} className="text-zinc-400" />
Desktop-Benachrichtigung anzeigen
</span>
<input
type="checkbox"
checked={settings.desktop}
onChange={(e) => onChange({ ...settings, desktop: e.target.checked })}
className="h-4 w-4 rounded border-zinc-300"
/>
</label>
</div>
<p className="mt-2 text-[11px] text-zinc-400">
Beide lassen sich unabhängig voneinander abschalten, um keine Benachrichtigungen mehr zu erhalten.
</p>
</div>
<div>
<p className="mb-2 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">Verfassen</p>
<label className="flex items-center justify-between gap-3 rounded-xl border border-zinc-200/80 px-3.5 py-2.5 dark:border-zinc-800">
<span className="flex items-center gap-2.5 text-[13px] text-zinc-700 dark:text-zinc-200">
<PictureInPicture2 size={15} className="text-zinc-400" />
Neu/Antworten als Popup-Fenster öffnen
</span>
<input
type="checkbox"
checked={settings.composePopup}
onChange={(e) => onChange({ ...settings, composePopup: e.target.checked })}
className="h-4 w-4 rounded border-zinc-300"
/>
</label>
<p className="mt-2 text-[11px] text-zinc-400">
Aus: Verfassen ersetzt die Lesefläche (Standard). An: Verfassen öffnet als schwebendes Fenster, die Nachrichtenliste bleibt sichtbar.
</p>
</div>
</div>
</div>
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { X } from 'lucide-react'
const SHORTCUTS: { keys: string; label: string }[] = [
{ keys: 'N', label: 'Neue Mail verfassen' },
{ keys: 'R', label: 'Antworten' },
{ keys: 'F', label: 'Weiterleiten' },
{ keys: 'Entf / Rücktaste', label: 'Ausgewählte Nachricht löschen' },
{ keys: '↑ / ↓', label: 'In der Mailliste navigieren' },
{ keys: 'Strg/Cmd + Klick', label: 'Mehrfachauswahl (einzelne Nachrichten)' },
{ keys: 'Umschalt + Klick', label: 'Bereich auswählen' },
{ keys: 'Rechtsklick', label: 'Kontextmenü öffnen (Mails, Kontakte, Ordner, Termine)' },
{ keys: 'Esc', label: 'Menü/Popup schließen' },
{ keys: '?', label: 'Diese Übersicht anzeigen' },
]
export function ShortcutsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null
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"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="animate-panel-in flex w-full max-w-sm 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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
Tastenkürzel
</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-1.5 px-6 py-5">
{SHORTCUTS.map((s) => (
<div key={s.keys} className="flex items-center justify-between gap-3 py-0.5">
<span className="text-[12.5px] text-zinc-600 dark:text-zinc-300">{s.label}</span>
<kbd className="shrink-0 rounded-lg border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] font-medium text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
{s.keys}
</kbd>
</div>
))}
</div>
</div>
</div>
)
}
+496
View File
@@ -0,0 +1,496 @@
import { useState } from 'react'
import {
Calendar,
ChevronRight,
Download,
Filter,
Folder as FolderIcon,
HardDrive,
Inbox,
Layers,
ListTodo,
Mail,
MailSearch,
MoreHorizontal,
PenLine,
Plus,
RefreshCw,
Settings,
Star,
Upload,
Users,
X,
} from 'lucide-react'
import clsx from 'clsx'
import type { Account, Folder } from '../types'
import { Avatar } from './Avatar'
import { Popover } from './Popover'
export type SidebarView = 'mail' | 'contacts' | 'calendar' | 'tasks'
export interface FavoriteFolder {
accountId: string
folder: string
label: string
}
export interface SavedSearch {
id: string
label: string
query: string
}
function folderIcon(name: string) {
const n = name.toLowerCase()
if (n.includes('inbox') || n.includes('posteingang')) return Inbox
if (n.includes('entwürfe') || n.includes('entwuerfe') || n.includes('draft')) return PenLine
return FolderIcon
}
function FolderMenu({
onExport,
onImport,
onManageRules,
onFullBackup,
}: {
onExport: () => void
onImport: () => void
onManageRules: () => void
onFullBackup: () => void
}) {
const [open, setOpen] = useState(false)
return (
<Popover
open={open}
onOpenChange={setOpen}
trigger={
<button
title="Import / Export"
className="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
>
<MoreHorizontal size={14} />
</button>
}
panelClassName="animate-panel-in w-56 overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/95 py-1.5 shadow-xl backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/95"
>
<button
onClick={() => {
setOpen(false)
onExport()
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
>
<Download size={13} /> Ordner exportieren (.mbox)
</button>
<button
onClick={() => {
setOpen(false)
onImport()
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
>
<Upload size={13} /> Mail importieren
</button>
<div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800" />
<button
onClick={() => {
setOpen(false)
onFullBackup()
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
>
<HardDrive size={13} /> Komplettes Postfach sichern
</button>
<div className="my-1 h-px bg-zinc-100 dark:bg-zinc-800" />
<button
onClick={() => {
setOpen(false)
onManageRules()
}}
className="flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-[12.5px] text-zinc-700 transition hover:bg-indigo-50 hover:text-indigo-700 dark:text-zinc-300 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-300"
>
<Filter size={13} /> Regeln verwalten
</button>
</Popover>
)
}
function FolderRow({
folder,
active,
isFavorite,
onSelect,
onToggleFavorite,
onContextMenu,
onDropMessages,
compact,
}: {
folder: Folder
active: boolean
isFavorite: boolean
onSelect: () => void
onToggleFavorite: () => void
onContextMenu?: (e: React.MouseEvent) => void
onDropMessages?: () => void
compact: boolean
}) {
const Icon = folderIcon(folder.name)
const [dragOver, setDragOver] = useState(false)
return (
<button
onClick={onSelect}
onContextMenu={onContextMenu}
onDragOver={(e) => {
if (!onDropMessages) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}}
onDragEnter={(e) => {
if (!onDropMessages) return
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
if (!onDropMessages) return
e.preventDefault()
setDragOver(false)
onDropMessages()
}}
className={clsx(
'group flex w-full items-center gap-2.5 rounded-xl px-2.5 text-left text-[13px] transition',
compact ? 'py-1' : 'py-1.5',
dragOver
? 'bg-indigo-100 ring-2 ring-indigo-400 dark:bg-indigo-500/25'
: active
? 'bg-white font-medium text-indigo-700 shadow-sm ring-1 ring-black/[0.04] dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-white/5'
: 'text-zinc-600 hover:bg-white/60 dark:text-zinc-400 dark:hover:bg-zinc-800/60',
)}
>
<Icon size={15} className="shrink-0" />
<span className="flex-1 truncate">{folder.display_name}</span>
<span
role="button"
onClick={(e) => {
e.stopPropagation()
onToggleFavorite()
}}
className={clsx(
'shrink-0 rounded-full p-0.5 transition',
isFavorite ? 'text-amber-400' : 'text-zinc-300 opacity-0 group-hover:opacity-100 hover:text-amber-400 dark:text-zinc-700',
)}
>
<Star size={12} fill={isFavorite ? 'currentColor' : 'none'} />
</span>
{folder.unread > 0 && (
<span
className={clsx(
'shrink-0 rounded-full px-1.5 py-0.5 text-[10.5px] font-semibold tabular-nums',
active ? 'bg-indigo-600 text-white' : 'bg-zinc-200 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300',
)}
>
{folder.unread > 99 ? '99+' : folder.unread}
</span>
)}
</button>
)
}
export function Sidebar({
accounts,
accountFolders,
expandedAccounts,
onToggleExpand,
selectedAccountId,
selectedFolder,
onSelectFolder,
favorites,
onToggleFavorite,
onFolderContextMenu,
onDropMessagesOnFolder,
onAddAccount,
onRefresh,
refreshing,
view,
onChangeView,
onExportFolder,
onFullBackup,
onOpenImport,
onManageRules,
onOpenAccountSettings,
compact,
savedSearches,
onSelectSavedSearch,
onDeleteSavedSearch,
unifiedInboxActive,
onSelectUnifiedInbox,
}: {
accounts: Account[]
accountFolders: Record<string, Folder[]>
expandedAccounts: Set<string>
onToggleExpand: (accountId: string) => void
selectedAccountId: string | null
selectedFolder: string | null
onSelectFolder: (accountId: string, folderName: string) => void
favorites: FavoriteFolder[]
onToggleFavorite: (accountId: string, folder: Folder) => void
onFolderContextMenu?: (e: React.MouseEvent, accountId: string, folder: Folder) => void
onDropMessagesOnFolder?: (accountId: string, folder: Folder) => void
onAddAccount: () => void
onRefresh: () => void
refreshing: boolean
view: SidebarView
onChangeView: (v: SidebarView) => void
onExportFolder: () => void
onFullBackup: () => void
onOpenImport: () => void
onManageRules: () => void
onOpenAccountSettings: (account: Account) => void
compact: boolean
savedSearches: SavedSearch[]
onSelectSavedSearch: (query: string) => void
onDeleteSavedSearch: (id: string) => void
unifiedInboxActive: boolean
onSelectUnifiedInbox: () => void
}) {
const isFavorite = (accountId: string, folder: string) =>
favorites.some((f) => f.accountId === accountId && f.folder === folder)
return (
<aside className="flex h-full w-64 shrink-0 flex-col border-r border-zinc-200/80 bg-gradient-to-b from-zinc-50 to-zinc-100/60 dark:border-zinc-800/80 dark:from-zinc-950 dark:to-zinc-900/40">
<div className="px-3 pt-4">
<div className="flex rounded-xl bg-zinc-200/60 p-1 dark:bg-zinc-900/60">
<button
onClick={() => onChangeView('mail')}
title="Mail"
className={clsx(
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
view === 'mail'
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
)}
>
<Mail size={13} />
</button>
<button
onClick={() => onChangeView('contacts')}
title="Kontakte"
className={clsx(
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
view === 'contacts'
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
)}
>
<Users size={13} />
</button>
<button
onClick={() => onChangeView('calendar')}
title="Kalender"
className={clsx(
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
view === 'calendar'
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
)}
>
<Calendar size={13} />
</button>
<button
onClick={() => onChangeView('tasks')}
title="Aufgaben"
className={clsx(
'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-[12px] font-medium transition',
view === 'tasks'
? 'bg-white text-zinc-800 shadow-sm dark:bg-zinc-800 dark:text-zinc-100'
: 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-500 dark:hover:text-zinc-300',
)}
>
<ListTodo size={13} />
</button>
</div>
</div>
{view === 'mail' && (
<div className="mt-4 flex-1 overflow-y-auto px-3 pb-3">
<button
onClick={onSelectUnifiedInbox}
className={clsx(
'mb-3 flex w-full items-center gap-2.5 rounded-xl px-2.5 py-2 text-left text-[13px] font-medium transition',
unifiedInboxActive
? 'bg-white text-indigo-700 shadow-sm ring-1 ring-black/[0.04] dark:bg-indigo-500/15 dark:text-indigo-300 dark:ring-white/5'
: 'text-zinc-600 hover:bg-white/60 dark:text-zinc-400 dark:hover:bg-zinc-800/60',
)}
>
<Layers size={15} className="shrink-0" />
Alle Posteingänge
</button>
{favorites.length > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase dark:text-zinc-500">
Favoriten
</span>
</div>
<nav className="flex flex-col gap-0.5">
{favorites.map((f) => {
const folders = accountFolders[f.accountId] ?? []
const folder = folders.find((x) => x.name === f.folder)
if (!folder) return null
return (
<FolderRow
key={`${f.accountId}-${f.folder}`}
folder={folder}
active={selectedAccountId === f.accountId && selectedFolder === f.folder}
isFavorite
onSelect={() => onSelectFolder(f.accountId, f.folder)}
onToggleFavorite={() => onToggleFavorite(f.accountId, folder)}
onContextMenu={(e) => onFolderContextMenu?.(e, f.accountId, folder)}
onDropMessages={() => onDropMessagesOnFolder?.(f.accountId, folder)}
compact={compact}
/>
)
})}
</nav>
</div>
)}
{savedSearches.length > 0 && (
<div className="mb-4">
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase dark:text-zinc-500">
Suchordner
</span>
</div>
<nav className="flex flex-col gap-0.5">
{savedSearches.map((s) => (
<button
key={s.id}
onClick={() => onSelectSavedSearch(s.query)}
className="group flex w-full items-center gap-2.5 rounded-xl px-2.5 py-1.5 text-left text-[13px] text-zinc-600 transition hover:bg-white/60 dark:text-zinc-400 dark:hover:bg-zinc-800/60"
>
<MailSearch size={15} className="shrink-0" />
<span className="flex-1 truncate">{s.label}</span>
<span
role="button"
onClick={(e) => {
e.stopPropagation()
onDeleteSavedSearch(s.id)
}}
className="shrink-0 rounded-full p-0.5 text-zinc-300 opacity-0 transition group-hover:opacity-100 hover:text-red-500 dark:text-zinc-700"
>
<X size={12} />
</span>
</button>
))}
</nav>
</div>
)}
<div className="flex items-center justify-between px-1 pb-1.5">
<span className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase dark:text-zinc-500">
Konten
</span>
<div className="flex items-center gap-0.5">
<button
onClick={onRefresh}
disabled={!selectedAccountId || refreshing}
title="Aktualisieren"
className="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-200 hover:text-zinc-600 disabled:opacity-40 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
>
<RefreshCw size={13} className={clsx(refreshing && 'animate-spin')} />
</button>
<FolderMenu onExport={onExportFolder} onImport={onOpenImport} onManageRules={onManageRules} onFullBackup={onFullBackup} />
<button
onClick={onAddAccount}
title="Konto hinzufügen"
className="rounded-full p-1 text-zinc-400 transition hover:bg-zinc-200 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
>
<Plus size={14} />
</button>
</div>
</div>
<div className="flex flex-col gap-2.5">
{[...accounts]
.sort((a, b) => (a.protocol === 'local' ? 1 : 0) - (b.protocol === 'local' ? 1 : 0))
.map((a) => {
const expanded = expandedAccounts.has(a.id)
const folders = accountFolders[a.id] ?? []
return (
<div key={a.id} className="group/account">
<div className="flex w-full items-center gap-1.5 rounded-lg px-1 py-1 transition hover:bg-white/50 dark:hover:bg-zinc-800/50">
<button
onClick={() => onToggleExpand(a.id)}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
>
<ChevronRight
size={13}
className={clsx('shrink-0 text-zinc-400 transition-transform', expanded && 'rotate-90')}
/>
{a.protocol === 'local' ? (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-zinc-300 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300">
<HardDrive size={11} />
</div>
) : (
<Avatar name={a.label || a.email} size={20} />
)}
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-zinc-600 dark:text-zinc-300">
{a.label}
</span>
</button>
{a.protocol !== 'local' && (
<button
onClick={(e) => {
e.stopPropagation()
onOpenAccountSettings(a)
}}
title="Kontoeinstellungen"
className="shrink-0 rounded-md p-1 text-zinc-300 opacity-0 transition group-hover/account:opacity-100 hover:bg-zinc-200 hover:text-zinc-600 dark:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
>
<Settings size={12} />
</button>
)}
</div>
{expanded && (
<nav className="mt-0.5 flex flex-col gap-0.5 pl-3">
{folders.map((f) => (
<FolderRow
key={f.name}
folder={f}
active={selectedAccountId === a.id && selectedFolder === f.name}
isFavorite={isFavorite(a.id, f.name)}
onSelect={() => onSelectFolder(a.id, f.name)}
onToggleFavorite={() => onToggleFavorite(a.id, f)}
onContextMenu={(e) => onFolderContextMenu?.(e, a.id, f)}
onDropMessages={() => onDropMessagesOnFolder?.(a.id, f)}
compact={compact}
/>
))}
{folders.length === 0 && (
<p className="px-2.5 py-1.5 text-xs text-zinc-400">Lade Ordner</p>
)}
</nav>
)}
</div>
)
})}
{accounts.length === 0 && (
<button
onClick={onAddAccount}
className="rounded-xl border border-dashed border-zinc-300 px-2.5 py-2.5 text-left text-xs text-zinc-500 transition hover:border-indigo-300 hover:bg-white/50 hover:text-indigo-600 dark:border-zinc-700 dark:text-zinc-500 dark:hover:border-indigo-800"
>
Erstes Konto hinzufügen
</button>
)}
</div>
</div>
)}
{view === 'contacts' && <div className="flex-1" />}
{view === 'calendar' && <div className="flex-1" />}
{view === 'tasks' && <div className="flex-1" />}
</aside>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react'
import { BellRing, X } from 'lucide-react'
import { api } from '../lib/api'
import type { MessageSummary, SnoozedMessage } from '../types'
export function SnoozedModal({
open,
onClose,
onToast,
onOpenMessage,
}: {
open: boolean
onClose: () => void
onToast: (msg: string) => void
onOpenMessage: (m: MessageSummary) => void
}) {
const [items, setItems] = useState<SnoozedMessage[]>([])
const [loading, setLoading] = useState(false)
function refresh() {
setLoading(true)
api
.listSnoozedMessages()
.then(setItems)
.catch((e) => onToast(`Liste konnte nicht geladen werden: ${e}`))
.finally(() => setLoading(false))
}
useEffect(() => {
if (open) refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
if (!open) return null
async function handleUnsnooze(m: SnoozedMessage) {
setItems((prev) => prev.filter((x) => x !== m))
try {
await api.unsnoozeMessage(m.account_id, m.folder, m.uid)
} 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-[80vh] 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">
<BellRing size={16} className="text-zinc-400" /> Wiedervorlage
</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-1.5 overflow-y-auto px-6 py-5">
{loading ? (
<p className="text-xs text-zinc-400">Lade</p>
) : items.length === 0 ? (
<p className="text-xs text-zinc-400">Nichts auf Wiedervorlage.</p>
) : (
items.map((m) => (
<div
key={`${m.account_id}-${m.folder}-${m.uid}`}
className="flex items-center gap-2.5 rounded-xl border border-zinc-200/80 px-3 py-2 dark:border-zinc-800"
>
<button
onClick={() => {
onOpenMessage(m)
onClose()
}}
className="min-w-0 flex-1 text-left"
>
<p className="truncate text-[12.5px] font-medium text-zinc-700 dark:text-zinc-300">
{m.subject || '(kein Betreff)'}
</p>
<p className="truncate text-[11px] text-zinc-400">
{m.from_name || m.from_addr} · wieder ab {new Date(m.until_ts * 1000).toLocaleString('de-DE')}
</p>
</button>
<button
onClick={() => handleUnsnooze(m)}
title="Jetzt wiedervorlegen"
className="shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium text-indigo-600 transition hover:bg-indigo-50 dark:text-indigo-400 dark:hover:bg-indigo-500/10"
>
Jetzt zeigen
</button>
</div>
))
)}
</div>
</div>
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useState } from 'react'
import { Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import type { NewTask, Task, TaskPriority } from '../types'
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>
)
}
const inputClass =
'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'
const PRIORITIES: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'low', label: 'Niedrig', color: '#94a3b8' },
{ value: 'normal', label: 'Normal', color: '#3b82f6' },
{ value: 'high', label: 'Hoch', color: '#ef4444' },
]
function toDateStr(ts: number) {
const d = new Date(ts * 1000)
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
export function TaskModal({
open,
task,
onClose,
onSave,
onDelete,
}: {
open: boolean
task: Task | null
onClose: () => void
onSave: (data: NewTask, existingId: string | null) => Promise<void>
onDelete: (task: Task) => Promise<void>
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [dueDate, setDueDate] = useState('')
const [priority, setPriority] = useState<TaskPriority>('normal')
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
useEffect(() => {
if (!open) return
setConfirmDelete(false)
if (task) {
setTitle(task.title)
setDescription(task.description ?? '')
setDueDate(task.due_ts ? toDateStr(task.due_ts) : '')
setPriority(task.priority)
} else {
setTitle('')
setDescription('')
setDueDate('')
setPriority('normal')
}
}, [open, task])
if (!open) return null
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!title.trim()) return
setSaving(true)
try {
const due_ts = dueDate ? Math.floor(new Date(`${dueDate}T09:00:00`).getTime() / 1000) : null
await onSave(
{
title: title.trim(),
description: description.trim() || undefined,
due_ts,
priority,
},
task?.id ?? null,
)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!task) return
setDeleting(true)
try {
await onDelete(task)
} finally {
setDeleting(false)
}
}
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">
<form
onSubmit={handleSubmit}
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="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">
{task ? 'Aufgabe bearbeiten' : 'Neue Aufgabe'}
</h2>
<button
type="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-3.5 overflow-y-auto px-6 py-5">
<Field label="Titel">
<input
className={inputClass}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="z. B. Angebot nachfassen"
autoFocus
required
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Fällig am (optional)">
<input className={inputClass} type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
</Field>
<Field label="Priorität">
<div className="flex items-center gap-1.5 pt-1">
{PRIORITIES.map((p) => (
<button
key={p.value}
type="button"
onClick={() => setPriority(p.value)}
title={p.label}
className={clsx(
'flex h-7 w-7 items-center justify-center rounded-full border-2 transition',
priority === p.value ? 'border-zinc-400 dark:border-zinc-300' : 'border-transparent',
)}
>
<span className="h-3.5 w-3.5 rounded-full" style={{ backgroundColor: p.color }} />
</button>
))}
</div>
</Field>
</div>
<Field label="Beschreibung (optional)">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full resize-none rounded-lg border border-zinc-200 bg-white px-3 py-2 text-[12.5px] text-zinc-700 outline-none focus:border-indigo-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
/>
</Field>
{task && (
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-3 dark:border-red-950/60 dark:bg-red-950/20">
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-[12px] text-red-700 dark:text-red-400">Aufgabe wirklich löschen?</span>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="ml-auto rounded-full bg-red-600 px-3 py-1 text-[11.5px] font-medium text-white transition hover:bg-red-500 disabled:opacity-60"
>
{deleting ? 'Lösche…' : 'Löschen'}
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="rounded-full px-3 py-1 text-[11.5px] font-medium text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="flex items-center gap-1.5 text-[12.5px] font-medium text-red-600 transition hover:text-red-700 dark:text-red-400"
>
<Trash2 size={13} /> Aufgabe löschen
</button>
)}
</div>
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-200/70 px-6 py-4 dark:border-zinc-800/70">
<button
type="button"
onClick={onClose}
className="rounded-full px-4 py-2 text-[13px] font-medium text-zinc-500 transition hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-5 py-2 text-[13px] font-medium text-white shadow-lg shadow-indigo-600/25 transition hover:from-indigo-400 hover:to-indigo-500 hover:shadow-indigo-600/35 active:scale-[0.98] disabled:opacity-60"
>
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</form>
</div>
)
}
+141
View File
@@ -0,0 +1,141 @@
import { useMemo } from 'react'
import { CheckSquare, ListTodo, Plus } from 'lucide-react'
import clsx from 'clsx'
import type { Task } from '../types'
const PRIORITY_COLOR: Record<Task['priority'], string> = {
low: '#94a3b8',
normal: '#3b82f6',
high: '#ef4444',
}
function isOverdue(task: Task) {
if (!task.due_ts || task.completed) return false
return task.due_ts * 1000 < Date.now()
}
function formatDue(ts: number) {
const d = new Date(ts * 1000)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })
}
export function TasksView({
tasks,
loading,
onAdd,
onEdit,
onToggleCompleted,
}: {
tasks: Task[]
loading: boolean
onAdd: () => void
onEdit: (task: Task) => void
onToggleCompleted: (task: Task) => void
}) {
const open = useMemo(() => tasks.filter((t) => !t.completed), [tasks])
const completed = useMemo(() => tasks.filter((t) => t.completed), [tasks])
return (
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex items-center gap-3 border-b border-zinc-200/80 px-6 py-3.5 dark:border-zinc-800/80">
<h2 className="text-[15px] font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">Aufgaben</h2>
<span className="text-[12px] text-zinc-400">{open.length} offen</span>
<button
onClick={onAdd}
className="ml-auto flex items-center gap-1.5 rounded-full bg-gradient-to-b from-indigo-500 to-indigo-600 px-3.5 py-1.5 text-[12.5px] font-medium text-white shadow-sm transition hover:from-indigo-400 hover:to-indigo-500"
>
<Plus size={13} /> Neue Aufgabe
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<p className="text-sm text-zinc-400">Lade</p>
) : tasks.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 text-zinc-300 dark:bg-zinc-900 dark:text-zinc-700">
<ListTodo size={26} strokeWidth={1.4} />
</div>
<p className="text-sm text-zinc-400">Noch keine Aufgaben angelegt.</p>
</div>
) : (
<div className="space-y-6">
<div className="space-y-1">
{open.map((task) => (
<div
key={task.id}
onClick={() => onEdit(task)}
role="button"
className="group flex cursor-default items-start gap-3 rounded-xl px-3 py-2.5 transition hover:bg-zinc-50 dark:hover:bg-zinc-900/60"
>
<button
onClick={(e) => {
e.stopPropagation()
onToggleCompleted(task)
}}
className="mt-0.5 h-4 w-4 shrink-0 rounded-full border-2 border-zinc-300 transition hover:border-indigo-400 dark:border-zinc-600"
/>
<span
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: PRIORITY_COLOR[task.priority] }}
title={task.priority}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-zinc-800 dark:text-zinc-100">{task.title}</p>
{task.description && (
<p className="truncate text-[12px] text-zinc-400">{task.description}</p>
)}
</div>
{task.due_ts && (
<span
className={clsx(
'shrink-0 rounded-full px-2 py-0.5 text-[10.5px] font-medium',
isOverdue(task)
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-400'
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400',
)}
>
{formatDue(task.due_ts)}
</span>
)}
</div>
))}
{open.length === 0 && <p className="px-3 text-[12.5px] text-zinc-400">Alles erledigt 🎉</p>}
</div>
{completed.length > 0 && (
<div>
<p className="mb-1.5 px-3 text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
Erledigt ({completed.length})
</p>
<div className="space-y-1">
{completed.map((task) => (
<div
key={task.id}
onClick={() => onEdit(task)}
role="button"
className="group flex cursor-default items-center gap-3 rounded-xl px-3 py-2 opacity-60 transition hover:bg-zinc-50 hover:opacity-100 dark:hover:bg-zinc-900/60"
>
<button
onClick={(e) => {
e.stopPropagation()
onToggleCompleted(task)
}}
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-indigo-500 bg-indigo-500 text-white"
>
<CheckSquare size={10} />
</button>
<p className="min-w-0 flex-1 truncate text-[13px] text-zinc-500 line-through dark:text-zinc-500">
{task.title}
</p>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useState } from 'react'
import { FileText, Pencil, Plus, Trash2, X } from 'lucide-react'
import { api } from '../lib/api'
import type { Template } 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 TemplatesModal({
open,
onClose,
onToast,
onInsert,
}: {
open: boolean
onClose: () => void
onToast: (msg: string) => void
onInsert?: (t: Template) => void
}) {
const [templates, setTemplates] = useState<Template[]>([])
const [loading, setLoading] = useState(false)
const [editing, setEditing] = useState<Template | null>(null)
const [name, setName] = useState('')
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
function refresh() {
setLoading(true)
api
.listTemplates()
.then(setTemplates)
.catch((e) => onToast(`Vorlagen konnten nicht geladen werden: ${e}`))
.finally(() => setLoading(false))
}
useEffect(() => {
if (open) {
refresh()
setEditing(null)
setName('')
setSubject('')
setBody('')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
if (!open) return null
function startEdit(t: Template) {
setEditing(t)
setName(t.name)
setSubject(t.subject)
setBody(t.body)
}
function startNew() {
setEditing({ id: '', name: '', subject: '', body: '' })
setName('')
setSubject('')
setBody('')
}
async function handleSave(e: React.FormEvent) {
e.preventDefault()
if (!name.trim()) return
setSaving(true)
try {
if (editing?.id) {
await api.updateTemplate({ id: editing.id, name: name.trim(), subject, body })
} else {
await api.createTemplate({ name: name.trim(), subject, body })
}
setEditing(null)
refresh()
onToast('Vorlage gespeichert')
} catch (err) {
onToast(`Fehlgeschlagen: ${err}`)
} finally {
setSaving(false)
}
}
async function handleDelete(t: Template) {
setTemplates((prev) => prev.filter((x) => x.id !== t.id))
if (editing?.id === t.id) setEditing(null)
try {
await api.deleteTemplate(t.id)
} catch (err) {
onToast(String(err))
}
}
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-lg 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">
<FileText size={16} className="text-zinc-400" /> E-Mail-Vorlagen
</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">
{editing ? (
<form onSubmit={handleSave} 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">
<p className="text-[11px] font-semibold tracking-wide text-zinc-400 uppercase">
{editing.id ? 'Vorlage bearbeiten' : 'Neue Vorlage'}
</p>
<Field label="Name">
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="z. B. Angebot" />
</Field>
<Field label="Betreff (optional)">
<input className={inputClass} value={subject} onChange={(e) => setSubject(e.target.value)} />
</Field>
<Field label="Text">
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={5}
className={`${inputClass} resize-none`}
/>
</Field>
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !name.trim()}
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 ? 'Speichere…' : 'Speichern'}
</button>
<button
type="button"
onClick={() => setEditing(null)}
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={startNew}
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} /> Neue Vorlage
</button>
)}
<div>
{loading ? (
<p className="text-xs text-zinc-400">Lade</p>
) : templates.length === 0 ? (
<p className="text-xs text-zinc-400">Noch keine Vorlagen.</p>
) : (
<div className="space-y-1.5">
{templates.map((t) => (
<div
key={t.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">{t.name}</p>
{t.subject && <p className="truncate text-[11px] text-zinc-400">{t.subject}</p>}
</div>
{onInsert && (
<button
onClick={() => {
onInsert(t)
onClose()
}}
className="shrink-0 rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700 transition hover:bg-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-300"
>
Einfügen
</button>
)}
<button
onClick={() => startEdit(t)}
className="shrink-0 rounded-full p-1 text-zinc-400 transition hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800"
>
<Pencil size={13} />
</button>
<button
onClick={() => handleDelete(t)}
className="shrink-0 rounded-full p-1 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>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import { Send, Undo2 } from 'lucide-react'
function nowSec() {
return Math.floor(Date.now() / 1000)
}
export function UndoSendBanner({
deadline,
onUndo,
}: {
deadline: number
onUndo: () => void
}) {
const [remaining, setRemaining] = useState(() => Math.max(0, deadline - nowSec()))
useEffect(() => {
setRemaining(Math.max(0, deadline - nowSec()))
const interval = setInterval(() => setRemaining(Math.max(0, deadline - nowSec())), 500)
return () => clearInterval(interval)
}, [deadline])
const label =
remaining > 90
? `Wird gesendet um ${new Date(deadline * 1000).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`
: `Wird in ${remaining}s gesendet`
return (
<div className="animate-toast-in fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-full border border-white/10 bg-zinc-900/95 px-5 py-2.5 text-[13px] font-medium text-white shadow-[0_12px_35px_-8px_rgba(0,0,0,0.5)] backdrop-blur-xl dark:border-black/10 dark:bg-zinc-100/95 dark:text-zinc-900">
<Send size={14} className="shrink-0 text-zinc-400 dark:text-zinc-500" />
<span>{label}</span>
<button
onClick={onUndo}
className="flex shrink-0 items-center gap-1.5 rounded-full bg-white/10 px-3 py-1 text-[12.5px] font-semibold transition hover:bg-white/20 dark:bg-black/10 dark:hover:bg-black/20"
>
<Undo2 size={13} /> Rückgängig
</button>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
color-scheme: light dark;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html,
body,
#root {
height: 100%;
margin: 0;
overscroll-behavior: none;
}
* {
box-sizing: border-box;
}
::selection {
background: color-mix(in srgb, var(--color-indigo-500) 35%, transparent);
}
/* thin, unobtrusive scrollbars that match the theme */
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, currentColor 18%, transparent) transparent;
}
*::-webkit-scrollbar {
width: 9px;
height: 9px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: color-mix(in srgb, currentColor 16%, transparent);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, currentColor 28%, transparent);
background-clip: content-box;
}
@keyframes overlay-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes panel-in {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 10px) scale(0.96);
}
to {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
}
@keyframes row-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-overlay-in {
animation: overlay-in 180ms ease-out both;
}
.animate-panel-in {
animation: panel-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-toast-in {
animation: toast-in 260ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-row-in {
animation: row-in 240ms ease-out both;
}
.compose-body ul {
list-style: disc;
padding-left: 1.4em;
}
.compose-body ol {
list-style: decimal;
padding-left: 1.4em;
}
.compose-body a {
color: var(--color-indigo-600);
text-decoration: underline;
}
.compose-body blockquote {
margin: 6px 0;
padding-left: 10px;
border-left: 3px solid color-mix(in srgb, currentColor 25%, transparent);
color: color-mix(in srgb, currentColor 65%, transparent);
}
+11
View File
@@ -0,0 +1,11 @@
export function parseAddressList(raw: string): string[] {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const match = s.match(/<([^>]+)>/)
return (match ? match[1] : s).trim().toLowerCase()
})
.filter(Boolean)
}
+147
View File
@@ -0,0 +1,147 @@
import { invoke } from '@tauri-apps/api/core'
import type {
Account,
CaldavAccount,
ComposeMessage,
Contact,
ContactGroup,
DraftPayload,
Event,
Folder,
InviteInfo,
InviteResponse,
MessageFull,
MessageSummary,
NewAccount,
NewContact,
NewContactGroup,
NewEvent,
NewCaldavAccount,
NewRule,
NewTask,
NewTemplate,
NewVacationSettings,
Rule,
SnoozedMessage,
Task,
Template,
VacationSettings,
} from '../types'
export const api = {
addAccount: (newAccount: NewAccount) => invoke<Account>('add_account', { newAccount }),
listAccounts: () => invoke<Account[]>('list_accounts'),
deleteAccount: (accountId: string) => invoke<void>('delete_account', { accountId }),
updateAccount: (account: Account, newPassword?: string) =>
invoke<void>('update_account', { account, newPassword: newPassword || undefined }),
listFolders: (accountId: string) => invoke<Folder[]>('list_folders', { accountId }),
createFolder: (accountId: string, name: string) => invoke<void>('create_folder', { accountId, name }),
renameFolder: (accountId: string, from: string, to: string) => invoke<void>('rename_folder', { accountId, from, to }),
deleteFolder: (accountId: string, name: string) => invoke<void>('delete_folder', { accountId, name }),
markFolderRead: (accountId: string, folder: string) => invoke<number>('mark_folder_read', { accountId, folder }),
applyRulesToFolder: (accountId: string, folder: string) => invoke<number>('apply_rules_to_folder', { accountId, folder }),
syncMessages: (accountId: string, folder: string) =>
invoke<MessageSummary[]>('sync_messages', { accountId, folder }),
listCachedMessages: (accountId: string, folder: string) =>
invoke<MessageSummary[]>('list_cached_messages', { accountId, folder }),
getMessage: (accountId: string, folder: string, uid: number) =>
invoke<MessageFull>('get_message', { accountId, folder, uid }),
sendMessage: (message: ComposeMessage) => invoke<void>('send_message', { message }),
sendMessageAt: (message: ComposeMessage, sendAtTs: number) => invoke<string>('send_message_at', { message, sendAtTs }),
cancelScheduledSend: (id: string) => invoke<boolean>('cancel_scheduled_send', { id }),
markRead: (accountId: string, folder: string, uid: number, isRead: boolean) =>
invoke<void>('mark_read', { accountId, folder, uid, isRead }),
deleteMessage: (accountId: string, folder: string, uid: number) =>
invoke<void>('delete_message', { accountId, folder, uid }),
searchMessages: (accountId: string, query: string) =>
invoke<MessageSummary[]>('search_messages', { accountId, query }),
searchAllMessages: (query: string) => invoke<MessageSummary[]>('search_all_messages', { query }),
setMessageCategory: (accountId: string, folder: string, uid: number, category: string | null) =>
invoke<void>('set_message_category', { accountId, folder, uid, category }),
toggleFlag: (accountId: string, folder: string, uid: number, flagged: boolean) =>
invoke<void>('toggle_flag', { accountId, folder, uid, flagged }),
moveMessage: (accountId: string, folder: string, uid: number, targetFolder: string) =>
invoke<void>('move_message', { accountId, folder, uid, targetFolder }),
listContacts: () => invoke<Contact[]>('list_contacts'),
searchContacts: (query: string) => invoke<Contact[]>('search_contacts', { query }),
addContact: (newContact: NewContact) => invoke<Contact>('add_contact', { newContact }),
updateContact: (contact: Contact) => invoke<void>('update_contact', { contact }),
deleteContact: (contactId: string) => invoke<void>('delete_contact', { contactId }),
toggleContactFavorite: (contactId: string, favorite: boolean) =>
invoke<void>('toggle_contact_favorite', { contactId, favorite }),
exportContactsVcard: (path: string) => invoke<number>('export_contacts_vcard', { path }),
importContactsVcard: (path: string) => invoke<number>('import_contacts_vcard', { path }),
exportMessageEml: (accountId: string, folder: string, uid: number, path: string) =>
invoke<void>('export_message_eml', { accountId, folder, uid, path }),
exportFolderMbox: (accountId: string, folder: string, path: string) =>
invoke<number>('export_folder_mbox', { accountId, folder, path }),
exportFullBackup: (dir: string) => invoke<number>('export_full_backup', { dir }),
importEmlFile: (accountId: string, folder: string, path: string) =>
invoke<void>('import_eml_file', { accountId, folder, path }),
importMboxFile: (accountId: string, folder: string, path: string) =>
invoke<number>('import_mbox_file', { accountId, folder, path }),
saveDraft: (uid: number | null, message: ComposeMessage) =>
invoke<number>('save_draft', { uid, message }),
loadDraft: (uid: number) => invoke<DraftPayload>('load_draft', { uid }),
googleOAuthAddAccount: () => invoke<Account>('google_oauth_add_account'),
listRules: () => invoke<Rule[]>('list_rules'),
createRule: (newRule: NewRule) => invoke<Rule>('create_rule', { newRule }),
setRuleEnabled: (ruleId: string, enabled: boolean) => invoke<void>('set_rule_enabled', { ruleId, enabled }),
deleteRule: (ruleId: string) => invoke<void>('delete_rule', { ruleId }),
listEvents: (startTs: number, endTs: number) => invoke<Event[]>('list_events', { startTs, endTs }),
createEvent: (newEvent: NewEvent) => invoke<Event>('create_event', { newEvent }),
updateEvent: (event: Event) => invoke<void>('update_event', { event }),
deleteEvent: (eventId: string) => invoke<void>('delete_event', { eventId }),
updateEventOccurrence: (baseId: string, occurrenceStart: number, data: NewEvent) =>
invoke<Event>('update_event_occurrence', { baseId, occurrenceStart, data }),
deleteEventOccurrence: (baseId: string, occurrenceStart: number) =>
invoke<void>('delete_event_occurrence', { baseId, occurrenceStart }),
listCaldavAccounts: () => invoke<CaldavAccount[]>('list_caldav_accounts'),
addCaldavAccount: (newAccount: NewCaldavAccount) => invoke<CaldavAccount>('add_caldav_account', { newAccount }),
deleteCaldavAccount: (accountId: string) => invoke<void>('delete_caldav_account', { accountId }),
syncCaldavCalendar: (accountId: string) => invoke<number>('sync_caldav_calendar', { accountId }),
importIcsFile: (path: string) => invoke<number>('import_ics_file', { path }),
importIcsText: (text: string) => invoke<number>('import_ics_text', { text }),
previewInvite: (icsBase64: string) => invoke<InviteInfo | null>('preview_invite', { icsBase64 }),
replyToInvite: (accountId: string, icsBase64: string, response: InviteResponse) =>
invoke<void>('reply_to_invite', { accountId, icsBase64, response }),
getVacationSettings: (accountId: string) => invoke<VacationSettings>('get_vacation_settings', { accountId }),
saveVacationSettings: (accountId: string, settings: NewVacationSettings) =>
invoke<void>('save_vacation_settings', { accountId, settings }),
snoozeMessage: (accountId: string, folder: string, uid: number, untilTs: number) =>
invoke<void>('snooze_message', { accountId, folder, uid, untilTs }),
unsnoozeMessage: (accountId: string, folder: string, uid: number) =>
invoke<void>('unsnooze_message', { accountId, folder, uid }),
listSnoozedMessages: () => invoke<SnoozedMessage[]>('list_snoozed_messages'),
listTemplates: () => invoke<Template[]>('list_templates'),
createTemplate: (newTemplate: NewTemplate) => invoke<Template>('create_template', { newTemplate }),
updateTemplate: (template: Template) => invoke<void>('update_template', { template }),
deleteTemplate: (templateId: string) => invoke<void>('delete_template', { templateId }),
searchMessagesAdvanced: (
query: string,
from: string | undefined,
dateFrom: number | undefined,
dateTo: number | undefined,
hasAttachment: boolean,
) => invoke<MessageSummary[]>('search_messages_advanced', { query, from, dateFrom, dateTo, hasAttachment }),
exportEventsIcs: (path: string) => invoke<number>('export_events_ics', { path }),
getEvent: (eventId: string) => invoke<Event>('get_event', { eventId }),
listUpcomingReminders: (withinHours: number) => invoke<Event[]>('list_upcoming_reminders', { withinHours }),
syncGoogleCalendar: (accountId: string) => invoke<number>('sync_google_calendar', { accountId }),
syncGoogleContacts: (accountId: string) => invoke<number>('sync_google_contacts', { accountId }),
reconnectGoogleAccount: (accountId: string) => invoke<void>('reconnect_google_account', { accountId }),
setDesktopNotificationsEnabled: (enabled: boolean) =>
invoke<void>('set_desktop_notifications_enabled', { enabled }),
listTasks: () => invoke<Task[]>('list_tasks'),
createTask: (newTask: NewTask) => invoke<Task>('create_task', { newTask }),
updateTask: (task: Task) => invoke<void>('update_task', { task }),
deleteTask: (taskId: string) => invoke<void>('delete_task', { taskId }),
setTaskCompleted: (taskId: string, completed: boolean) =>
invoke<void>('set_task_completed', { taskId, completed }),
createTaskFromMessage: (accountId: string, folder: string, uid: number, title: string) =>
invoke<Task>('create_task_from_message', { accountId, folder, uid, title }),
listContactGroups: () => invoke<ContactGroup[]>('list_contact_groups'),
createContactGroup: (newGroup: NewContactGroup) => invoke<ContactGroup>('create_contact_group', { newGroup }),
updateContactGroup: (group: ContactGroup) => invoke<void>('update_contact_group', { group }),
deleteContactGroup: (groupId: string) => invoke<void>('delete_contact_group', { groupId }),
}
+19
View File
@@ -0,0 +1,19 @@
export interface CategoryColor {
id: string
label: string
hex: string
}
export const CATEGORY_COLORS: CategoryColor[] = [
{ id: 'red', label: 'Rot', hex: '#ef4444' },
{ id: 'orange', label: 'Orange', hex: '#f97316' },
{ id: 'yellow', label: 'Gelb', hex: '#eab308' },
{ id: 'green', label: 'Grün', hex: '#22c55e' },
{ id: 'blue', label: 'Blau', hex: '#3b82f6' },
{ id: 'purple', label: 'Lila', hex: '#a855f7' },
]
export function categoryHex(id: string | null | undefined): string | null {
if (!id) return null
return CATEGORY_COLORS.find((c) => c.id === id)?.hex ?? null
}
+42
View File
@@ -0,0 +1,42 @@
import { format, isThisYear, isToday } from 'date-fns'
const AVATAR_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#f59e0b',
'#10b981', '#06b6d4', '#3b82f6', '#ef4444',
]
export function initials(name: string): string {
const clean = name.trim()
if (!clean) return '?'
const parts = clean.split(/\s+/).filter(Boolean)
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
export function avatarColor(seed: string): string {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = seed.charCodeAt(i) + ((hash << 5) - hash)
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]
}
export function formatListDate(timestamp: number): string {
if (!timestamp) return ''
const date = new Date(timestamp * 1000)
if (isToday(date)) return format(date, 'HH:mm')
if (isThisYear(date)) return format(date, 'd. MMM')
return format(date, 'd. MMM yyyy')
}
export function formatFullDate(rawDate: string): string {
const date = new Date(rawDate)
if (Number.isNaN(date.getTime())) return rawDate
return format(date, 'EEEE, d. MMMM yyyy, HH:mm')
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+86
View File
@@ -0,0 +1,86 @@
import type { Protocol } from '../types'
export interface ProviderPreset {
id: string
label: string
color: string
protocol: Protocol
incoming_host: string
incoming_port: number
smtp_host: string
smtp_port: number
smtp_starttls: boolean
hint?: string
}
export const providerPresets: ProviderPreset[] = [
{
id: 'gmail',
label: 'Google Mail',
color: '#ea4335',
protocol: 'imap',
incoming_host: 'imap.gmail.com',
incoming_port: 993,
smtp_host: 'smtp.gmail.com',
smtp_port: 465,
smtp_starttls: false,
hint: 'Nutze ein App-Passwort (Google-Konto -> Sicherheit -> App-Passwörter), nicht dein normales Passwort.',
},
{
id: 'outlook',
label: 'Outlook / Microsoft 365',
color: '#0078d4',
protocol: 'imap',
incoming_host: 'outlook.office365.com',
incoming_port: 993,
smtp_host: 'smtp.office365.com',
smtp_port: 587,
smtp_starttls: true,
},
{
id: 'yahoo',
label: 'Yahoo Mail',
color: '#6001d2',
protocol: 'imap',
incoming_host: 'imap.mail.yahoo.com',
incoming_port: 993,
smtp_host: 'smtp.mail.yahoo.com',
smtp_port: 465,
smtp_starttls: false,
hint: 'Erfordert ein App-Passwort aus den Yahoo-Kontoeinstellungen.',
},
{
id: 'icloud',
label: 'iCloud Mail',
color: '#3693f3',
protocol: 'imap',
incoming_host: 'imap.mail.me.com',
incoming_port: 993,
smtp_host: 'smtp.mail.me.com',
smtp_port: 587,
smtp_starttls: true,
hint: 'Erfordert ein app-spezifisches Passwort von appleid.apple.com.',
},
{
id: 'custom-imap',
label: 'Custom IMAP',
color: '#64748b',
protocol: 'imap',
incoming_host: '',
incoming_port: 993,
smtp_host: '',
smtp_port: 587,
smtp_starttls: true,
},
{
id: 'custom-pop3',
label: 'Custom POP3',
color: '#64748b',
protocol: 'pop3',
incoming_host: '',
incoming_port: 995,
smtp_host: '',
smtp_port: 587,
smtp_starttls: true,
},
]
+7
View File
@@ -0,0 +1,7 @@
export function htmlIsEmpty(html: string): boolean {
return !html || html.replace(/<[^>]*>/g, '').trim().length === 0
}
export function escapeHtml(s: string): string {
return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c]!)
}
+28
View File
@@ -0,0 +1,28 @@
export interface AppSettings {
desktop: boolean
popup: boolean
composePopup: boolean
}
const KEY = 'mailclient.notifySettings'
const DEFAULTS: AppSettings = { desktop: true, popup: true, composePopup: false }
export function loadAppSettings(): AppSettings {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return DEFAULTS
const parsed = JSON.parse(raw) as Partial<AppSettings>
return {
desktop: parsed.desktop ?? DEFAULTS.desktop,
popup: parsed.popup ?? DEFAULTS.popup,
composePopup: parsed.composePopup ?? DEFAULTS.composePopup,
}
} catch {
return DEFAULTS
}
}
export function saveAppSettings(v: AppSettings) {
localStorage.setItem(KEY, JSON.stringify(v))
}
+92
View File
@@ -0,0 +1,92 @@
export interface Signature {
id: string
name: string
body: string
}
interface AccountSignatures {
signatures: Signature[]
defaultId: string | null
}
const KEY = 'mailclient.signatures.v2'
const LEGACY_KEY = 'mailclient.signatures'
function migrateLegacy(): Record<string, AccountSignatures> {
const out: Record<string, AccountSignatures> = {}
try {
const raw = localStorage.getItem(LEGACY_KEY)
if (!raw) return out
const legacy = JSON.parse(raw) as Record<string, string>
for (const [accountId, body] of Object.entries(legacy)) {
if (!body.trim()) continue
const id = crypto.randomUUID()
out[accountId] = { signatures: [{ id, name: 'Standard', body }], defaultId: id }
}
localStorage.setItem(KEY, JSON.stringify(out))
} catch {
// ignore malformed legacy data
}
return out
}
function loadAll(): Record<string, AccountSignatures> {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return migrateLegacy()
return JSON.parse(raw) as Record<string, AccountSignatures>
} catch {
return {}
}
}
function saveAll(all: Record<string, AccountSignatures>) {
localStorage.setItem(KEY, JSON.stringify(all))
}
export function listSignatures(accountId: string): Signature[] {
return loadAll()[accountId]?.signatures ?? []
}
export function getDefaultSignature(accountId: string): Signature | null {
const acc = loadAll()[accountId]
if (!acc || acc.signatures.length === 0) return null
return acc.signatures.find((s) => s.id === acc.defaultId) ?? acc.signatures[0]
}
export function getDefaultSignatureId(accountId: string): string | null {
return loadAll()[accountId]?.defaultId ?? null
}
export function saveSignatureList(accountId: string, signatures: Signature[], defaultId: string | null) {
const all = loadAll()
if (signatures.length === 0) {
delete all[accountId]
} else {
const resolvedDefault = signatures.some((s) => s.id === defaultId) ? defaultId : signatures[0].id
all[accountId] = { signatures, defaultId: resolvedDefault }
}
saveAll(all)
}
/// Back-compat helper: the single "signature text" a fresh compose should
/// start with — the account's default signature body, or empty.
export function loadSignature(accountId: string): string {
return getDefaultSignature(accountId)?.body ?? ''
}
/// Sets the body of the account's default signature (creating a "Standard"
/// signature if none exists yet), leaving any other named signatures intact.
/// Used by the simple single-field signature editor in account settings.
export function saveSignature(accountId: string, body: string) {
const existing = listSignatures(accountId)
const defaultId = getDefaultSignatureId(accountId)
const current = existing.find((s) => s.id === defaultId) ?? existing[0]
if (current) {
const next = existing.map((s) => (s.id === current.id ? { ...s, body } : s))
saveSignatureList(accountId, next, current.id)
} else if (body.trim()) {
const sig: Signature = { id: crypto.randomUUID(), name: 'Standard', body }
saveSignatureList(accountId, [sig], sig.id)
}
}
+52
View File
@@ -0,0 +1,52 @@
export type Theme = 'light' | 'dark' | 'system'
export type Density = 'comfortable' | 'compact'
const THEME_KEY = 'mailclient.theme'
const DENSITY_KEY = 'mailclient.density'
const FAVORITES_KEY = 'mailclient.favorites'
const CONVERSATIONS_KEY = 'mailclient.conversations'
export function loadTheme(): Theme {
const v = localStorage.getItem(THEME_KEY)
return v === 'light' || v === 'dark' || v === 'system' ? v : 'system'
}
export function saveTheme(t: Theme) {
localStorage.setItem(THEME_KEY, t)
}
export function applyTheme(t: Theme) {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const dark = t === 'dark' || (t === 'system' && prefersDark)
document.documentElement.classList.toggle('dark', dark)
}
export function loadDensity(): Density {
const v = localStorage.getItem(DENSITY_KEY)
return v === 'compact' ? 'compact' : 'comfortable'
}
export function saveDensity(d: Density) {
localStorage.setItem(DENSITY_KEY, d)
}
export function loadFavoritesRaw<T>(): T[] {
try {
const raw = localStorage.getItem(FAVORITES_KEY)
return raw ? (JSON.parse(raw) as T[]) : []
} catch {
return []
}
}
export function saveFavoritesRaw<T>(favorites: T[]) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites))
}
export function loadConversationsEnabled(): boolean {
return localStorage.getItem(CONVERSATIONS_KEY) === '1'
}
export function saveConversationsEnabled(enabled: boolean) {
localStorage.setItem(CONVERSATIONS_KEY, enabled ? '1' : '0')
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ComposeWindow } from './ComposeWindow.tsx'
const isComposeWindow = new URLSearchParams(window.location.search).has('compose')
createRoot(document.getElementById('root')!).render(
<StrictMode>{isComposeWindow ? <ComposeWindow /> : <App />}</StrictMode>,
)
+285
View File
@@ -0,0 +1,285 @@
export type Protocol = 'imap' | 'pop3' | 'local'
export type AuthMethod = 'password' | 'google_oauth'
export interface Account {
id: string
label: string
email: string
username: string
protocol: Protocol
incoming_host: string
incoming_port: number
smtp_host: string
smtp_port: number
smtp_starttls: boolean
auth_method: AuthMethod
}
export type RuleField = 'from' | 'subject'
export type RuleAction = 'move_to' | 'mark_read' | 'flag' | 'delete'
export type MatchType = 'all' | 'any'
export interface RuleCondition {
field: string
contains: string
}
export interface Rule {
id: string
account_id: string | null
conditions: RuleCondition[]
match_type: MatchType
action: RuleAction
action_value: string | null
enabled: boolean
}
export interface NewRule {
account_id: string | null
conditions: RuleCondition[]
match_type: MatchType
action: RuleAction
action_value?: string
}
export interface NewAccount {
label: string
email: string
username: string
password: string
protocol: Protocol
incoming_host: string
incoming_port: number
smtp_host: string
smtp_port: number
smtp_starttls: boolean
}
export interface Folder {
name: string
display_name: string
unread: number
}
export interface MessageSummary {
uid: number
account_id: string
folder: string
from_name: string
from_addr: string
subject: string
date: string
timestamp: number
snippet: string
is_read: boolean
has_attachments: boolean
flagged: boolean
message_id: string
thread_id: string
category: string | null
}
export interface AttachmentMeta {
filename: string
mime_type: string
size: number
content_base64: string
}
export interface MessageFull {
uid: number
account_id: string
folder: string
from_name: string
from_addr: string
to: string
subject: string
date: string
body_html: string | null
body_text: string | null
attachments: AttachmentMeta[]
}
export interface Contact {
id: string
name: string
email: string | null
phone: string | null
address: string | null
company: string | null
notes: string | null
favorite: boolean
google_account_id?: string | null
google_resource_name?: string | null
}
export interface NewContact {
name: string
email?: string | null
phone?: string | null
address?: string | null
company?: string
notes?: string
}
export interface ContactGroup {
id: string
name: string
member_ids: string[]
}
export interface NewContactGroup {
name: string
member_ids: string[]
}
export interface OutgoingAttachment {
filename: string
mime_type: string
content_base64: string
}
export interface DraftPayload {
uid: number
account_id: string | null
to: string
cc: string
subject: string
body: string
}
export interface Event {
id: string
base_id: string
title: string
location: string | null
description: string | null
start_ts: number
end_ts: number
all_day: boolean
account_id: string | null
google_event_id: string | null
rrule: string | null
reminder_minutes: number | null
category: string | null
exdates?: string | null
series_id?: string | null
}
export interface NewEvent {
title: string
location?: string
description?: string
start_ts: number
end_ts: number
all_day: boolean
account_id?: string | null
rrule?: string | null
reminder_minutes?: number | null
category?: string | null
}
export type TaskPriority = 'low' | 'normal' | 'high'
export interface Task {
id: string
title: string
description: string | null
due_ts: number | null
priority: TaskPriority
completed: boolean
created_ts: number
source_account_id: string | null
source_folder: string | null
source_uid: number | null
}
export interface NewTask {
title: string
description?: string | null
due_ts?: number | null
priority: TaskPriority
source_account_id?: string | null
source_folder?: string | null
source_uid?: number | null
}
export interface InviteInfo {
uid: string
sequence: number
organizer_email: string | null
organizer_name: string | null
summary: string
start_ts: number
end_ts: number
all_day: boolean
}
export type InviteResponse = 'accepted' | 'declined' | 'tentative'
export interface VacationSettings {
account_id: string
enabled: boolean
start_ts: number | null
end_ts: number | null
subject: string
body: string
}
export interface NewVacationSettings {
enabled: boolean
start_ts: number | null
end_ts: number | null
subject: string
body: string
}
export type SnoozedMessage = MessageSummary & { until_ts: number }
export interface CaldavAccount {
id: string
label: string
url: string
username: string
}
export interface NewCaldavAccount {
label: string
url: string
username: string
password: string
}
export interface AdvancedSearchFilters {
from: string
dateFrom: string
dateTo: string
hasAttachment: boolean
}
export interface Template {
id: string
name: string
subject: string
body: string
}
export interface NewTemplate {
name: string
subject: string
body: string
}
export interface ComposeMessage {
account_id: string
to: string
cc?: string
bcc?: string
subject: string
body_html: string
attachments: OutgoingAttachment[]
in_reply_to?: string
reply_to?: string
request_read_receipt?: boolean
importance?: 'high' | 'low'
}