feat: add destructive color variables and enhance unsubscribe banner functionality

This commit is contained in:
Linus Rath
2026-03-14 16:29:26 +01:00
parent 98cb3e158b
commit 1936a37f28
3 changed files with 120 additions and 56 deletions
+6
View File
@@ -16,6 +16,8 @@
--color-muted-foreground: #64748b;
--color-accent: #dbeafe;
--color-accent-foreground: #1e40af;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
/* Settings variables */
--font-size-base: 16px;
@@ -37,6 +39,8 @@
--color-muted-foreground: #a3a3a3;
--color-accent: #1e3a8a;
--color-accent-foreground: #dbeafe;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
}
@theme inline {
@@ -53,6 +57,8 @@
--color-muted-foreground: var(--color-muted-foreground);
--color-accent: var(--color-accent);
--color-accent-foreground: var(--color-accent-foreground);
--color-destructive: var(--color-destructive);
--color-destructive-foreground: var(--color-destructive-foreground);
}
* {
+28 -19
View File
@@ -1674,8 +1674,20 @@ export function EmailViewer({
<EmailIdentityBadge email={email} identities={identities} />
</div>
{sender?.email && (
<div className="text-sm text-muted-foreground mt-0.5 truncate">
{sender.email}
<div className="text-sm text-muted-foreground mt-0.5 flex items-center min-w-0">
<span className="truncate">{sender.email}</span>
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
)}
</div>
)}
</div>
@@ -2192,6 +2204,18 @@ export function EmailViewer({
{sender?.email && sender?.name && (
<>
<span className="truncate">{sender.email}</span>
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
)}
<span>·</span>
</>
)}
@@ -2216,9 +2240,8 @@ export function EmailViewer({
</div>
</div>
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */}
{/* Unified Notification Banner - External Content + Calendar Invitation */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe) ||
hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5">
@@ -2252,21 +2275,7 @@ export function EmailViewer({
</div>
)}
{/* Unsubscribe Controls */}
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<div className="flex items-center md:justify-center rounded-md px-3 py-1 bg-blue-50/50 dark:bg-blue-950/20">
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
setDismissedUnsubBanners(newSet);
localStorage.setItem('dismissed-unsub-banners', JSON.stringify([...newSet]));
}}
/>
</div>
)}
{/* Calendar Invitation Banner */}
{hasCalendarInvitation && (
+86 -37
View File
@@ -1,9 +1,11 @@
'use client';
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
interface UnsubscribeBannerProps {
listUnsubscribe: {
@@ -25,12 +27,31 @@ export function UnsubscribeBanner({
const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const isDesktop = useIsDesktop();
const unsubMethod = listUnsubscribe.preferred;
const unsubUrl = unsubMethod === 'http'
? listUnsubscribe.http
: listUnsubscribe.mailto;
useEffect(() => {
if (!showConfirm) return;
const handleClickOutside = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setShowConfirm(false);
}
};
// Use setTimeout to avoid the opening click triggering immediate close
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showConfirm]);
if (!unsubUrl || !unsubMethod) {
return null;
}
@@ -39,6 +60,7 @@ export function UnsubscribeBanner({
if (!isValidUnsubscribeUrl(unsubUrl)) {
setError(true);
setProcessing(false);
setShowConfirm(false);
return;
}
@@ -49,6 +71,7 @@ export function UnsubscribeBanner({
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
setSuccess(true);
setProcessing(false);
setShowConfirm(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
@@ -60,76 +83,102 @@ export function UnsubscribeBanner({
setSuccess(true);
setProcessing(false);
setShowConfirm(false);
setTimeout(onDismiss, 3000);
}
} catch (err) {
console.error('Unsubscribe error:', err);
setError(true);
setProcessing(false);
setShowConfirm(false);
}
};
if (success) {
return (
<div className="flex items-center gap-2">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1 ml-1">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-xs text-green-600 dark:text-green-400">
{t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
</span>
</div>
</span>
);
}
if (error) {
return (
<div className="flex items-center gap-2 flex-wrap">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{t('email_viewer.unsubscribe_banner.error')}
</span>
<span className="inline-flex items-center gap-1 ml-1">
<AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
<button
onClick={onDismiss}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
className="text-xs text-red-500 dark:text-red-400 hover:underline bg-transparent p-0 min-h-0"
>
{t('email_viewer.unsubscribe_banner.dismiss')}
{t('email_viewer.unsubscribe_banner.error')}
</button>
</div>
</span>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{showConfirm ? (
<>
<span className="text-sm text-muted-foreground">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</span>
<button
onClick={handleUnsubscribe}
disabled={processing}
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors disabled:opacity-50 disabled:cursor-not-allowed min-h-[44px] md:min-h-0"
>
{processing && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</>
) : (
<>
<span className="relative inline-flex items-center">
<span className="text-muted-foreground/40 mx-1">·</span>
<button
onClick={() => setShowConfirm(true)}
className="text-sm text-muted-foreground hover:text-foreground bg-transparent hover:bg-transparent transition-colors min-h-[44px] md:min-h-0"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline bg-transparent p-0 min-h-0 leading-normal"
>
{t('email_viewer.unsubscribe_banner.button')}
</button>
{/* Desktop popover */}
{showConfirm && isDesktop && (
<div
ref={popoverRef}
className="absolute top-full left-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-3 min-w-[220px]"
>
<p className="text-sm text-foreground mb-2">
{t('email_viewer.unsubscribe_banner.confirm_title')}
</p>
<div className="flex items-center gap-2">
<button
onClick={handleUnsubscribe}
disabled={processing}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md bg-destructive text-destructive-foreground hover:bg-destructive/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{processing && <Loader2 className="w-3 h-3 animate-spin" />}
{t('email_viewer.unsubscribe_banner.confirm_button')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="text-xs px-3 py-1.5 rounded-md text-muted-foreground hover:bg-muted transition-colors"
>
{t('email_viewer.unsubscribe_banner.cancel')}
</button>
</div>
</div>
)}
</span>
{/* Mobile/tablet: proper confirm dialog */}
{!isDesktop && (
<ConfirmDialog
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => {
setShowConfirm(false);
handleUnsubscribe();
}}
title={t('email_viewer.unsubscribe_banner.confirm_title')}
message={t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
)}
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
variant="destructive"
/>
)}
</div>
</>
);
}