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:
Matthieu MALVACHE
2025-12-10 17:54:22 +01:00
committed by Matthieu MALVACHE
commit cf21a84263
79 changed files with 21821 additions and 0 deletions
+53
View File
@@ -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>
);
}
+77
View File
@@ -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>
);
}
+311
View File
@@ -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>
);
}
+837
View File
@@ -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>
);
}
+89
View File
@@ -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>
);
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+48
View File
@@ -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
View File
@@ -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;
}
+11
View File
@@ -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;
}