feat: inline search filters, folder icon picker, richer demo data & UI polish

- Replace advanced search panel with inline filter chips and collapsible fields
  (from/to/subject/body inputs, folder dropdown, attachment/flagged/read toggles)
- Add debounced auto-search on filter field changes
- Folder settings: add icon picker for custom per-folder icons, toast feedback,
  reorder sections (folder list first, roles second), empty-state illustration
- Avatar: show sender favicon for company domains, randomuser.me portraits for
  personal emails, custom avatars for demo senders, fallback to initials
- Add /api/favicon proxy route for fetching domain favicons
- Settings store: add senderFavicons toggle and folderIcons persistence
- Richer mock inbox with 11 realistic emails (GitHub, Slack, Stripe, Vercel, etc.)
- Navigation rail & sidebar cleanup, permanent-delete warning i18n (all 8 locales)
- Rename search toggle label to 'More', add body/folder filter translations
This commit is contained in:
Linus Rath
2026-03-09 20:59:32 +01:00
parent d41718768b
commit 8985865843
21 changed files with 950 additions and 341 deletions
+1 -4
View File
@@ -102,10 +102,7 @@ export function EmailListItem({ email, selected, onClick, onContextMenu }: Email
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
<div className="flex items-start gap-3 px-4 py-3">
{/* Checkbox with smooth animation */}
<button
onClick={handleCheckboxClick}
+2 -33
View File
@@ -4,7 +4,7 @@ import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { Inbox, Trash2, Mail, MailOpen, Loader2 } from "lucide-react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -72,7 +72,6 @@ export function EmailList({
loadMoreEmails,
hasMoreEmails,
isLoadingMore,
totalEmails,
mailboxes,
selectedMailbox,
expandedThreadIds,
@@ -133,7 +132,6 @@ export function EmailList({
);
const hasSelection = selectedEmailIds.size > 0;
const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id));
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
@@ -300,36 +298,7 @@ export function EmailList({
/>
)}
{/* List Header */}
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
className={cn(
"p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-110",
"active:scale-95",
allSelected && "text-primary"
)}
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4" />
)}
</button>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails !== undefined && totalEmails > threadGroups.length
? t('conversations_count', { count: threadGroups.length, total: totalEmails })
: hasMoreEmails
? t('conversations_count_plus', { count: threadGroups.length })
: t('conversations_count_simple', { count: threadGroups.length }))
: t('no_conversations')}
</h2>
</div>
</div>
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
+2 -10
View File
@@ -96,12 +96,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
<div className="w-6 flex-shrink-0" />
<div className="flex items-start gap-3 px-3 py-3">
{isUnread && (
<div className="absolute left-1 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-blue-600 text-blue-600 dark:fill-blue-400 dark:text-blue-400" />
@@ -275,10 +270,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onContextMenu={handleContextMenu}
style={{ minHeight: 'var(--list-item-height)' }}
>
<div className="flex items-start gap-3 px-4" style={{
paddingTop: 'calc((var(--list-item-height) - 40px) / 2)',
paddingBottom: 'calc((var(--list-item-height) - 40px) / 2)'
}}>
<div className="flex items-start gap-3 px-3 py-3">
{!isMobile && (
<button
data-expand-toggle
+28 -8
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import { Mail, Calendar, BookUser, Settings, LogOut } from "lucide-react";
import { usePathname, Link } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
@@ -29,16 +30,33 @@ interface NavigationRailProps {
function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; total: number }; usagePercent: number }) {
const t = useTranslations("sidebar");
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
const updatePosition = useCallback(() => {
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
setPopoverStyle({
position: "fixed",
left: rect.right + 8,
bottom: window.innerHeight - rect.bottom,
});
}, []);
useEffect(() => {
if (!open) return;
updatePosition();
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
if (
buttonRef.current?.contains(e.target as Node) ||
popoverRef.current?.contains(e.target as Node)
) return;
setOpen(false);
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
}, [open, updatePosition]);
const free = quota.total - quota.used;
const strokeColor = usagePercent > 90
@@ -48,8 +66,9 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
: "stroke-green-500 dark:stroke-green-400";
return (
<div className="relative" ref={ref}>
<div className="relative">
<button
ref={buttonRef}
onClick={() => setOpen(!open)}
className="relative w-8 h-8 flex items-center justify-center rounded-full hover:bg-muted transition-colors cursor-pointer"
aria-label={t("storage")}
@@ -69,8 +88,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
</span>
</button>
{open && (
<div className="absolute left-full bottom-0 ml-2 w-52 rounded-lg border border-border bg-popover text-popover-foreground shadow-lg p-3 z-50">
{open && createPortal(
<div ref={popoverRef} style={popoverStyle} className="w-52 rounded-lg border border-border bg-background text-foreground shadow-lg p-3 z-50">
<p className="text-xs font-semibold mb-2">{t("storage")}</p>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
@@ -102,7 +121,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
<p className="text-[10px] text-muted-foreground mt-1 tabular-nums">
{Math.round(usagePercent)}% {t("storage_used").toLowerCase()}
</p>
</div>
</div>,
document.body
)}
</div>
);
+39 -99
View File
@@ -4,7 +4,6 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Inbox,
Send,
@@ -13,8 +12,8 @@ import {
Trash2,
Archive,
PenSquare,
Search,
Menu,
ChevronsLeft,
ChevronsRight,
ChevronRight,
ChevronDown,
Folder,
@@ -22,7 +21,6 @@ import {
Users,
User,
Palmtree,
SlidersHorizontal,
Settings,
X,
} from "lucide-react";
@@ -30,9 +28,8 @@ import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { useEmailStore } from "@/stores/email-store";
import { useUIStore } from "@/stores/ui-store";
import { activeFilterCount } from "@/lib/jmap/search-utils";
import { useAuthStore } from "@/stores/auth-store";
import { useVacationStore } from "@/stores/vacation-store";
import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
@@ -43,9 +40,6 @@ interface SidebarProps {
onMailboxSelect?: (mailboxId: string) => void;
onCompose?: () => void;
onSidebarClose?: () => void;
onSearch?: (query: string) => void;
onClearSearch?: () => void;
activeSearchQuery?: string;
className?: string;
}
@@ -126,7 +120,8 @@ function MailboxTreeItem({
<div
{...(globalDragging ? dropHandlers : {})}
className={cn(
"group w-full flex items-center px-2 py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
"group w-full flex items-center py-1 lg:py-1 max-lg:py-3 max-lg:min-h-[44px] text-sm transition-all duration-200",
isCollapsed ? "justify-center px-1" : "px-2",
isVirtualNode
? "text-muted-foreground"
: selectedMailbox === node.id
@@ -137,7 +132,7 @@ function MailboxTreeItem({
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)}
>
{hasChildren && (
{hasChildren && !isCollapsed && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -162,17 +157,19 @@ function MailboxTreeItem({
onClick={() => !isVirtualNode && onMailboxSelect?.(node.id)}
disabled={isVirtualNode}
className={cn(
"flex-1 flex items-center text-left py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"flex items-center py-1 lg:py-1 max-lg:py-2 px-1 rounded",
"transition-colors duration-150",
isCollapsed ? "justify-center" : "flex-1 text-left",
isVirtualNode && "cursor-default select-none"
)}
style={{
style={isCollapsed ? undefined : {
paddingLeft: hasChildren ? '4px' : `${indentPixels + 24}px`
}}
title={isCollapsed ? node.name : undefined}
>
<Icon className={cn(
"w-4 h-4 mr-2 flex-shrink-0 transition-colors",
"w-4 h-4 flex-shrink-0 transition-colors",
!isCollapsed && "mr-2",
hasChildren && isExpanded && "text-primary",
selectedMailbox === node.id && "text-accent-foreground",
!hasChildren && node.depth > 0 && "text-muted-foreground",
@@ -243,53 +240,19 @@ function VacationBanner() {
);
}
function AdvancedSearchToggle() {
const tSearch = useTranslations("advanced_search");
const { searchFilters, isAdvancedSearchOpen, toggleAdvancedSearch } = useEmailStore();
const filterCount = activeFilterCount(searchFilters);
return (
<button
type="button"
onClick={toggleAdvancedSearch}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
isAdvancedSearchOpen || filterCount > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={tSearch("toggle_filters")}
>
<SlidersHorizontal className="w-4 h-4" />
{filterCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 text-[10px] font-bold rounded-full bg-primary text-primary-foreground">
{filterCount}
</span>
)}
</button>
);
}
export function Sidebar({
mailboxes = [],
selectedMailbox = "",
onMailboxSelect,
onCompose,
onSidebarClose,
onSearch,
onClearSearch,
activeSearchQuery = "",
className,
}: SidebarProps) {
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
const [searchQuery, setSearchQuery] = useState("");
const { primaryIdentity } = useAuthStore();
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const t = useTranslations('sidebar');
useEffect(() => {
setSearchQuery(activeSearchQuery);
}, [activeSearchQuery]);
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
if (stored) {
@@ -323,13 +286,6 @@ export function Sidebar({
});
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim() && onSearch) {
onSearch(searchQuery);
}
};
const mailboxTree = buildMailboxTree(mailboxes);
useEffect(() => {
@@ -369,12 +325,12 @@ export function Sidebar({
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
"max-lg:w-full",
isCollapsed ? "lg:w-16" : "lg:w-full",
isCollapsed ? "lg:w-12" : "lg:w-full",
className
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-border">
<div className={cn("flex items-center border-b border-border", isCollapsed ? "justify-center px-2 py-3" : "gap-2 px-4 py-3")}>
<Button
variant="ghost"
size="icon"
@@ -389,55 +345,27 @@ export function Sidebar({
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
className="hidden lg:flex"
className="hidden lg:flex flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
<Menu className="w-5 h-5" />
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
</Button>
{!isCollapsed && (
<Button onClick={onCompose} className="flex-1" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
{!isCollapsed && primaryIdentity && (
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate" title={primaryIdentity.name}>
{primaryIdentity.name}
</p>
<p className="text-xs text-muted-foreground truncate" title={primaryIdentity.email}>
{primaryIdentity.email}
</p>
</div>
)}
</div>
{/* Vacation Banner */}
{!isCollapsed && <VacationBanner />}
{/* Search + Advanced Filter Toggle */}
{!isCollapsed && (
<div className="px-4 py-3">
<div className="flex items-center gap-1.5">
<form onSubmit={handleSearch} className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("search_placeholder_hint")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9", searchQuery && "pr-8")}
data-search-input
/>
{searchQuery && (
<button
type="button"
onClick={() => {
setSearchQuery("");
onClearSearch?.();
}}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('clear_search')}
>
<X className="w-4 h-4" />
</button>
)}
</form>
<AdvancedSearchToggle />
</div>
</div>
)}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div className="py-1">
@@ -463,7 +391,19 @@ export function Sidebar({
</div>
</div>
{/* Footer removed - storage quota and sign out moved to NavigationRail */}
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
)}
</div>
</div>
);
}
+17 -9
View File
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { TrustedSendersModal } from '@/components/trusted-senders-modal';
import { ChevronRight } from 'lucide-react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
export function EmailSettings() {
const t = useTranslations('settings.email_behavior');
@@ -47,14 +47,22 @@ export function EmailSettings() {
{/* Delete Action */}
<SettingItem label={t('delete_action.label')} description={t('delete_action.description')}>
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
<div className="flex flex-col gap-2">
<Select
value={deleteAction}
onChange={(value) => updateSetting('deleteAction', value as 'trash' | 'permanent')}
options={[
{ value: 'trash', label: t('delete_action.trash') },
{ value: 'permanent', label: t('delete_action.permanent') },
]}
/>
{deleteAction === 'permanent' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-destructive/10 text-destructive text-xs">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{t('delete_action.warning')}</span>
</div>
)}
</div>
</SettingItem>
{/* Show Preview */}
+285 -124
View File
@@ -1,28 +1,113 @@
"use client";
import { useState } from 'react';
import { useState, useRef, useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useEmailStore } from '@/stores/email-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { toast } from '@/stores/toast-store';
import { SettingsSection, SettingItem, Select } from './settings-section';
import { Plus, Pencil, Trash2, Check, X, FolderPlus } from 'lucide-react';
import {
Plus, Pencil, Trash2, Check, X, FolderPlus, Folder,
Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Star, Heart, Bookmark, Tag, Flag, Briefcase, Users,
Bell, Zap, Globe, Lock, Eye, MessageSquare, Mail,
type LucideIcon,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const STANDARD_ROLES = ['inbox', 'drafts', 'sent', 'trash', 'junk', 'archive'] as const;
const ROLE_ICONS: Record<string, LucideIcon> = {
inbox: Inbox,
drafts: FileText,
sent: Send,
trash: Trash,
junk: ShieldAlert,
archive: Archive,
};
const ICON_CHOICES: { name: string; icon: LucideIcon }[] = [
{ name: 'Folder', icon: Folder },
{ name: 'Star', icon: Star },
{ name: 'Heart', icon: Heart },
{ name: 'Bookmark', icon: Bookmark },
{ name: 'Tag', icon: Tag },
{ name: 'Flag', icon: Flag },
{ name: 'Briefcase', icon: Briefcase },
{ name: 'Users', icon: Users },
{ name: 'Bell', icon: Bell },
{ name: 'Zap', icon: Zap },
{ name: 'Globe', icon: Globe },
{ name: 'Lock', icon: Lock },
{ name: 'Eye', icon: Eye },
{ name: 'MessageSquare', icon: MessageSquare },
{ name: 'Mail', icon: Mail },
{ name: 'Inbox', icon: Inbox },
{ name: 'Archive', icon: Archive },
{ name: 'FileText', icon: FileText },
];
function IconPicker({ currentIcon, onSelect, onClose }: {
currentIcon: string;
onSelect: (iconName: string) => void;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
return (
<div
ref={ref}
className="absolute left-0 top-full mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-2 grid grid-cols-6 gap-1 w-52"
>
{ICON_CHOICES.map(({ name, icon: Icon }) => (
<button
key={name}
onClick={() => onSelect(name)}
className={cn(
"p-1.5 rounded-md transition-colors flex items-center justify-center",
currentIcon === name
? "bg-primary text-primary-foreground"
: "hover:bg-accent text-muted-foreground hover:text-foreground"
)}
title={name}
>
<Icon className="w-4 h-4" />
</button>
))}
</div>
);
}
export function FolderSettings() {
const t = useTranslations('settings.folders');
const { client } = useAuthStore();
const { mailboxes, createMailbox, renameMailbox, deleteMailbox, setMailboxRole } = useEmailStore();
const { folderIcons, setFolderIcon } = useSettingsStore();
const [isCreating, setIsCreating] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null);
const [iconPickerId, setIconPickerId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Only show own (non-shared) mailboxes
const ownMailboxes = mailboxes.filter(mb => !mb.isShared);
const getRoleMailboxId = (role: string): string => {
@@ -30,6 +115,30 @@ export function FolderSettings() {
return mb?.id ?? '';
};
const getIconForMailbox = (mb: { id: string; role?: string }): LucideIcon => {
// Custom icon takes priority for non-role folders
const customIconName = folderIcons[mb.id];
if (customIconName) {
const found = ICON_CHOICES.find(c => c.name === customIconName);
if (found) return found.icon;
}
// Role folders get their role icon
if (mb.role && ROLE_ICONS[mb.role]) return ROLE_ICONS[mb.role];
return Folder;
};
const getIconName = (mb: { id: string; role?: string }): string => {
if (folderIcons[mb.id]) return folderIcons[mb.id];
if (mb.role && ROLE_ICONS[mb.role]) {
const entry = Object.entries(ROLE_ICONS).find(([r]) => r === mb.role);
if (entry) {
const found = ICON_CHOICES.find(c => c.icon === entry[1]);
if (found) return found.name;
}
}
return 'Folder';
};
const handleCreate = async () => {
if (!client || !newFolderName.trim()) return;
setIsLoading(true);
@@ -37,8 +146,9 @@ export function FolderSettings() {
await createMailbox(client, newFolderName.trim());
setNewFolderName('');
setIsCreating(false);
toast.success(t('folder_created'));
} catch {
// error is set in the store
toast.error(t('error_create'));
} finally {
setIsLoading(false);
}
@@ -51,8 +161,9 @@ export function FolderSettings() {
await renameMailbox(client, mailboxId, editingName.trim());
setEditingId(null);
setEditingName('');
toast.success(t('folder_renamed'));
} catch {
// error is set in the store
toast.error(t('error_rename'));
} finally {
setIsLoading(false);
}
@@ -64,8 +175,9 @@ export function FolderSettings() {
try {
await deleteMailbox(client, mailboxId);
setDeletingId(null);
toast.success(t('folder_deleted'));
} catch {
// error is set in the store
toast.error(t('error_delete'));
} finally {
setIsLoading(false);
}
@@ -76,7 +188,6 @@ export function FolderSettings() {
setIsLoading(true);
try {
if (mailboxId === '') {
// Clear the role from whatever mailbox currently has it
const current = ownMailboxes.find(m => m.role === role);
if (current) {
await setMailboxRole(client, current.id, null);
@@ -84,8 +195,9 @@ export function FolderSettings() {
} else {
await setMailboxRole(client, mailboxId, role);
}
toast.success(t('role_updated'));
} catch {
// error is set in the store
toast.error(t('error_role'));
} finally {
setIsLoading(false);
}
@@ -101,126 +213,156 @@ export function FolderSettings() {
setEditingName('');
};
const renderFolderRow = (mb: typeof ownMailboxes[0]) => {
const Icon = getIconForMailbox(mb);
if (editingId === mb.id) {
return (
<div key={mb.id} className="flex items-center gap-2 py-2 px-3">
<Icon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(mb.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
<button
onClick={() => handleRename(mb.id)}
disabled={isLoading || !editingName.trim()}
className="p-1.5 text-primary hover:bg-accent rounded-md disabled:opacity-50"
title={t('rename')}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1.5 text-muted-foreground hover:bg-accent rounded-md"
title={t('cancel')}
>
<X className="w-4 h-4" />
</button>
</div>
);
}
if (deletingId === mb.id) {
return (
<div key={mb.id} className="flex items-center gap-3 py-2.5 px-3 bg-destructive/5 rounded-md border border-destructive/20">
<Trash2 className="w-4 h-4 text-destructive flex-shrink-0" />
<p className="text-sm text-foreground flex-1">
{t('confirm_delete', { name: mb.name })}
</p>
<button
onClick={() => handleDelete(mb.id)}
disabled={isLoading}
className="px-3 py-1 text-xs font-medium bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
</div>
);
}
return (
<div
key={mb.id}
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50"
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="relative flex-shrink-0">
<button
onClick={() => setIconPickerId(iconPickerId === mb.id ? null : mb.id)}
className={cn(
"p-1 rounded-md transition-colors",
iconPickerId === mb.id
? "bg-accent"
: "hover:bg-accent"
)}
title={t('change_icon')}
>
<Icon className={cn(
"w-4 h-4",
mb.role ? "text-primary" : "text-muted-foreground"
)} />
</button>
{iconPickerId === mb.id && (
<IconPicker
currentIcon={getIconName(mb)}
onSelect={(iconName) => {
setFolderIcon(mb.id, iconName);
setIconPickerId(null);
}}
onClose={() => setIconPickerId(null)}
/>
)}
</div>
<span className="text-sm text-foreground truncate">{mb.name}</span>
{mb.role && (
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
{t(`role_${mb.role}`)}
</span>
)}
{mb.unreadEmails > 0 && (
<span className="text-xs tabular-nums px-1.5 py-0.5 rounded-full bg-primary text-primary-foreground font-medium flex-shrink-0">
{mb.unreadEmails}
</span>
)}
</div>
<div className="flex items-center gap-0.5">
{mb.myRights?.mayRename && (
<button
onClick={() => startEdit(mb)}
className="p-1.5 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors"
title={t('rename')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{mb.myRights?.mayDelete && !mb.role && (
<button
onClick={() => setDeletingId(mb.id)}
className="p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-md transition-colors"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
};
return (
<div className="space-y-8">
{/* Standard Folder Roles */}
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
{STANDARD_ROLES.map((role) => (
<SettingItem key={role} label={t(`role_${role}`)}>
<Select
value={getRoleMailboxId(role)}
onChange={(value) => handleRoleChange(role, value)}
options={[
{ value: '', label: t('role_none') },
...ownMailboxes.map(mb => ({
value: mb.id,
label: mb.name,
})),
]}
/>
</SettingItem>
))}
</SettingsSection>
{/* Folder List */}
<SettingsSection title={t('folder_list')}>
<div className="space-y-1">
{ownMailboxes.map((mb) => (
<div
key={mb.id}
className="flex items-center justify-between py-2 px-3 rounded-md hover:bg-muted/50 group"
>
{editingId === mb.id ? (
<div className="flex items-center gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(mb.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-2 py-1 text-sm rounded border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
autoFocus
disabled={isLoading}
/>
<button
onClick={() => handleRename(mb.id)}
disabled={isLoading || !editingName.trim()}
className="p-1 text-primary hover:bg-accent rounded disabled:opacity-50"
title={t('rename')}
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEdit}
className="p-1 text-muted-foreground hover:bg-accent rounded"
title={t('cancel')}
>
<X className="w-4 h-4" />
</button>
</div>
) : deletingId === mb.id ? (
<div className="flex items-center gap-2 flex-1">
<p className="text-sm text-destructive flex-1">
{t('confirm_delete', { name: mb.name })}
</p>
<button
onClick={() => handleDelete(mb.id)}
disabled={isLoading}
className="px-2 py-1 text-xs bg-destructive text-destructive-foreground rounded hover:bg-destructive/90 disabled:opacity-50"
>
{t('delete')}
</button>
<button
onClick={() => setDeletingId(null)}
className="px-2 py-1 text-xs bg-muted text-foreground rounded hover:bg-accent"
>
{t('cancel')}
</button>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground">{mb.name}</span>
{mb.role && (
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{mb.role}
</span>
)}
</div>
<div className={cn(
"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity",
)}>
{mb.myRights?.mayRename && (
<button
onClick={() => startEdit(mb)}
className="p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded"
title={t('rename')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{mb.myRights?.mayDelete && !mb.role && (
<button
onClick={() => setDeletingId(mb.id)}
className="p-1 text-muted-foreground hover:text-destructive hover:bg-accent rounded"
title={t('delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</>
)}
{/* Folder List — primary section */}
<SettingsSection title={t('folder_list')} description={t('folder_list_description')}>
<div className="space-y-0.5">
{ownMailboxes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Folder className="w-10 h-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">{t('no_folders')}</p>
</div>
))}
) : (
ownMailboxes.map(renderFolderRow)
)}
</div>
{/* Create folder */}
{isCreating ? (
<div className="flex items-center gap-2 mt-3">
<FolderPlus className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex items-center gap-2 mt-3 p-2.5 bg-muted/30 rounded-md border border-border">
<FolderPlus className="w-4 h-4 text-primary flex-shrink-0" />
<input
type="text"
value={newFolderName}
@@ -240,7 +382,7 @@ export function FolderSettings() {
<button
onClick={handleCreate}
disabled={isLoading || !newFolderName.trim()}
className="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50"
className="px-3 py-1 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50"
>
{t('create')}
</button>
@@ -249,7 +391,7 @@ export function FolderSettings() {
setIsCreating(false);
setNewFolderName('');
}}
className="px-3 py-1 text-xs bg-muted text-foreground rounded hover:bg-accent"
className="px-3 py-1 text-xs bg-muted text-foreground rounded-md hover:bg-accent"
>
{t('cancel')}
</button>
@@ -257,13 +399,32 @@ export function FolderSettings() {
) : (
<button
onClick={() => setIsCreating(true)}
className="flex items-center gap-2 mt-3 px-3 py-2 text-sm text-primary hover:bg-accent rounded-md transition-colors w-full"
className="flex items-center gap-2 mt-3 px-3 py-2 text-sm text-primary hover:bg-primary/5 rounded-md transition-colors w-full border border-dashed border-primary/30 hover:border-primary/50"
>
<Plus className="w-4 h-4" />
{t('create_folder')}
</button>
)}
</SettingsSection>
{/* Standard Folder Roles — advanced section */}
<SettingsSection title={t('standard_roles')} description={t('standard_roles_description')}>
{STANDARD_ROLES.map((role) => (
<SettingItem key={role} label={t(`role_${role}`)}>
<Select
value={getRoleMailboxId(role)}
onChange={(value) => handleRoleChange(role, value)}
options={[
{ value: '', label: t('role_none') },
...ownMailboxes.map(mb => ({
value: mb.id,
label: mb.name,
})),
]}
/>
</SettingItem>
))}
</SettingsSection>
</div>
);
}
+98 -2
View File
@@ -1,4 +1,77 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
// Personal email domains where the favicon is the mail provider logo, not the sender
const PERSONAL_DOMAINS = new Set([
"gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com",
"msn.com", "yahoo.com", "yahoo.fr", "yahoo.co.uk", "yahoo.co.jp",
"aol.com", "icloud.com", "me.com", "mac.com", "mail.com",
"proton.me", "protonmail.com", "pm.me", "tutanota.com", "tuta.com",
"zoho.com", "yandex.com", "yandex.ru", "gmx.com", "gmx.net",
"fastmail.com", "hey.com", "posteo.de", "mailbox.org",
"example.com", "example.org",
]);
// Deterministic hash for an email string
function emailHash(email: string): number {
let hash = 0;
for (let i = 0; i < email.length; i++) {
hash = email.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash);
}
// Common first names to infer gender for portrait selection
const FEMALE_NAMES = new Set([
"alice", "emily", "sarah", "priya", "carol", "anna", "maria", "emma", "olivia",
"sophia", "isabella", "mia", "charlotte", "amelia", "harper", "ella", "grace",
"chloe", "luna", "lily", "zoey", "hannah", "nora", "riley", "elena", "maya",
"claire", "victoria", "natalie", "rachel", "jessica", "jennifer", "lisa",
"karen", "nancy", "betty", "sandra", "ashley", "margaret", "dorothy",
"julia", "laura", "susan", "andrea", "diana", "marie", "sophie",
]);
const MALE_NAMES = new Set([
"bob", "marcus", "alex", "david", "james", "john", "robert", "michael",
"william", "richard", "joseph", "thomas", "charles", "daniel", "matthew",
"anthony", "mark", "steven", "paul", "andrew", "kevin", "brian", "george",
"timothy", "jason", "ryan", "jacob", "gary", "eric", "peter", "frank",
"samuel", "benjamin", "henry", "patrick", "jack", "noah", "liam", "oliver",
"lucas", "ethan", "mason", "logan", "leo", "max", "oscar", "hugo",
]);
function inferGender(name: string | undefined, hash: number): "women" | "men" {
if (name) {
const firstName = name.trim().split(/\s+/)[0].toLowerCase();
if (FEMALE_NAMES.has(firstName)) return "women";
if (MALE_NAMES.has(firstName)) return "men";
}
return hash % 2 === 0 ? "women" : "men";
}
// Custom avatar URLs for specific demo senders (e.g. newsletters with custom logos)
const CUSTOM_AVATARS: Record<string, string> = {
"newsletter@launchweekly.com": "https://img.freepik.com/premium-vector/swoosh-letter-lw-logo-design-business-company-identity-water-wave-lw-logo-with-modern-trendy_754537-799.jpg?w=360",
"hello@launchpad.example": "https://img.freepik.com/premium-vector/swoosh-letter-lw-logo-design-business-company-identity-water-wave-lw-logo-with-modern-trendy_754537-799.jpg?w=360",
"news@techdigest.example": "https://img.freepik.com/premium-vector/technology-letter-t-logo-design-template_125964-1249.jpg?w=360",
"alice@example.com": "https://randomuser.me/api/portraits/thumb/women/44.jpg",
"bob@example.org": "https://randomuser.me/api/portraits/thumb/men/32.jpg",
"carol@example.com": "https://randomuser.me/api/portraits/thumb/women/68.jpg",
};
// For personal-domain emails, deterministically pick a randomuser.me portrait.
// Returns null for ~30% of addresses so not everyone has a photo.
function getProfilePictureUrl(email: string, domain: string, name?: string): string | null {
if (!PERSONAL_DOMAINS.has(domain)) return null;
const h = emailHash(email);
if (h % 10 < 3) return null; // ~30% get no photo
const gender = inferGender(name, h);
const id = h % 100;
return `https://randomuser.me/api/portraits/thumb/${gender}/${id}.jpg`;
}
interface AvatarProps {
name?: string;
@@ -8,6 +81,9 @@ interface AvatarProps {
}
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const senderFavicons = useSettingsStore((s) => s.senderFavicons);
const getInitials = () => {
if (name) {
const parts = name.trim().split(/\s+/);
@@ -38,17 +114,37 @@ export function Avatar({ name, email, size = "md", className }: AvatarProps) {
lg: "w-12 h-12 text-base",
};
const domain = email?.split("@")[1]?.toLowerCase();
const profilePic = email && domain ? getProfilePictureUrl(email, domain, name) : null;
const showFavicon =
senderFavicons && domain && !PERSONAL_DOMAINS.has(domain) && !imgError;
// Priority: custom avatar > profile picture > company favicon > initials
const customAvatar = email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const imgSrc = !imgError
? customAvatar || profilePic || (showFavicon ? `/api/favicon?domain=${encodeURIComponent(domain!)}` : null)
: null;
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-semibold text-white",
"rounded-full flex items-center justify-center font-semibold text-white overflow-hidden",
sizeClasses[size],
className
)}
style={{ backgroundColor: getBackgroundColor() }}
title={name || email}
>
{getInitials()}
{imgSrc ? (
<img
src={imgSrc}
alt=""
className="w-full h-full object-cover"
onError={() => setImgError(true)}
/>
) : (
getInitials()
)}
</div>
);
}