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-muted-foreground: #64748b;
--color-accent: #dbeafe; --color-accent: #dbeafe;
--color-accent-foreground: #1e40af; --color-accent-foreground: #1e40af;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
/* Settings variables */ /* Settings variables */
--font-size-base: 16px; --font-size-base: 16px;
@@ -37,6 +39,8 @@
--color-muted-foreground: #a3a3a3; --color-muted-foreground: #a3a3a3;
--color-accent: #1e3a8a; --color-accent: #1e3a8a;
--color-accent-foreground: #dbeafe; --color-accent-foreground: #dbeafe;
--color-destructive: #ef4444;
--color-destructive-foreground: #fafafa;
} }
@theme inline { @theme inline {
@@ -53,6 +57,8 @@
--color-muted-foreground: var(--color-muted-foreground); --color-muted-foreground: var(--color-muted-foreground);
--color-accent: var(--color-accent); --color-accent: var(--color-accent);
--color-accent-foreground: var(--color-accent-foreground); --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} /> <EmailIdentityBadge email={email} identities={identities} />
</div> </div>
{sender?.email && ( {sender?.email && (
<div className="text-sm text-muted-foreground mt-0.5 truncate"> <div className="text-sm text-muted-foreground mt-0.5 flex items-center min-w-0">
{sender.email} <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>
)} )}
</div> </div>
@@ -2192,6 +2204,18 @@ export function EmailViewer({
{sender?.email && sender?.name && ( {sender?.email && sender?.name && (
<> <>
<span className="truncate">{sender.email}</span> <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> <span>·</span>
</> </>
)} )}
@@ -2216,9 +2240,8 @@ export function EmailViewer({
</div> </div>
</div> </div>
{/* Unified Notification Banner - External Content + Unsubscribe + Calendar Invitation */} {/* Unified Notification Banner - External Content + Calendar Invitation */}
{((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(shouldShowUnsubBanner && listHeaders?.listUnsubscribe) ||
hasCalendarInvitation) && ( hasCalendarInvitation) && (
<div className="border-b border-border bg-muted/30 isolate"> <div className="border-b border-border bg-muted/30 isolate">
<div className="max-w-4xl mx-auto px-6 py-1.5"> <div className="max-w-4xl mx-auto px-6 py-1.5">
@@ -2252,21 +2275,7 @@ export function EmailViewer({
</div> </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 */} {/* Calendar Invitation Banner */}
{hasCalendarInvitation && ( {hasCalendarInvitation && (
+86 -37
View File
@@ -1,9 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react'; import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation'; import { isValidUnsubscribeUrl } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
interface UnsubscribeBannerProps { interface UnsubscribeBannerProps {
listUnsubscribe: { listUnsubscribe: {
@@ -25,12 +27,31 @@ export function UnsubscribeBanner({
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const isDesktop = useIsDesktop();
const unsubMethod = listUnsubscribe.preferred; const unsubMethod = listUnsubscribe.preferred;
const unsubUrl = unsubMethod === 'http' const unsubUrl = unsubMethod === 'http'
? listUnsubscribe.http ? listUnsubscribe.http
: listUnsubscribe.mailto; : 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) { if (!unsubUrl || !unsubMethod) {
return null; return null;
} }
@@ -39,6 +60,7 @@ export function UnsubscribeBanner({
if (!isValidUnsubscribeUrl(unsubUrl)) { if (!isValidUnsubscribeUrl(unsubUrl)) {
setError(true); setError(true);
setProcessing(false); setProcessing(false);
setShowConfirm(false);
return; return;
} }
@@ -49,6 +71,7 @@ export function UnsubscribeBanner({
window.open(unsubUrl, '_blank', 'noopener,noreferrer'); window.open(unsubUrl, '_blank', 'noopener,noreferrer');
setSuccess(true); setSuccess(true);
setProcessing(false); setProcessing(false);
setShowConfirm(false);
setTimeout(onDismiss, 3000); setTimeout(onDismiss, 3000);
} else { } else {
const link = document.createElement('a'); const link = document.createElement('a');
@@ -60,76 +83,102 @@ export function UnsubscribeBanner({
setSuccess(true); setSuccess(true);
setProcessing(false); setProcessing(false);
setShowConfirm(false);
setTimeout(onDismiss, 3000); setTimeout(onDismiss, 3000);
} }
} catch (err) { } catch (err) {
console.error('Unsubscribe error:', err); console.error('Unsubscribe error:', err);
setError(true); setError(true);
setProcessing(false); setProcessing(false);
setShowConfirm(false);
} }
}; };
if (success) { if (success) {
return ( return (
<div className="flex items-center gap-2"> <span className="inline-flex items-center gap-1 ml-1">
<CheckCircle className="w-3.5 h-3.5 text-green-600 dark:text-green-400" /> <CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-sm text-muted-foreground"> <span className="text-xs text-green-600 dark:text-green-400">
{t(unsubMethod === 'http' {t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http' ? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto' : 'email_viewer.unsubscribe_banner.success_mailto'
)} )}
</span> </span>
</div> </span>
); );
} }
if (error) { if (error) {
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <span className="inline-flex items-center gap-1 ml-1">
<AlertCircle className="w-3.5 h-3.5 text-red-600 dark:text-red-400" /> <AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
<span className="text-sm text-red-600 dark:text-red-400">
{t('email_viewer.unsubscribe_banner.error')}
</span>
<button <button
onClick={onDismiss} 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> </button>
</div> </span>
); );
} }
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <>
{showConfirm ? ( <span className="relative inline-flex items-center">
<> <span className="text-muted-foreground/40 mx-1">·</span>
<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>
</>
) : (
<button <button
onClick={() => setShowConfirm(true)} 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')} {t('email_viewer.unsubscribe_banner.button')}
</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> </>
); );
} }