Initial release: JMAP Webmail Client
A modern, privacy-focused webmail client built with Next.js and the JMAP protocol. Designed for Stalwart Mail Server. Features: - Full email operations (compose, reply, forward, threading) - Real-time push notifications - Dark/light theme support - Mobile responsive design - Keyboard shortcuts - Drag-and-drop organization - i18n (English/French) - Security-first (external content blocked, HTML sanitization)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, RefreshCw, Home } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for locale pages.
|
||||
* Catches errors in the locale layout and its children.
|
||||
*/
|
||||
export default function LocaleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("errors");
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("Route error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center">
|
||||
<AlertCircle className="w-8 h-8 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground mb-2">
|
||||
{t("page_error_title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{t("page_error_description")}
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button variant="outline" onClick={() => router.push(`/${params.locale}`)}>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{t("go_home")}
|
||||
</Button>
|
||||
<Button onClick={reset}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { locales } from "@/i18n/request";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "JMAP Webmail",
|
||||
description: "Minimalist webmail client using JMAP protocol",
|
||||
};
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
params
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
|
||||
// Validate that the incoming `locale` parameter is valid
|
||||
if (!(locales as readonly string[]).includes(locale)) notFound();
|
||||
|
||||
// Load messages for the current locale
|
||||
let messages;
|
||||
try {
|
||||
messages = (await import(`@/locales/${locale}/common.json`)).default;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
try {
|
||||
const stored = localStorage.getItem('theme-storage');
|
||||
const theme = stored ? JSON.parse(stored).state.theme : 'system';
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
const resolved = theme === 'system' ? systemTheme : theme;
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('light');
|
||||
}
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Mail, AlertCircle, Loader2, X } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations("login");
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
|
||||
const serverUrl = process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Webmail';
|
||||
|
||||
// All hooks must be called unconditionally at the top
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const [savedUsernames, setSavedUsernames] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const justSelectedSuggestion = useRef(false);
|
||||
|
||||
// Set page title
|
||||
useEffect(() => {
|
||||
if (serverUrl) {
|
||||
document.title = appName;
|
||||
}
|
||||
}, [appName, serverUrl]);
|
||||
|
||||
// Load saved usernames from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
if (saved) {
|
||||
try {
|
||||
const usernames = JSON.parse(saved);
|
||||
setSavedUsernames(usernames);
|
||||
} catch {
|
||||
console.error("Failed to parse saved usernames");
|
||||
}
|
||||
}
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
}, [isAuthenticated, router, params.locale]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
}, [formData, clearError]);
|
||||
|
||||
// Filter suggestions based on input
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
// Skip showing suggestions if we just selected one
|
||||
if (justSelectedSuggestion.current) {
|
||||
justSelectedSuggestion.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.username && savedUsernames.length > 0) {
|
||||
const filtered = savedUsernames.filter(username =>
|
||||
username.toLowerCase().includes(formData.username.toLowerCase())
|
||||
);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} else if (formData.username === "" && savedUsernames.length > 0) {
|
||||
setFilteredSuggestions(savedUsernames);
|
||||
setShowSuggestions(false); // Don't show on empty input
|
||||
} else {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}, [formData.username, savedUsernames, serverUrl]);
|
||||
|
||||
// Close suggestions when clicking outside
|
||||
useEffect(() => {
|
||||
if (!serverUrl) return;
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (suggestionsRef.current && !suggestionsRef.current.contains(event.target as Node) &&
|
||||
inputRef.current && !inputRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [serverUrl]);
|
||||
|
||||
// Show error if JMAP server URL is not configured
|
||||
if (!serverUrl) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="w-full max-w-sm mx-auto px-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
|
||||
<AlertCircle className="w-10 h-10 text-red-500" />
|
||||
</div>
|
||||
<h1 className="text-xl font-medium text-foreground mb-2">Configuration Error</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
NEXT_PUBLIC_JMAP_SERVER_URL environment variable is not set.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Save username on successful login
|
||||
const saveUsername = (username: string) => {
|
||||
const saved = localStorage.getItem("webmail_usernames");
|
||||
let usernames: string[] = [];
|
||||
|
||||
if (saved) {
|
||||
try {
|
||||
usernames = JSON.parse(saved);
|
||||
} catch {
|
||||
console.error("Failed to parse saved usernames");
|
||||
}
|
||||
}
|
||||
|
||||
// Add username if not already present, keep max 5 recent usernames
|
||||
if (!usernames.includes(username)) {
|
||||
usernames = [username, ...usernames].slice(0, 5);
|
||||
localStorage.setItem("webmail_usernames", JSON.stringify(usernames));
|
||||
setSavedUsernames(usernames);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove a username from saved list
|
||||
const removeUsername = (username: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const updated = savedUsernames.filter(u => u !== username);
|
||||
localStorage.setItem("webmail_usernames", JSON.stringify(updated));
|
||||
setSavedUsernames(updated);
|
||||
setFilteredSuggestions(updated.filter(u =>
|
||||
u.toLowerCase().includes(formData.username.toLowerCase())
|
||||
));
|
||||
};
|
||||
|
||||
const handleUsernameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData({ ...formData, username: e.target.value });
|
||||
};
|
||||
|
||||
const handleUsernameFocus = () => {
|
||||
if (savedUsernames.length > 0 && formData.username === "") {
|
||||
setFilteredSuggestions(savedUsernames);
|
||||
setShowSuggestions(true);
|
||||
} else if (filteredSuggestions.length > 0) {
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
};
|
||||
|
||||
const selectSuggestion = (username: string) => {
|
||||
justSelectedSuggestion.current = true;
|
||||
setFormData({ ...formData, username });
|
||||
setShowSuggestions(false);
|
||||
// Focus password field
|
||||
document.getElementById("password")?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showSuggestions || filteredSuggestions.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedSuggestionIndex(prev =>
|
||||
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedSuggestionIndex(prev => prev > 0 ? prev - 1 : -1);
|
||||
} else if (e.key === "Enter" && selectedSuggestionIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectSuggestion(filteredSuggestions[selectedSuggestionIndex]);
|
||||
} else if (e.key === "Escape") {
|
||||
setShowSuggestions(false);
|
||||
setSelectedSuggestionIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const success = await login(
|
||||
serverUrl,
|
||||
formData.username,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
router.push(`/${params.locale}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
|
||||
<div className="w-full max-w-sm mx-auto px-4">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-primary/10 to-primary/5 mb-6 shadow-lg shadow-primary/5">
|
||||
<Mail className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-light text-foreground tracking-tight">
|
||||
{appName}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={handleUsernameChange}
|
||||
onFocus={handleUsernameFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
|
||||
placeholder={t("username_placeholder")}
|
||||
required
|
||||
autoComplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Custom autocomplete dropdown */}
|
||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
className="absolute top-full mt-1 w-full bg-secondary border border-border rounded-md shadow-lg z-50 overflow-hidden"
|
||||
>
|
||||
{filteredSuggestions.map((username, index) => (
|
||||
<div
|
||||
key={username}
|
||||
className={`px-4 py-2.5 flex items-center justify-between hover:bg-muted cursor-pointer transition-colors ${
|
||||
index === selectedSuggestionIndex ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => selectSuggestion(username)}
|
||||
>
|
||||
<span className="text-sm text-foreground">{username}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => removeUsername(username, e)}
|
||||
className="p-1 hover:bg-background rounded transition-colors"
|
||||
title="Remove from history"
|
||||
>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="h-12 px-4 bg-secondary/50 border-border/50 focus:bg-secondary focus:border-primary/50 transition-colors"
|
||||
placeholder={t("password_placeholder")}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 font-medium text-base bg-primary hover:bg-primary/90 transition-all duration-200 shadow-lg shadow-primary/20"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("signing_in")}
|
||||
</div>
|
||||
) : (
|
||||
t("sign_in")
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { EmailList } from "@/components/email/email-list";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { EmailComposer } from "@/components/email/email-composer";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader, MobileViewerHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email } from "@/lib/jmap/types";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ErrorBoundary,
|
||||
SidebarErrorFallback,
|
||||
EmailListErrorFallback,
|
||||
EmailViewerErrorFallback,
|
||||
ComposerErrorFallback,
|
||||
} from "@/components/error";
|
||||
import { DragDropProvider } from "@/contexts/drag-drop-context";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations();
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose');
|
||||
const [initialCheckDone, setInitialCheckDone] = useState(false);
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
// Mobile conversation view state
|
||||
const [conversationThread, setConversationThread] = useState<ThreadGroup | null>(null);
|
||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore();
|
||||
|
||||
// Mobile responsive hooks
|
||||
const { isMobile } = useDeviceDetection();
|
||||
const { activeView, sidebarOpen, setSidebarOpen, setActiveView } = useUIStore();
|
||||
const {
|
||||
emails,
|
||||
mailboxes,
|
||||
selectedEmail,
|
||||
selectedMailbox,
|
||||
quota,
|
||||
isPushConnected,
|
||||
newEmailNotification,
|
||||
selectEmail,
|
||||
selectMailbox,
|
||||
selectAllEmails,
|
||||
clearSelection,
|
||||
fetchMailboxes,
|
||||
fetchEmails,
|
||||
fetchQuota,
|
||||
sendEmail,
|
||||
deleteEmail,
|
||||
markAsRead,
|
||||
toggleStar,
|
||||
moveToMailbox,
|
||||
searchEmails,
|
||||
isLoading,
|
||||
isLoadingEmail,
|
||||
setLoadingEmail,
|
||||
setPushConnected,
|
||||
handleStateChange,
|
||||
clearNewEmailNotification,
|
||||
} = useEmailStore();
|
||||
|
||||
// Play notification sound for new emails
|
||||
const playNotificationSound = () => {
|
||||
try {
|
||||
// Use Web Audio API for a simple notification beep
|
||||
const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800; // Hz
|
||||
oscillator.type = 'sine';
|
||||
gainNode.gain.value = 0.1; // Low volume
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.15); // Short beep
|
||||
} catch (e) {
|
||||
debug.log('Could not play notification sound:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts handlers
|
||||
const keyboardHandlers = useMemo(() => ({
|
||||
onNextEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : -1;
|
||||
const nextIndex = currentIndex < emails.length - 1 ? currentIndex + 1 : currentIndex;
|
||||
if (nextIndex >= 0 && nextIndex < emails.length) {
|
||||
handleEmailSelect(emails[nextIndex]);
|
||||
}
|
||||
},
|
||||
onPreviousEmail: () => {
|
||||
if (emails.length === 0) return;
|
||||
const currentIndex = selectedEmail ? emails.findIndex(e => e.id === selectedEmail.id) : emails.length;
|
||||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
|
||||
if (prevIndex >= 0 && prevIndex < emails.length) {
|
||||
handleEmailSelect(emails[prevIndex]);
|
||||
}
|
||||
},
|
||||
onOpenEmail: () => {
|
||||
// Email is already opened when selected
|
||||
},
|
||||
onCloseEmail: () => {
|
||||
selectEmail(null);
|
||||
if (isMobile) {
|
||||
setActiveView("list");
|
||||
}
|
||||
},
|
||||
onReply: () => {
|
||||
if (selectedEmail) handleReply();
|
||||
},
|
||||
onReplyAll: () => {
|
||||
if (selectedEmail) handleReplyAll();
|
||||
},
|
||||
onForward: () => {
|
||||
if (selectedEmail) handleForward();
|
||||
},
|
||||
onToggleStar: () => {
|
||||
if (selectedEmail) handleToggleStar();
|
||||
},
|
||||
onArchive: () => {
|
||||
if (selectedEmail) handleArchive();
|
||||
},
|
||||
onDelete: () => {
|
||||
if (selectedEmail) handleDelete();
|
||||
},
|
||||
onMarkAsUnread: async () => {
|
||||
if (selectedEmail && client) {
|
||||
await markAsRead(client, selectedEmail.id, false);
|
||||
}
|
||||
},
|
||||
onMarkAsRead: async () => {
|
||||
if (selectedEmail && client) {
|
||||
await markAsRead(client, selectedEmail.id, true);
|
||||
}
|
||||
},
|
||||
onCompose: () => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
},
|
||||
onFocusSearch: () => {
|
||||
const searchInput = document.querySelector('[data-search-input]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
searchInput.select();
|
||||
}
|
||||
},
|
||||
onShowHelp: () => {
|
||||
setShowShortcutsModal(true);
|
||||
},
|
||||
onRefresh: async () => {
|
||||
if (client && selectedMailbox) {
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
}
|
||||
},
|
||||
onSelectAll: () => {
|
||||
selectAllEmails();
|
||||
},
|
||||
onDeselectAll: () => {
|
||||
clearSelection();
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [emails, selectedEmail, client, selectedMailbox, isMobile]);
|
||||
|
||||
// Initialize keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
enabled: isAuthenticated && !showComposer,
|
||||
emails,
|
||||
selectedEmailId: selectedEmail?.id,
|
||||
handlers: keyboardHandlers,
|
||||
});
|
||||
|
||||
// Update page title based on context
|
||||
useEffect(() => {
|
||||
let title = "Webmail";
|
||||
|
||||
if (showComposer) {
|
||||
// Composing email
|
||||
const modeText = {
|
||||
compose: t('email_composer.new_message'),
|
||||
reply: t('email_composer.reply'),
|
||||
replyAll: t('email_composer.reply_all'),
|
||||
forward: t('email_composer.forward'),
|
||||
}[composerMode] || t('email_composer.new_message');
|
||||
title = `${modeText} - Webmail`;
|
||||
} else if (selectedEmail) {
|
||||
// Reading email
|
||||
const subject = selectedEmail.subject || t('email_viewer.no_subject');
|
||||
title = `${subject} - Webmail`;
|
||||
} else if (selectedMailbox && mailboxes.length > 0) {
|
||||
// Mailbox view
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
if (mailbox) {
|
||||
const mailboxName = mailbox.name;
|
||||
const unreadCount = mailbox.unreadEmails || 0;
|
||||
title = unreadCount > 0
|
||||
? `${mailboxName} (${unreadCount}) - Webmail`
|
||||
: `${mailboxName} - Webmail`;
|
||||
}
|
||||
}
|
||||
|
||||
document.title = title;
|
||||
}, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
checkAuth().finally(() => {
|
||||
setInitialCheckDone(true);
|
||||
});
|
||||
}, [checkAuth]);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (initialCheckDone && !isAuthenticated && !authLoading) {
|
||||
router.push(`/${params.locale}/login`);
|
||||
}
|
||||
}, [initialCheckDone, isAuthenticated, authLoading, router, params.locale]);
|
||||
|
||||
// Load mailboxes and emails when authenticated (only if not already loaded)
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && client && mailboxes.length === 0) {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
|
||||
await Promise.all([
|
||||
fetchMailboxes(client),
|
||||
fetchQuota(client)
|
||||
]);
|
||||
|
||||
// Get the selected mailbox (should be inbox by default)
|
||||
const state = useEmailStore.getState();
|
||||
const selectedMailboxId = state.selectedMailbox;
|
||||
|
||||
// Fetch emails for the selected mailbox
|
||||
if (selectedMailboxId) {
|
||||
await fetchEmails(client, selectedMailboxId);
|
||||
} else {
|
||||
await fetchEmails(client);
|
||||
}
|
||||
|
||||
// Setup push notifications after successful data load
|
||||
try {
|
||||
// Register state change callback
|
||||
client.onStateChange((change) => handleStateChange(change, client));
|
||||
|
||||
// Start receiving push notifications
|
||||
const pushEnabled = client.setupPushNotifications();
|
||||
|
||||
if (pushEnabled) {
|
||||
setPushConnected(true);
|
||||
debug.log('[Push] Push notifications successfully enabled');
|
||||
} else {
|
||||
debug.log('[Push] Push notifications not available on this server');
|
||||
}
|
||||
} catch (error) {
|
||||
// Push notifications are optional - don't break the app if they fail
|
||||
debug.log('[Push] Failed to setup push notifications:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading email data:', error);
|
||||
}
|
||||
};
|
||||
loadData();
|
||||
}
|
||||
|
||||
// Cleanup push notifications on unmount
|
||||
return () => {
|
||||
if (client) {
|
||||
client.closePushNotifications();
|
||||
}
|
||||
};
|
||||
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, handleStateChange, setPushConnected]);
|
||||
|
||||
// Handle mark-as-read with delay based on settings
|
||||
useEffect(() => {
|
||||
// Clear any existing timeout when email changes
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Clearing previous timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Only set timeout if there's a selected email, it's unread, and we have a client
|
||||
if (!selectedEmail || !client || selectedEmail.keywords?.$seen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current setting value
|
||||
const markAsReadDelay = useSettingsStore.getState().markAsReadDelay;
|
||||
debug.log('[Mark as Read] Delay setting:', markAsReadDelay, 'ms for email:', selectedEmail.id);
|
||||
|
||||
if (markAsReadDelay === -1) {
|
||||
// Never mark as read automatically
|
||||
debug.log('[Mark as Read] Never mode - email will stay unread');
|
||||
} else if (markAsReadDelay === 0) {
|
||||
// Mark as read instantly
|
||||
debug.log('[Mark as Read] Instant mode - marking as read now');
|
||||
markAsRead(client, selectedEmail.id, true);
|
||||
} else {
|
||||
// Mark as read after delay
|
||||
debug.log('[Mark as Read] Delayed mode - will mark as read in', markAsReadDelay, 'ms');
|
||||
markAsReadTimeoutRef.current = setTimeout(() => {
|
||||
debug.log('[Mark as Read] Timeout fired - marking as read now');
|
||||
markAsRead(client, selectedEmail.id, true);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}, markAsReadDelay);
|
||||
}
|
||||
|
||||
// Cleanup on unmount or when dependencies change
|
||||
return () => {
|
||||
if (markAsReadTimeoutRef.current) {
|
||||
debug.log('[Mark as Read] Cleanup - clearing timeout');
|
||||
clearTimeout(markAsReadTimeoutRef.current);
|
||||
markAsReadTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedEmail?.id]);
|
||||
|
||||
// Handle new email notifications - play sound
|
||||
useEffect(() => {
|
||||
if (newEmailNotification) {
|
||||
playNotificationSound();
|
||||
debug.log('New email received:', newEmailNotification.subject);
|
||||
clearNewEmailNotification();
|
||||
}
|
||||
}, [newEmailNotification, clearNewEmailNotification]);
|
||||
|
||||
const handleEmailSend = async (data: {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
bcc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
draftId?: string;
|
||||
}) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.draftId);
|
||||
setShowComposer(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscardDraft = async (draftId: string) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.deleteEmail(draftId);
|
||||
} catch (error) {
|
||||
console.error("Failed to discard draft:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleReplyAll = () => {
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleForward = () => {
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
try {
|
||||
await deleteEmail(client, selectedEmail.id);
|
||||
selectEmail(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete email:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
// Find archive mailbox
|
||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||
if (archiveMailbox) {
|
||||
try {
|
||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
||||
selectEmail(null);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive email:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStar = async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
try {
|
||||
await toggleStar(client, selectedEmail.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle star:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetColorTag = async (emailId: string, color: string | null) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Remove any existing color tags
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
if (!email) return;
|
||||
|
||||
const keywords = { ...email.keywords };
|
||||
|
||||
// Remove old color tags - set to false for JMAP to remove them
|
||||
Object.keys(keywords).forEach(key => {
|
||||
if (key.startsWith("$color:")) {
|
||||
keywords[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Add new color tag if specified
|
||||
if (color) {
|
||||
keywords[`$color:${color}`] = true;
|
||||
}
|
||||
|
||||
// Update email keywords via JMAP
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
|
||||
// Update local state
|
||||
selectEmail(email.id === selectedEmail?.id ? { ...email, keywords } : selectedEmail);
|
||||
|
||||
// Refresh emails list to show color in list
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
} catch (error) {
|
||||
console.error("Failed to set color tag:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMailboxSelect = async (mailboxId: string) => {
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null); // Clear selected email when switching mailboxes
|
||||
|
||||
// On mobile, close sidebar and go to list view
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
|
||||
if (client) {
|
||||
await fetchEmails(client, mailboxId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push(`/${params.locale}/login`);
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!client) return;
|
||||
await searchEmails(client, query);
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
await client.downloadBlob(blobId, name, type);
|
||||
} catch (error) {
|
||||
console.error("Failed to download attachment:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickReply = async (body: string) => {
|
||||
if (!client || !selectedEmail) return;
|
||||
|
||||
const sender = selectedEmail.from?.[0];
|
||||
if (!sender?.email) {
|
||||
throw new Error("No sender email found");
|
||||
}
|
||||
|
||||
// Send reply with just the body text
|
||||
await sendEmail(
|
||||
client,
|
||||
[sender.email],
|
||||
`Re: ${selectedEmail.subject || "(no subject)"}`,
|
||||
body
|
||||
);
|
||||
|
||||
// Refresh emails to show the sent reply
|
||||
await fetchEmails(client, selectedMailbox);
|
||||
};
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (!initialCheckDone || authLoading || (!isAuthenticated || !client)) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current mailbox name for mobile header
|
||||
const currentMailboxName = mailboxes.find(m => m.id === selectedMailbox)?.name || "Inbox";
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
const handleEmailSelect = async (email: { id: string }) => {
|
||||
if (!client || !email) return;
|
||||
|
||||
// Set loading state immediately (keep current email visible)
|
||||
setLoadingEmail(true);
|
||||
|
||||
// On mobile, switch to viewer
|
||||
if (isMobile) {
|
||||
setActiveView("viewer");
|
||||
}
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// Find selected mailbox to determine accountId (for shared folders)
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const fullEmail = await client.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch email content:', error);
|
||||
} finally {
|
||||
setLoadingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle back navigation from viewer on mobile
|
||||
const handleMobileBack = () => {
|
||||
// If in conversation view, clear it
|
||||
if (conversationThread) {
|
||||
setConversationThread(null);
|
||||
setConversationEmails([]);
|
||||
}
|
||||
selectEmail(null);
|
||||
setActiveView("list");
|
||||
};
|
||||
|
||||
// Handle opening conversation view on mobile
|
||||
const handleOpenConversation = async (thread: ThreadGroup) => {
|
||||
if (!client) return;
|
||||
|
||||
setConversationThread(thread);
|
||||
setIsLoadingConversation(true);
|
||||
setActiveView("viewer");
|
||||
|
||||
try {
|
||||
// Fetch complete thread emails
|
||||
const emails = await client.getThreadEmails(thread.threadId);
|
||||
setConversationEmails(emails);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thread emails:', error);
|
||||
// Fall back to thread.emails
|
||||
setConversationEmails(thread.emails);
|
||||
} finally {
|
||||
setIsLoadingConversation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reply from conversation view
|
||||
const handleConversationReply = (email: Email) => {
|
||||
selectEmail(email);
|
||||
setComposerMode('reply');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleConversationReplyAll = (email: Email) => {
|
||||
selectEmail(email);
|
||||
setComposerMode('replyAll');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
const handleConversationForward = (email: Email) => {
|
||||
selectEmail(email);
|
||||
setComposerMode('forward');
|
||||
setShowComposer(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropProvider>
|
||||
<div className="flex h-screen bg-background overflow-hidden">
|
||||
{/* Mobile Sidebar Overlay Backdrop */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar - overlay on mobile, fixed on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 h-full z-50",
|
||||
// Mobile: fixed overlay
|
||||
"max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:w-72",
|
||||
"max-md:transform max-md:transition-transform max-md:duration-300 max-md:ease-in-out",
|
||||
isMobile && !sidebarOpen && "max-md:-translate-x-full",
|
||||
// Desktop: normal flow
|
||||
"md:relative md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary fallback={SidebarErrorFallback}>
|
||||
<Sidebar
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
onLogout={handleLogout}
|
||||
onSearch={handleSearch}
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 min-w-0 h-full">
|
||||
{/* Email List - full width on mobile, fixed width on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-background border-r border-border",
|
||||
// Mobile: full width, hidden when viewing email
|
||||
"max-md:flex-1 max-md:border-r-0",
|
||||
isMobile && activeView !== "list" && "max-md:hidden",
|
||||
// Desktop: fixed width
|
||||
"md:w-80 lg:w-96 md:flex-shrink-0 md:shadow-sm"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Header for List View */}
|
||||
<MobileHeader
|
||||
title={currentMailboxName}
|
||||
onCompose={() => {
|
||||
setComposerMode('compose');
|
||||
setShowComposer(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
<EmailList
|
||||
emails={emails}
|
||||
selectedEmailId={selectedEmail?.id}
|
||||
isLoading={isLoading}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onOpenConversation={handleOpenConversation}
|
||||
// Context menu handlers
|
||||
onReply={(email) => {
|
||||
selectEmail(email);
|
||||
handleReply();
|
||||
}}
|
||||
onReplyAll={(email) => {
|
||||
selectEmail(email);
|
||||
handleReplyAll();
|
||||
}}
|
||||
onForward={(email) => {
|
||||
selectEmail(email);
|
||||
handleForward();
|
||||
}}
|
||||
onMarkAsRead={async (email, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, email.id, read);
|
||||
}
|
||||
}}
|
||||
onToggleStar={async (email) => {
|
||||
if (client) {
|
||||
await toggleStar(client, email.id);
|
||||
}
|
||||
}}
|
||||
onDelete={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleDelete();
|
||||
}}
|
||||
onArchive={async (email) => {
|
||||
selectEmail(email);
|
||||
await handleArchive();
|
||||
}}
|
||||
onSetColorTag={(emailId, color) => {
|
||||
handleSetColorTag(emailId, color);
|
||||
}}
|
||||
onMoveToMailbox={async (emailId, mailboxId) => {
|
||||
if (client) {
|
||||
await moveToMailbox(client, emailId, mailboxId);
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Email Viewer - full screen on mobile, flex on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col h-full bg-background",
|
||||
// Mobile: full screen overlay when active
|
||||
"max-md:fixed max-md:inset-0 max-md:z-30",
|
||||
isMobile && activeView !== "viewer" && "max-md:hidden",
|
||||
// Desktop: flex grow
|
||||
"md:flex-1 md:relative"
|
||||
)}
|
||||
>
|
||||
{/* Mobile Conversation View - shown when thread is selected on mobile */}
|
||||
{isMobile && conversationThread ? (
|
||||
<ThreadConversationView
|
||||
thread={conversationThread}
|
||||
emails={conversationEmails}
|
||||
isLoading={isLoadingConversation}
|
||||
onBack={handleMobileBack}
|
||||
onReply={handleConversationReply}
|
||||
onReplyAll={handleConversationReplyAll}
|
||||
onForward={handleConversationForward}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Mobile Header for Viewer */}
|
||||
{isMobile && activeView === "viewer" && (
|
||||
<MobileViewerHeader
|
||||
subject={selectedEmail?.subject}
|
||||
onBack={handleMobileBack}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ErrorBoundary fallback={EmailViewerErrorFallback}>
|
||||
<EmailViewer
|
||||
email={selectedEmail}
|
||||
isLoading={isLoadingEmail}
|
||||
onReply={handleReply}
|
||||
onReplyAll={handleReplyAll}
|
||||
onForward={handleForward}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
onToggleStar={handleToggleStar}
|
||||
onSetColorTag={handleSetColorTag}
|
||||
onMarkAsRead={async (emailId, read) => {
|
||||
if (client) {
|
||||
await markAsRead(client, emailId, read);
|
||||
}
|
||||
}}
|
||||
onDownloadAttachment={handleDownloadAttachment}
|
||||
onQuickReply={handleQuickReply}
|
||||
currentUserEmail={client?.["username"]}
|
||||
currentUserName={client?.["username"]?.split("@")[0]}
|
||||
className={isMobile ? "flex-1" : undefined}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Composer Modal */}
|
||||
{showComposer && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 md:p-0">
|
||||
<div className={cn(
|
||||
"w-full h-full md:h-auto md:max-w-3xl md:max-h-[600px]",
|
||||
"max-md:flex max-md:flex-col"
|
||||
)}>
|
||||
<ErrorBoundary
|
||||
fallback={ComposerErrorFallback}
|
||||
onReset={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
}}
|
||||
>
|
||||
<EmailComposer
|
||||
mode={composerMode}
|
||||
replyTo={selectedEmail ? {
|
||||
from: selectedEmail.from,
|
||||
to: selectedEmail.to,
|
||||
cc: selectedEmail.cc,
|
||||
subject: selectedEmail.subject,
|
||||
body: selectedEmail.bodyValues?.[selectedEmail.textBody?.[0]?.partId || '']?.value || selectedEmail.preview || '',
|
||||
receivedAt: selectedEmail.receivedAt
|
||||
} : undefined}
|
||||
onSend={handleEmailSend}
|
||||
onClose={() => {
|
||||
setShowComposer(false);
|
||||
setComposerMode('compose');
|
||||
}}
|
||||
onDiscardDraft={handleDiscardDraft}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Modal */}
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { ArrowLeft, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AppearanceSettings } from '@/components/settings/appearance-settings';
|
||||
import { EmailSettings } from '@/components/settings/email-settings';
|
||||
import { AccountSettings } from '@/components/settings/account-settings';
|
||||
import { AdvancedSettings } from '@/components/settings/advanced-settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Tab = 'appearance' | 'email' | 'account' | 'advanced';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const t = useTranslations('settings');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('appearance');
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'appearance', label: t('tabs.appearance') },
|
||||
{ id: 'email', label: t('tabs.email') },
|
||||
{ id: 'account', label: t('tabs.account') },
|
||||
{ id: 'advanced', label: t('tabs.advanced') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-background">
|
||||
{/* Settings Sidebar */}
|
||||
<div className="w-64 border-r border-border bg-secondary flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/${params.locale}`)}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{t('back_to_mail')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="px-2 space-y-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<SettingsIcon className="w-8 h-8 text-foreground" />
|
||||
<h1 className="text-3xl font-semibold text-foreground">{t('title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tab Content */}
|
||||
<div className="bg-card border border-border rounded-lg p-6">
|
||||
{activeTab === 'appearance' && <AppearanceSettings />}
|
||||
{activeTab === 'email' && <EmailSettings />}
|
||||
{activeTab === 'account' && <AccountSettings />}
|
||||
{activeTab === 'advanced' && <AdvancedSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Global error boundary for the root layout.
|
||||
* Note: This component cannot use translations since it's outside providers.
|
||||
* It must render its own <html> and <body> tags as it replaces the root layout.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error("Global error:", error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-50 dark:bg-gray-900">
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center max-w-md px-4">
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="w-10 h-10 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--color-border: #e2e8f0;
|
||||
--color-input: #e2e8f0;
|
||||
--color-ring: #94a3b8;
|
||||
--color-background: #ffffff;
|
||||
--color-foreground: #0f172a;
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #f8fafc;
|
||||
--color-secondary-foreground: #0f172a;
|
||||
--color-muted: #f1f5f9;
|
||||
--color-muted-foreground: #64748b;
|
||||
--color-accent: #dbeafe;
|
||||
--color-accent-foreground: #1e40af;
|
||||
|
||||
/* Settings variables */
|
||||
--font-size-base: 16px;
|
||||
--list-item-height: 48px;
|
||||
--transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-border: #262626;
|
||||
--color-input: #262626;
|
||||
--color-ring: #d4d4d4;
|
||||
--color-background: #0a0a0a;
|
||||
--color-foreground: #fafafa;
|
||||
--color-primary: #fafafa;
|
||||
--color-primary-foreground: #171717;
|
||||
--color-secondary: #262626;
|
||||
--color-secondary-foreground: #fafafa;
|
||||
--color-muted: #262626;
|
||||
--color-muted-foreground: #a3a3a3;
|
||||
--color-accent: #1e3a8a;
|
||||
--color-accent-foreground: #dbeafe;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-border: var(--color-border);
|
||||
--color-input: var(--color-input);
|
||||
--color-ring: var(--color-ring);
|
||||
--color-background: var(--color-background);
|
||||
--color-foreground: var(--color-foreground);
|
||||
--color-primary: var(--color-primary);
|
||||
--color-primary-foreground: var(--color-primary-foreground);
|
||||
--color-secondary: var(--color-secondary);
|
||||
--color-secondary-foreground: var(--color-secondary-foreground);
|
||||
--color-muted: var(--color-muted);
|
||||
--color-muted-foreground: var(--color-muted-foreground);
|
||||
--color-accent: var(--color-accent);
|
||||
--color-accent-foreground: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: var(--font-size-base);
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
/* Minimalist scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
/* Enhanced Email Content Styling */
|
||||
.email-content {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-foreground);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.email-content p {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.email-content a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.email-content a:hover {
|
||||
text-decoration: underline;
|
||||
color: var(--color-accent-foreground);
|
||||
}
|
||||
|
||||
.email-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.375rem;
|
||||
margin: 1rem 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.email-content blockquote {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .email-content blockquote {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.email-content pre {
|
||||
background-color: #f5f5f7;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.375rem;
|
||||
padding: 1rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content pre {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.email-content code {
|
||||
background-color: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-family: 'SF Mono', Monaco, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.dark .email-content code {
|
||||
background-color: #374151;
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.email-content h1,
|
||||
.email-content h2,
|
||||
.email-content h3,
|
||||
.email-content h4 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.email-content h1 { font-size: 1.5rem; }
|
||||
.email-content h2 { font-size: 1.25rem; }
|
||||
.email-content h3 { font-size: 1.125rem; }
|
||||
.email-content h4 { font-size: 1rem; }
|
||||
|
||||
.email-content ul,
|
||||
.email-content ol {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.email-content li {
|
||||
margin: 0.375rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.email-content ul li {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.email-content ol li {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
/* Only style tables that are actual data tables, not layout tables */
|
||||
.email-content table.data-table,
|
||||
.email-content table[border="1"] {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table.data-table td,
|
||||
.email-content table[border="1"] th,
|
||||
.email-content table[border="1"] td {
|
||||
padding: 0.625rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.email-content table.data-table th,
|
||||
.email-content table[border="1"] th {
|
||||
background-color: #f9fafb;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table th,
|
||||
.dark .email-content table[border="1"] th {
|
||||
background-color: #1f2937;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dark .email-content table.data-table td,
|
||||
.dark .email-content table[border="1"] td {
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
/* Reset styles for layout tables (commonly used in HTML emails) */
|
||||
.email-content table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
/* Let HTML email's own styles take precedence */
|
||||
.email-content table:not(.data-table):not([border="1"]) {
|
||||
border: initial;
|
||||
}
|
||||
|
||||
.email-content table:not(.data-table):not([border="1"]) td,
|
||||
.email-content table:not(.data-table):not([border="1"]) th {
|
||||
border: initial;
|
||||
padding: initial;
|
||||
}
|
||||
|
||||
.email-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.dark .email-content hr {
|
||||
border-top-color: #374151;
|
||||
}
|
||||
|
||||
/* Email thread quoted text */
|
||||
.email-content .quoted-text {
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
color: #6b7280;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dark .email-content .quoted-text {
|
||||
border-left-color: #4b5563;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Mobile Responsive Utilities */
|
||||
|
||||
/* Safe area insets for notched devices (iPhone X+, etc.) */
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.safe-area-inset-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.safe-area-inset-left {
|
||||
padding-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
.safe-area-inset-right {
|
||||
padding-right: env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
/* Minimum touch targets (44x44px recommended by Apple HIG) */
|
||||
.touch-target {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Prevent text selection on mobile for UI elements */
|
||||
.no-select {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
/* Smooth transitions for view switching */
|
||||
.view-transition {
|
||||
transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* Hide scrollbar on mobile while keeping functionality */
|
||||
@media (max-width: 767px) {
|
||||
.mobile-scroll-hidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-scroll-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile backdrop blur support */
|
||||
@supports (backdrop-filter: blur(8px)) {
|
||||
.mobile-backdrop {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent overscroll bounce on iOS */
|
||||
.no-overscroll {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Slide in from right animation (for mobile views) */
|
||||
@keyframes slide-in-from-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-from-right {
|
||||
animation: slide-in-from-right 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Slide in from left animation (for sidebar) */
|
||||
@keyframes slide-in-from-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-from-left {
|
||||
animation: slide-in-from-left 0.3s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// This is the root layout that wraps all pages
|
||||
// The actual layout with providers and styles is in [locale]/layout.tsx
|
||||
export default function RootLayout({ children }: Props) {
|
||||
return children;
|
||||
}
|
||||
Reference in New Issue
Block a user