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:
+215
-17
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
@@ -29,11 +29,13 @@ import {
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
import { AdvancedSearchPanel } from "@/components/search/advanced-search-panel";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search, Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw } from "lucide-react";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
@@ -44,6 +46,7 @@ export default function Home() {
|
||||
const [composerDraftText, setComposerDraftText] = useState("");
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
const [showAdvancedFields, setShowAdvancedFields] = useState(false);
|
||||
// Mobile conversation view state
|
||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
@@ -604,6 +607,24 @@ export default function Home() {
|
||||
await advancedSearch(client);
|
||||
};
|
||||
|
||||
const advancedSearchDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const handleAdvancedSearchDebounced = useCallback(() => {
|
||||
if (advancedSearchDebounceRef.current) {
|
||||
clearTimeout(advancedSearchDebounceRef.current);
|
||||
}
|
||||
advancedSearchDebounceRef.current = setTimeout(() => {
|
||||
if (client) advancedSearch(client);
|
||||
}, 300);
|
||||
}, [client, advancedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (advancedSearchDebounceRef.current) {
|
||||
clearTimeout(advancedSearchDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
@@ -744,6 +765,22 @@ export default function Home() {
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const ToggleChip = ({ icon, label, value, onClick }: { icon: React.ReactNode; label: string; value: boolean | null; onClick: () => void }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs transition-colors border",
|
||||
value === true && "bg-primary/10 border-primary/30 text-primary",
|
||||
value === false && "bg-muted border-border text-muted-foreground line-through",
|
||||
value === null && "bg-background border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
@@ -791,9 +828,6 @@ export default function Home() {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onSidebarClose={() => setSidebarOpen(false)}
|
||||
onSearch={handleSearch}
|
||||
onClearSearch={handleClearSearch}
|
||||
activeSearchQuery={searchQuery}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -834,17 +868,181 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<AdvancedSearchPanel
|
||||
filters={searchFilters}
|
||||
isOpen={isAdvancedSearchOpen}
|
||||
onFiltersChange={setSearchFilters}
|
||||
onClear={() => {
|
||||
clearSearchFilters();
|
||||
if (client) advancedSearch(client);
|
||||
}}
|
||||
onSearch={handleAdvancedSearch}
|
||||
onClose={toggleAdvancedSearch}
|
||||
/>
|
||||
{/* Search Bar + Inline Advanced Filters */}
|
||||
<div className="border-b border-border bg-background">
|
||||
<div className="px-3 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} 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("sidebar.search_placeholder_hint")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn("pl-9 h-9", searchQuery && "pr-8")}
|
||||
data-search-input
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearSearch}
|
||||
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("sidebar.clear_search")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAdvancedSearch}
|
||||
className={cn(
|
||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
||||
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
title={t("advanced_search.toggle_filters")}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 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">
|
||||
{activeFilterCount(searchFilters)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Area */}
|
||||
{isAdvancedSearchOpen && (
|
||||
<div className="px-3 pb-3 space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
|
||||
{/* Quick toggle filters + clear */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<ToggleChip
|
||||
icon={<Paperclip className="w-3.5 h-3.5" />}
|
||||
label={t("advanced_search.has_attachment")}
|
||||
value={searchFilters.hasAttachment}
|
||||
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<Star className="w-3.5 h-3.5" />}
|
||||
label={t("advanced_search.starred")}
|
||||
value={searchFilters.isStarred}
|
||||
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
|
||||
value={searchFilters.isUnread}
|
||||
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
|
||||
<RotateCcw className="w-3 h-3 mr-1" />
|
||||
{t("advanced_search.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "More" expand for advanced fields */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronDown className={cn("w-3.5 h-3.5 transition-transform", showAdvancedFields && "rotate-180")} />
|
||||
<span>{t("advanced_search.title")}</span>
|
||||
</button>
|
||||
|
||||
{/* Advanced fields */}
|
||||
{showAdvancedFields && (
|
||||
<div className="space-y-2.5 animate-in slide-in-from-top-1 fade-in duration-150">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.from")}</label>
|
||||
<Input
|
||||
value={searchFilters.from}
|
||||
onChange={(e) => { setSearchFilters({ from: e.target.value }); handleAdvancedSearchDebounced(); }}
|
||||
placeholder={t("advanced_search.from_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.to")}</label>
|
||||
<Input
|
||||
value={searchFilters.to}
|
||||
onChange={(e) => { setSearchFilters({ to: e.target.value }); handleAdvancedSearchDebounced(); }}
|
||||
placeholder={t("advanced_search.to_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.subject")}</label>
|
||||
<Input
|
||||
value={searchFilters.subject}
|
||||
onChange={(e) => { setSearchFilters({ subject: e.target.value }); handleAdvancedSearchDebounced(); }}
|
||||
placeholder={t("advanced_search.subject_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.body")}</label>
|
||||
<Input
|
||||
value={searchFilters.body}
|
||||
onChange={(e) => { setSearchFilters({ body: e.target.value }); handleAdvancedSearchDebounced(); }}
|
||||
placeholder={t("advanced_search.body_placeholder")}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Folder selector */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.folder")}</label>
|
||||
<select
|
||||
value={selectedMailbox || ""}
|
||||
onChange={(e) => { handleMailboxSelect(e.target.value); }}
|
||||
className="w-full h-8 text-sm rounded-md border border-input bg-background px-3 text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
||||
>
|
||||
<option value="">{t("advanced_search.all_folders")}</option>
|
||||
{mailboxes.map((mb) => (
|
||||
<option key={mb.id} value={mb.id}>
|
||||
{mb.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.date_after")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={searchFilters.dateAfter}
|
||||
onChange={(e) => { setSearchFilters({ dateAfter: e.target.value }); handleAdvancedSearch(); }}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">{t("advanced_search.date_before")}</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={searchFilters.dateBefore}
|
||||
onChange={(e) => { setSearchFilters({ dateBefore: e.target.value }); handleAdvancedSearch(); }}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<WelcomeBanner />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
|
||||
@@ -23,9 +23,19 @@ type Tab = 'appearance' | 'email' | 'account' | 'identities' | 'vacation' | 'cal
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('settings');
|
||||
const { client } = useAuthStore();
|
||||
const { client, isAuthenticated } = useAuthStore();
|
||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportsVacation = client?.supportsVacationResponse() ?? false;
|
||||
const supportsCalendar = client?.supportsCalendars() ?? false;
|
||||
const supportsSieve = client?.supportsSieve() ?? false;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
|
||||
const CACHE_MAX_SIZE = 1000;
|
||||
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000; // 2 weeks
|
||||
|
||||
interface CacheEntry {
|
||||
data: ArrayBuffer;
|
||||
contentType: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
// Strict domain validation to prevent SSRF
|
||||
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
|
||||
|
||||
function isValidDomain(domain: string): boolean {
|
||||
if (domain.length > 253) return false;
|
||||
if (!DOMAIN_RE.test(domain)) return false;
|
||||
// Block internal/private hostnames
|
||||
const lower = domain.toLowerCase();
|
||||
if (
|
||||
lower === 'localhost' ||
|
||||
lower.endsWith('.local') ||
|
||||
lower.endsWith('.internal') ||
|
||||
lower.endsWith('.arpa')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function evictOldest() {
|
||||
if (cache.size < CACHE_MAX_SIZE) return;
|
||||
// Evict the oldest entry
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of cache) {
|
||||
if (entry.fetchedAt < oldestTime) {
|
||||
oldestTime = entry.fetchedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) cache.delete(oldestKey);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const domain = request.nextUrl.searchParams.get('domain');
|
||||
|
||||
if (!domain || !isValidDomain(domain)) {
|
||||
return new NextResponse(null, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedDomain = domain.toLowerCase();
|
||||
|
||||
// Check cache
|
||||
const cached = cache.get(normalizedDomain);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return new NextResponse(cached.data, {
|
||||
headers: {
|
||||
'Content-Type': cached.contentType,
|
||||
'Cache-Control': 'public, max-age=1209600', // 2 weeks
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(
|
||||
`https://icons.duckduckgo.com/ip3/${encodeURIComponent(normalizedDomain)}.ico`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || 'image/x-icon';
|
||||
const data = await upstream.arrayBuffer();
|
||||
|
||||
// Don't cache empty/tiny responses (likely no real favicon)
|
||||
if (data.byteLength < 10) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
evictOldest();
|
||||
cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() });
|
||||
|
||||
return new NextResponse(data, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=1209600',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return new NextResponse(null, { status: 502 });
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "Löschaktion",
|
||||
"description": "Was passiert, wenn Sie eine E-Mail löschen",
|
||||
"trash": "In Papierkorb verschieben",
|
||||
"permanent": "Dauerhaft löschen"
|
||||
"permanent": "Dauerhaft löschen",
|
||||
"warning": "E-Mails werden dauerhaft gelöscht und können nicht wiederhergestellt werden. Diese Aktion ist unwiderruflich."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Vorschautext anzeigen",
|
||||
|
||||
@@ -534,7 +534,8 @@
|
||||
"label": "Delete Action",
|
||||
"description": "What happens when you delete an email",
|
||||
"trash": "Move to Trash",
|
||||
"permanent": "Delete Permanently"
|
||||
"permanent": "Delete Permanently",
|
||||
"warning": "Emails will be permanently deleted and cannot be recovered. This action is irreversible."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Show Preview Text",
|
||||
@@ -704,6 +705,7 @@
|
||||
"title": "Folders",
|
||||
"description": "Manage your email folders and assign standard roles",
|
||||
"folder_list": "Your Folders",
|
||||
"folder_list_description": "Click a folder icon to customize it",
|
||||
"standard_roles": "Standard Folder Roles",
|
||||
"standard_roles_description": "Assign which folders are used for standard mailbox roles like Inbox, Sent, Trash, etc.",
|
||||
"role_inbox": "Inbox",
|
||||
@@ -716,6 +718,7 @@
|
||||
"create_folder": "Create Folder",
|
||||
"new_folder_name": "Folder name",
|
||||
"rename": "Rename",
|
||||
"change_icon": "Change icon",
|
||||
"delete": "Delete",
|
||||
"confirm_delete": "Are you sure you want to delete \"{name}\"? Emails in this folder will be moved to Trash.",
|
||||
"create": "Create",
|
||||
@@ -1397,6 +1400,9 @@
|
||||
"subject": "Subject",
|
||||
"subject_placeholder": "Subject contains...",
|
||||
"body": "Body",
|
||||
"body_placeholder": "Body contains...",
|
||||
"folder": "Folder",
|
||||
"all_folders": "All folders",
|
||||
"has_attachment": "Attachments",
|
||||
"date_after": "After",
|
||||
"date_before": "Before",
|
||||
@@ -1409,7 +1415,7 @@
|
||||
"clear_all": "Clear all",
|
||||
"filters_active": "{count} filter",
|
||||
"filters_active_plural": "{count} filters",
|
||||
"toggle_filters": "Filters",
|
||||
"toggle_filters": "More",
|
||||
"search_hint": "Use advanced filters for precise search",
|
||||
"advanced_filters_tooltip": "Advanced search filters"
|
||||
},
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "Acción al Eliminar",
|
||||
"description": "Qué sucede cuando elimina un correo",
|
||||
"trash": "Mover a Papelera",
|
||||
"permanent": "Eliminar Permanentemente"
|
||||
"permanent": "Eliminar Permanentemente",
|
||||
"warning": "Los correos se eliminarán permanentemente y no se podrán recuperar. Esta acción es irreversible."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostrar Vista Previa",
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "Action de suppression",
|
||||
"description": "Que se passe-t-il quand vous supprimez un email",
|
||||
"trash": "Déplacer vers la corbeille",
|
||||
"permanent": "Supprimer définitivement"
|
||||
"permanent": "Supprimer définitivement",
|
||||
"warning": "Les emails seront supprimés définitivement et ne pourront pas être récupérés. Cette action est irréversible."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Afficher l'aperçu",
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "Azione di eliminazione",
|
||||
"description": "Cosa accade quando elimini un messaggio",
|
||||
"trash": "Sposta nel cestino",
|
||||
"permanent": "Elimina definitivamente"
|
||||
"permanent": "Elimina definitivamente",
|
||||
"warning": "I messaggi verranno eliminati definitivamente e non potranno essere recuperati. Questa azione è irreversibile."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostra anteprima testo",
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "削除動作",
|
||||
"description": "メール削除時の動作",
|
||||
"trash": "ゴミ箱に移動",
|
||||
"permanent": "完全に削除"
|
||||
"permanent": "完全に削除",
|
||||
"warning": "メールは完全に削除され、復元できません。この操作は元に戻せません。"
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "プレビューテキストを表示",
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "Verwijderactie",
|
||||
"description": "Wat er gebeurt wanneer je een e-mail verwijdert",
|
||||
"trash": "Verplaatsen naar prullenbak",
|
||||
"permanent": "Permanent verwijderen"
|
||||
"permanent": "Permanent verwijderen",
|
||||
"warning": "E-mails worden permanent verwijderd en kunnen niet worden hersteld. Deze actie is onomkeerbaar."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Voorbeeldtekst tonen",
|
||||
|
||||
@@ -525,7 +525,8 @@
|
||||
"label": "Ação de Exclusão",
|
||||
"description": "O que acontece quando você exclui um e-mail",
|
||||
"trash": "Mover para Lixeira",
|
||||
"permanent": "Excluir Permanentemente"
|
||||
"permanent": "Excluir Permanentemente",
|
||||
"warning": "Os e-mails serão excluídos permanentemente e não poderão ser recuperados. Esta ação é irreversível."
|
||||
},
|
||||
"show_preview": {
|
||||
"label": "Mostrar Texto de Visualização",
|
||||
|
||||
+102
-24
@@ -1253,11 +1253,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
keywords: { $seen: false },
|
||||
size: 1024,
|
||||
receivedAt: new Date().toISOString(),
|
||||
from: [{ name: "Alice Johnson", email: "alice@example.com" }],
|
||||
from: [{ name: "GitHub", email: "notifications@github.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Q4 Budget Review Meeting",
|
||||
preview: "Hi team, I wanted to schedule a meeting to review our Q4 budget projections. Are you available this Thursday at 2 PM? We need to discuss...",
|
||||
hasAttachment: true,
|
||||
subject: "[jmap-webmail] New pull request #42: Add OAuth2 module",
|
||||
preview: "dependabot[bot] opened a pull request in root-fr/jmap-webmail. This PR adds a comprehensive authentication module with OAuth2 PKCE support...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
@@ -1266,11 +1266,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
keywords: { $seen: true, $flagged: true },
|
||||
size: 512,
|
||||
receivedAt: new Date(Date.now() - 3600000).toISOString(),
|
||||
from: [{ name: "Bob Smith", email: "bob@company.com" }],
|
||||
from: [{ name: "Emily Chen", email: "emily.chen@gmail.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Re: Project Timeline Update",
|
||||
preview: "Thanks for the update. The new timeline looks good to me. I've reviewed the milestones and everything seems achievable...",
|
||||
hasAttachment: false,
|
||||
subject: "Re: Dashboard Redesign v2 — feedback",
|
||||
preview: "Hey! I just pushed the updated mockups to Figma. I incorporated all the feedback from last week's meeting. Let me know what you think about the new nav...",
|
||||
hasAttachment: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
@@ -1279,11 +1279,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
keywords: { $seen: false },
|
||||
size: 2048,
|
||||
receivedAt: new Date(Date.now() - 7200000).toISOString(),
|
||||
from: [{ name: "Carol White", email: "carol@design.co" }],
|
||||
from: [{ name: "Slack", email: "notifications@slack.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "New Design Mockups Ready",
|
||||
preview: "Hey! The new mockups for the landing page are ready for review. I've incorporated all the feedback from last week's meeting...",
|
||||
hasAttachment: true,
|
||||
subject: "3 new messages in #engineering",
|
||||
preview: "Marcus: Hey team, the CI pipeline is green again. Sarah: Great, merging the feature branch now. Alex: Let's do a quick sync at 3 PM...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
@@ -1291,11 +1291,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 768,
|
||||
receivedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
from: [{ name: "GitHub", email: "notifications@github.com" }],
|
||||
receivedAt: new Date(Date.now() - 14400000).toISOString(),
|
||||
from: [{ name: "Marcus Rivera", email: "marcus.rivera@outlook.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "[PR] Feature: Add authentication module",
|
||||
preview: "A new pull request has been opened in your repository. This PR adds a comprehensive authentication module with OAuth support...",
|
||||
subject: "Quick question about the API rate limits",
|
||||
preview: "Hey, I was looking at the JMAP spec and I'm not sure how we should handle rate limiting on the server side. Do you have any thoughts on...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
@@ -1304,13 +1304,91 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 1536,
|
||||
receivedAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
from: [{ name: "David Lee", email: "david@startup.io" }],
|
||||
receivedAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
from: [{ name: "Stripe", email: "receipts@stripe.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Investment Proposal Discussion",
|
||||
preview: "Following up on our call yesterday, I'm sending over the investment proposal we discussed. The terms are quite favorable...",
|
||||
subject: "Your invoice from Acme Corp is ready",
|
||||
preview: "Invoice #INV-2026-0312 for $49.00 has been paid. Thank you for your payment. View your receipt and download your invoice...",
|
||||
hasAttachment: true,
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
threadId: "thread-6",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: false },
|
||||
size: 3072,
|
||||
receivedAt: new Date(Date.now() - 108000000).toISOString(),
|
||||
from: [{ name: "Sarah Kim", email: "sarah.kim@proton.me" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Conference talk proposal — need your review",
|
||||
preview: "I'm submitting a talk to ReactConf about our email client architecture. Could you take a look at my abstract before the deadline on Friday?...",
|
||||
hasAttachment: true,
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
threadId: "thread-7",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true, $flagged: true },
|
||||
size: 4096,
|
||||
receivedAt: new Date(Date.now() - 172800000).toISOString(),
|
||||
from: [{ name: "Vercel", email: "notifications@vercel.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Deployment successful: jmap-webmail \u2192 Production",
|
||||
preview: "Your project jmap-webmail has been deployed to production. Build completed in 47s. All checks passed. Preview: https://jmap-webmail.vercel.app...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "8",
|
||||
threadId: "thread-8",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 2560,
|
||||
receivedAt: new Date(Date.now() - 259200000).toISOString(),
|
||||
from: [{ name: "Alex Petrov", email: "alex.petrov@fastmail.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Meeting notes from yesterday's standup",
|
||||
preview: "Here are the action items from yesterday. 1) Finish the drag-and-drop implementation by Wednesday. 2) Review the accessibility audit results...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "9",
|
||||
threadId: "thread-9",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: false },
|
||||
size: 1280,
|
||||
receivedAt: new Date(Date.now() - 345600000).toISOString(),
|
||||
from: [{ name: "Linear", email: "notifications@linear.app" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "ENG-384: Implement email threading view \u2014 moved to In Progress",
|
||||
preview: "Alice Johnson moved ENG-384 to In Progress. This issue covers implementing the conversation thread view for the email client...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "10",
|
||||
threadId: "thread-10",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 896,
|
||||
receivedAt: new Date(Date.now() - 432000000).toISOString(),
|
||||
from: [{ name: "Priya Sharma", email: "priya.sharma@icloud.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "Re: Onboarding docs for new contributors",
|
||||
preview: "Thanks for putting this together! I added a section on setting up the dev environment. Also linked the architecture diagram from our wiki...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
threadId: "thread-11",
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
size: 5120,
|
||||
receivedAt: new Date(Date.now() - 518400000).toISOString(),
|
||||
from: [{ name: "LaunchWeekly", email: "newsletter@launchweekly.com" }],
|
||||
to: [{ email: "you@example.com" }],
|
||||
subject: "\uD83D\uDE80 This week in tech: AI agents, new frameworks, and more",
|
||||
preview: "Happy Monday! Here's your weekly roundup of the most interesting launches, open-source projects, and developer tools you might have missed...",
|
||||
hasAttachment: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockMailboxes: Mailbox[] = [
|
||||
@@ -1319,10 +1397,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
name: "Inbox",
|
||||
role: "inbox",
|
||||
sortOrder: 1,
|
||||
totalEmails: 5,
|
||||
unreadEmails: 2,
|
||||
totalThreads: 5,
|
||||
unreadThreads: 2,
|
||||
totalEmails: 11,
|
||||
unreadEmails: 4,
|
||||
totalThreads: 11,
|
||||
unreadThreads: 4,
|
||||
myRights: {
|
||||
mayReadItems: true,
|
||||
mayAddItems: true,
|
||||
|
||||
@@ -41,6 +41,12 @@ interface SettingsState {
|
||||
calendarNotificationsEnabled: boolean;
|
||||
calendarNotificationSound: boolean;
|
||||
|
||||
// Experimental
|
||||
senderFavicons: boolean;
|
||||
|
||||
// Folders
|
||||
folderIcons: Record<string, string>; // mailboxId -> icon name
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
|
||||
@@ -53,6 +59,10 @@ interface SettingsState {
|
||||
exportSettings: () => string;
|
||||
importSettings: (json: string) => boolean;
|
||||
|
||||
// Folder icons
|
||||
setFolderIcon: (mailboxId: string, icon: string) => void;
|
||||
removeFolderIcon: (mailboxId: string) => void;
|
||||
|
||||
// Trusted senders
|
||||
addTrustedSender: (email: string) => void;
|
||||
removeTrustedSender: (email: string) => void;
|
||||
@@ -90,6 +100,12 @@ const DEFAULT_SETTINGS = {
|
||||
calendarNotificationsEnabled: true,
|
||||
calendarNotificationSound: true,
|
||||
|
||||
// Experimental
|
||||
senderFavicons: true,
|
||||
|
||||
// Folders
|
||||
folderIcons: {} as Record<string, string>,
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
};
|
||||
@@ -146,6 +162,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
sessionTimeout: state.sessionTimeout,
|
||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||
calendarNotificationSound: state.calendarNotificationSound,
|
||||
senderFavicons: state.senderFavicons,
|
||||
folderIcons: state.folderIcons,
|
||||
debugMode: state.debugMode,
|
||||
};
|
||||
return JSON.stringify(settings, null, 2);
|
||||
@@ -179,6 +197,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// Folder icon methods
|
||||
setFolderIcon: (mailboxId: string, icon: string) => {
|
||||
set({ folderIcons: { ...get().folderIcons, [mailboxId]: icon } });
|
||||
},
|
||||
|
||||
removeFolderIcon: (mailboxId: string) => {
|
||||
const { [mailboxId]: _, ...rest } = get().folderIcons;
|
||||
set({ folderIcons: rest });
|
||||
},
|
||||
|
||||
// Trusted senders methods
|
||||
addTrustedSender: (email: string) => {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
|
||||
Reference in New Issue
Block a user