feat: Gitea Webhook, IMAP, Settings & Deployment docs
- Webhook route and Gitea integration - IMAP service and Nextcloud/Porkbun setup docs - Settings UI improvements and API updates - SSH/Webhook fix prompt for emailsorter.webklar.com - Bootstrap, config and AI sorter updates
This commit is contained in:
@@ -905,10 +905,10 @@ export function Dashboard() {
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className={`w-6 h-6 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : 'bg-blue-100 dark:bg-blue-900/50'
|
||||
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : account.provider === 'outlook' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-700/50'
|
||||
}`}>
|
||||
<Mail className={`w-3 h-3 ${
|
||||
account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'
|
||||
account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : account.provider === 'outlook' ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400'
|
||||
}`} />
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-700 dark:text-slate-300 truncate">{account.email}</p>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { api } from '@/lib/api'
|
||||
import {
|
||||
Mail,
|
||||
User,
|
||||
Users,
|
||||
CreditCard,
|
||||
Shield,
|
||||
Settings as SettingsIcon,
|
||||
@@ -54,15 +55,15 @@ import {
|
||||
Save,
|
||||
Edit2,
|
||||
} from 'lucide-react'
|
||||
import type { AIControlSettings, CompanyLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings'
|
||||
import type { AIControlSettings, CompanyLabel, NameLabel, CategoryInfo, CleanupSettings, CleanupStatus, CategoryAdvanced } from '@/types/settings'
|
||||
import { PrivacySecurity } from '@/components/PrivacySecurity'
|
||||
|
||||
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'subscription' | 'privacy' | 'referrals'
|
||||
type TabType = 'profile' | 'accounts' | 'vip' | 'ai-control' | 'name-labels' | 'subscription' | 'privacy' | 'referrals'
|
||||
|
||||
interface EmailAccount {
|
||||
id: string
|
||||
email: string
|
||||
provider: 'gmail' | 'outlook'
|
||||
provider: 'gmail' | 'outlook' | 'imap'
|
||||
connected: boolean
|
||||
lastSync?: string
|
||||
}
|
||||
@@ -97,6 +98,9 @@ export function Settings() {
|
||||
const savedProfileRef = useRef<{ name: string; language: string; timezone: string } | null>(null)
|
||||
const [accounts, setAccounts] = useState<EmailAccount[]>([])
|
||||
const [connectingProvider, setConnectingProvider] = useState<string | null>(null)
|
||||
const [showImapForm, setShowImapForm] = useState(false)
|
||||
const [imapForm, setImapForm] = useState({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||||
const [imapConnecting, setImapConnecting] = useState(false)
|
||||
const [vipSenders, setVipSenders] = useState<VIPSender[]>([])
|
||||
const [newVipEmail, setNewVipEmail] = useState('')
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
@@ -126,6 +130,10 @@ export function Settings() {
|
||||
})
|
||||
const [categories, setCategories] = useState<CategoryInfo[]>([])
|
||||
const [companyLabels, setCompanyLabels] = useState<CompanyLabel[]>([])
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [nameLabels, setNameLabels] = useState<NameLabel[]>([])
|
||||
const [editingNameLabel, setEditingNameLabel] = useState<NameLabel | null>(null)
|
||||
const [showNameLabelPanel, setShowNameLabelPanel] = useState(false)
|
||||
const [referralData, setReferralData] = useState<{ referralCode: string; referralCount: number } | null>(null)
|
||||
const [loadingReferral, setLoadingReferral] = useState(false)
|
||||
|
||||
@@ -185,16 +193,24 @@ export function Settings() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes] = await Promise.all([
|
||||
const [accountsRes, subsRes, prefsRes, aiControlRes, companyLabelsRes, meRes] = await Promise.all([
|
||||
api.getEmailAccounts(user.$id),
|
||||
api.getSubscriptionStatus(user.$id),
|
||||
api.getUserPreferences(user.$id),
|
||||
api.getAIControlSettings(user.$id),
|
||||
api.getCompanyLabels(user.$id),
|
||||
user?.email ? api.getMe(user.email) : Promise.resolve({ data: { isAdmin: false } }),
|
||||
])
|
||||
|
||||
if (accountsRes.data) setAccounts(accountsRes.data)
|
||||
if (subsRes.data) setSubscription(subsRes.data)
|
||||
if (meRes.data?.isAdmin) {
|
||||
setIsAdmin(true)
|
||||
const nameLabelsRes = await api.getNameLabels(user.$id, user.email)
|
||||
if (nameLabelsRes.data) setNameLabels(nameLabelsRes.data)
|
||||
} else {
|
||||
setIsAdmin(false)
|
||||
}
|
||||
if (prefsRes.data?.vipSenders) setVipSenders(prefsRes.data.vipSenders)
|
||||
if (aiControlRes.data) {
|
||||
// Merge cleanup defaults if not present
|
||||
@@ -478,6 +494,31 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleConnectImap = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!user?.$id || !imapForm.email.trim() || !imapForm.password) return
|
||||
setImapConnecting(true)
|
||||
const res = await api.connectImapAccount(user.$id, {
|
||||
email: imapForm.email.trim(),
|
||||
password: imapForm.password,
|
||||
imapHost: imapForm.imapHost || undefined,
|
||||
imapPort: imapForm.imapPort || 993,
|
||||
imapSecure: imapForm.imapSecure,
|
||||
})
|
||||
if (res.error) {
|
||||
const msg = res.error.message || 'Connection failed'
|
||||
showMessage('error', msg.includes('credentials') || msg.includes('auth') || msg.includes('password') ? 'Login failed – check email and password' : msg)
|
||||
setImapConnecting(false)
|
||||
return
|
||||
}
|
||||
const list = await api.getEmailAccounts(user.$id)
|
||||
setAccounts(list.data ?? [])
|
||||
setShowImapForm(false)
|
||||
setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true })
|
||||
showMessage('success', 'IMAP account connected')
|
||||
setImapConnecting(false)
|
||||
}
|
||||
|
||||
const handleAddVip = () => {
|
||||
if (!newVipEmail.trim() || !newVipEmail.includes('@')) return
|
||||
|
||||
@@ -535,14 +576,18 @@ export function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile' as TabType, label: 'Profile', icon: User },
|
||||
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
|
||||
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
|
||||
{ id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain },
|
||||
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
|
||||
{ id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock },
|
||||
]
|
||||
const tabs = useMemo(() => {
|
||||
const base = [
|
||||
{ id: 'profile' as TabType, label: 'Profile', icon: User },
|
||||
{ id: 'accounts' as TabType, label: 'Email Accounts', icon: Mail },
|
||||
{ id: 'vip' as TabType, label: 'VIP List', icon: Star },
|
||||
{ id: 'ai-control' as TabType, label: 'Control Panel', icon: Brain },
|
||||
...(isAdmin ? [{ id: 'name-labels' as TabType, label: 'Name Labels (Team)', icon: Users }] : []),
|
||||
{ id: 'subscription' as TabType, label: 'Subscription', icon: CreditCard },
|
||||
{ id: 'privacy' as TabType, label: 'Privacy & Security', icon: Lock },
|
||||
]
|
||||
return base
|
||||
}, [isAdmin])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
@@ -853,13 +898,13 @@ export function Settings() {
|
||||
<div key={account.id} className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800/50 rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
|
||||
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : 'bg-blue-100 dark:bg-blue-900/50'
|
||||
account.provider === 'gmail' ? 'bg-red-100 dark:bg-red-900/50' : account.provider === 'outlook' ? 'bg-blue-100 dark:bg-blue-900/50' : 'bg-slate-100 dark:bg-slate-700/50'
|
||||
}`}>
|
||||
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'}`} />
|
||||
<Mail className={`w-6 h-6 ${account.provider === 'gmail' ? 'text-red-600 dark:text-red-400' : account.provider === 'outlook' ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{account.email}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 capitalize">{account.provider === 'imap' ? 'IMAP' : account.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -928,7 +973,100 @@ export function Settings() {
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Connect Microsoft account</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImapForm(!showImapForm)}
|
||||
className="flex items-center gap-4 p-4 border-2 border-slate-200 dark:border-slate-700 rounded-xl hover:border-slate-300 dark:hover:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-all"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
<Mail className="w-6 h-6 text-slate-600 dark:text-slate-400" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">IMAP / Other</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Porkbun, Nextcloud Mail, or any IMAP</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showImapForm && (
|
||||
<form onSubmit={handleConnectImap} className="mt-6 p-4 border border-slate-200 dark:border-slate-700 rounded-xl bg-slate-50 dark:bg-slate-800/50 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="imap-email" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
|
||||
<Input
|
||||
id="imap-email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={imapForm.email}
|
||||
onChange={(e) => setImapForm((f) => ({ ...f, email: e.target.value }))}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="bg-white dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imap-password" className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Password / App password</label>
|
||||
<Input
|
||||
id="imap-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={imapForm.password}
|
||||
onChange={(e) => setImapForm((f) => ({ ...f, password: e.target.value }))}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="bg-white dark:bg-slate-900"
|
||||
/>
|
||||
</div>
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200">Advanced (host, port, SSL)</summary>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label htmlFor="imap-host" className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">IMAP host</label>
|
||||
<Input
|
||||
id="imap-host"
|
||||
type="text"
|
||||
placeholder="imap.porkbun.com"
|
||||
value={imapForm.imapHost}
|
||||
onChange={(e) => setImapForm((f) => ({ ...f, imapHost: e.target.value }))}
|
||||
className="bg-white dark:bg-slate-900 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="imap-port" className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Port</label>
|
||||
<Input
|
||||
id="imap-port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={imapForm.imapPort}
|
||||
onChange={(e) => setImapForm((f) => ({ ...f, imapPort: Number(e.target.value) || 993 }))}
|
||||
className="bg-white dark:bg-slate-900 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2 pb-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-slate-600 dark:text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={imapForm.imapSecure}
|
||||
onChange={(e) => setImapForm((f) => ({ ...f, imapSecure: e.target.checked }))}
|
||||
className="rounded border-slate-300 dark:border-slate-600"
|
||||
/>
|
||||
Use SSL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={imapConnecting}>
|
||||
{imapConnecting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||
Connect IMAP
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => { setShowImapForm(false); setImapForm({ email: '', password: '', imapHost: 'imap.porkbun.com', imapPort: 993, imapSecure: true }); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -2045,6 +2183,226 @@ export function Settings() {
|
||||
{editingLabel?.id ? 'Save Changes' : 'Create Label'}
|
||||
</Button>
|
||||
</SidePanelFooter>
|
||||
</SidePanelContent>
|
||||
</SidePanel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'name-labels' && isAdmin && (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-primary-500 dark:text-primary-400" />
|
||||
<CardTitle>Name Labels (Team)</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Personal labels for each team member. The AI will assign emails to a worker when they are clearly for that person (e.g. "für Max", "an Anna", subject/body mentions).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{nameLabels.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{nameLabels.map((label) => (
|
||||
<div
|
||||
key={label.id || label.name}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">{label.name}</p>
|
||||
{label.email && (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{label.email}</p>
|
||||
)}
|
||||
{label.keywords?.length ? (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
Keywords: {label.keywords.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingNameLabel({ ...label })
|
||||
setShowNameLabelPanel(true)
|
||||
}}
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !label.id) return
|
||||
try {
|
||||
await api.saveNameLabel(user.$id, user.email, { ...label, enabled: !label.enabled })
|
||||
setNameLabels(nameLabels.map(l => l.id === label.id ? { ...l, enabled: !l.enabled } : l))
|
||||
showMessage('success', 'Label updated!')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to update label')
|
||||
}
|
||||
}}
|
||||
className={`w-10 h-6 rounded-full transition-colors ${label.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
title={label.enabled ? 'Disable' : 'Enable'}
|
||||
>
|
||||
<div className={`w-4 h-4 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-1 ${
|
||||
label.enabled ? 'translate-x-4' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !label.id) return
|
||||
if (!confirm('Delete this name label?')) return
|
||||
try {
|
||||
await api.deleteNameLabel(user.$id, user.email, label.id)
|
||||
setNameLabels(nameLabels.filter(l => l.id !== label.id))
|
||||
showMessage('success', 'Label deleted!')
|
||||
} catch {
|
||||
showMessage('error', 'Failed to delete label')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingNameLabel({ name: '', enabled: true })
|
||||
setShowNameLabelPanel(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add team member
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
|
||||
<Users className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="font-medium">No name labels yet</p>
|
||||
<p className="text-sm mt-1">Add team members so the AI can assign emails to the right person</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => {
|
||||
setEditingNameLabel({ name: '', enabled: true })
|
||||
setShowNameLabelPanel(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add team member
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Name Label Editor Side Panel */}
|
||||
<SidePanel open={showNameLabelPanel} onOpenChange={setShowNameLabelPanel}>
|
||||
<SidePanelContent>
|
||||
<SidePanelHeader>
|
||||
<SidePanelCloseButton />
|
||||
<SidePanelTitle>
|
||||
{editingNameLabel?.id ? 'Edit Name Label' : 'Add Team Member'}
|
||||
</SidePanelTitle>
|
||||
<SidePanelDescription>
|
||||
{editingNameLabel?.id
|
||||
? 'Update the name label'
|
||||
: 'Add a team member. The AI will assign emails to this person when they are clearly for them (e.g. "für Max", subject mentions).'}
|
||||
</SidePanelDescription>
|
||||
</SidePanelHeader>
|
||||
{editingNameLabel && (
|
||||
<SidePanelBody>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Label htmlFor="namelabel-name">Name</Label>
|
||||
<Input
|
||||
id="namelabel-name"
|
||||
placeholder="e.g. Max, Anna"
|
||||
value={editingNameLabel.name}
|
||||
onChange={(e) => setEditingNameLabel({ ...editingNameLabel, name: e.target.value })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namelabel-email">Email (optional)</Label>
|
||||
<Input
|
||||
id="namelabel-email"
|
||||
type="email"
|
||||
placeholder="max@company.com"
|
||||
value={editingNameLabel.email || ''}
|
||||
onChange={(e) => setEditingNameLabel({ ...editingNameLabel, email: e.target.value || undefined })}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="namelabel-keywords">Keywords (optional, comma-separated)</Label>
|
||||
<Input
|
||||
id="namelabel-keywords"
|
||||
placeholder="für Max, an Max, Max bitte"
|
||||
value={(editingNameLabel.keywords || []).join(', ')}
|
||||
onChange={(e) => setEditingNameLabel({
|
||||
...editingNameLabel,
|
||||
keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean),
|
||||
})}
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">Hints for the AI to recognize emails for this person</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
|
||||
<div>
|
||||
<Label className="font-medium">Enabled</Label>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">This label will be used when sorting</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingNameLabel({ ...editingNameLabel, enabled: !editingNameLabel.enabled })}
|
||||
className={`w-12 h-6 rounded-full transition-colors ${editingNameLabel.enabled ? 'bg-primary-500 dark:bg-primary-600' : 'bg-slate-300 dark:bg-slate-600'}`}
|
||||
>
|
||||
<div className={`w-5 h-5 bg-white dark:bg-slate-200 rounded-full transform transition-transform mx-0.5 ${
|
||||
editingNameLabel.enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SidePanelBody>
|
||||
)}
|
||||
<SidePanelFooter>
|
||||
<Button variant="secondary" onClick={() => { setShowNameLabelPanel(false); setEditingNameLabel(null) }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!user?.$id || !editingNameLabel?.name?.trim()) {
|
||||
showMessage('error', 'Please enter a name')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await api.saveNameLabel(user.$id, user.email, editingNameLabel)
|
||||
if (saved.data) {
|
||||
if (editingNameLabel.id) {
|
||||
setNameLabels(nameLabels.map(l => l.id === editingNameLabel.id ? (saved.data || l) : l))
|
||||
showMessage('success', 'Label updated!')
|
||||
} else {
|
||||
setNameLabels([...nameLabels, saved.data])
|
||||
showMessage('success', 'Label created!')
|
||||
}
|
||||
setShowNameLabelPanel(false)
|
||||
setEditingNameLabel(null)
|
||||
}
|
||||
} catch {
|
||||
showMessage('error', editingNameLabel.id ? 'Failed to update' : 'Failed to create')
|
||||
}
|
||||
}}
|
||||
>
|
||||
{editingNameLabel?.id ? 'Save Changes' : 'Add'}
|
||||
</Button>
|
||||
</SidePanelFooter>
|
||||
</SidePanelContent>
|
||||
</SidePanel>
|
||||
</div>
|
||||
|
||||
@@ -464,6 +464,11 @@ export function Setup() {
|
||||
<ChevronRight className="w-5 h-5 text-slate-400 dark:text-slate-500 group-hover:text-blue-500 dark:group-hover:text-blue-400 group-hover:translate-x-1 transition-all" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-4">
|
||||
Using Porkbun, Nextcloud Mail, or another IMAP provider?{' '}
|
||||
<Link to="/settings?tab=accounts" className="text-primary-600 dark:text-primary-400 hover:underline">Add your account in Settings → Accounts</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 p-4 bg-slate-50 dark:bg-slate-800/50 rounded-xl max-w-lg mx-auto">
|
||||
|
||||
Reference in New Issue
Block a user