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
+54
View File
@@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
interface AvatarProps {
name?: string;
email?: string;
size?: "sm" | "md" | "lg";
className?: string;
}
export function Avatar({ name, email, size = "md", className }: AvatarProps) {
const getInitials = () => {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
if (email) {
return email[0].toUpperCase();
}
return "?";
};
const getBackgroundColor = () => {
const str = name || email || "";
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
return `hsl(${hue}, 70%, 50%)`;
};
const sizeClasses = {
sm: "w-8 h-8 text-xs",
md: "w-10 h-10 text-sm",
lg: "w-12 h-12 text-base",
};
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-semibold text-white",
sizeClasses[size],
className
)}
style={{ backgroundColor: getBackgroundColor() }}
title={name || email}
>
{getInitials()}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "ghost" | "outline" | "destructive";
size?: "sm" | "md" | "lg" | "icon";
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center rounded-md font-medium transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
{
default:
"bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow",
ghost: "hover:bg-accent hover:text-accent-foreground",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
}[variant],
{
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 py-2",
lg: "h-11 px-8",
icon: "h-10 w-10",
}[size],
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button };
+184
View File
@@ -0,0 +1,184 @@
"use client";
import { forwardRef, useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
interface Position {
x: number;
y: number;
}
interface ContextMenuProps {
isOpen: boolean;
position: Position;
onClose: () => void;
children: React.ReactNode;
}
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose: _onClose, children }, ref) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted || !isOpen) return null;
return createPortal(
<div
ref={ref}
className={cn(
"fixed z-50 min-w-[200px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100"
)}
style={{
left: position.x,
top: position.y,
}}
role="menu"
aria-orientation="vertical"
>
<div className="py-1">
{children}
</div>
</div>,
document.body
);
}
);
ContextMenu.displayName = "ContextMenu";
interface ContextMenuItemProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
}
export function ContextMenuItem({
icon: Icon,
label,
onClick,
disabled = false,
destructive = false,
shortcut,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
disabled={disabled}
className={cn(
"w-full px-3 py-2 text-sm text-left flex items-center gap-2",
"transition-colors duration-100",
"focus:outline-none focus:bg-muted",
disabled && "opacity-50 cursor-not-allowed",
!disabled && "hover:bg-muted cursor-pointer",
destructive && !disabled && "text-destructive hover:bg-destructive/10 focus:bg-destructive/10"
)}
onClick={(e) => {
if (disabled) return;
e.stopPropagation();
onClick();
}}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
{shortcut && (
<span className="text-xs text-muted-foreground ml-auto">{shortcut}</span>
)}
</button>
);
}
export function ContextMenuSeparator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
interface ContextMenuSubMenuProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
}
export function ContextMenuSubMenu({
icon: Icon,
label,
children,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPosition, setSubMenuPosition] = useState<"right" | "left">("right");
const itemRef = useRef<HTMLDivElement>(null);
const subMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen && itemRef.current) {
const rect = itemRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
// Check if submenu would overflow right edge
if (rect.right + 200 > viewportWidth - 10) {
setSubMenuPosition("left");
} else {
setSubMenuPosition("right");
}
}
}, [isOpen]);
return (
<div
ref={itemRef}
className="relative"
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
<div
className={cn(
"w-full px-3 py-2 text-sm flex items-center gap-2",
"transition-colors duration-100 cursor-pointer",
"hover:bg-muted"
)}
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
{isOpen && (
<div
ref={subMenuRef}
className={cn(
"absolute top-0 min-w-[180px] bg-background rounded-md shadow-lg border border-border",
"animate-in fade-in-0 zoom-in-95 duration-100",
subMenuPosition === "right" ? "left-full ml-1" : "right-full mr-1"
)}
role="menu"
>
<div className="py-1 max-h-[300px] overflow-y-auto">
{children}
</div>
</div>
)}
</div>
);
}
interface ContextMenuHeaderProps {
children: React.ReactNode;
}
export function ContextMenuHeader({ children }: ContextMenuHeaderProps) {
return (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground border-b border-border">
{children}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground transition-all duration-200",
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground",
"hover:border-muted-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useParams, usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import { locales } from '@/i18n/request';
export function LanguageSwitcher({ className }: { className?: string }) {
const router = useRouter();
const pathname = usePathname();
const params = useParams();
const t = useTranslations('language');
const currentLocale = params.locale as string;
const handleLanguageChange = (newLocale: string) => {
// Get the path without the locale prefix
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '');
// Navigate to the same page with the new locale
router.push(`/${newLocale}${pathWithoutLocale}`);
};
return (
<div className={cn("flex items-center gap-1 p-1 bg-muted rounded-lg", className)}>
{locales.map((locale) => (
<button
key={locale}
onClick={() => handleLanguageChange(locale)}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded transition-all text-xs",
"text-foreground",
currentLocale === locale
? "bg-background shadow-sm font-medium"
: "hover:bg-accent/50"
)}
title={t(locale === 'en' ? 'english' : 'french')}
>
{locale === 'en' ? '🇬🇧' : '🇫🇷'}
<span className="hidden sm:inline">
{locale.toUpperCase()}
</span>
</button>
))}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect } from "react";
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
export type ToastType = "success" | "error" | "info" | "warning";
export interface Toast {
id: string;
type: ToastType;
title: string;
message?: string;
duration?: number;
onClick?: () => void;
}
interface ToastProps {
toast: Toast;
onClose: (id: string) => void;
}
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
};
const styles = {
success: "bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800 text-green-800 dark:text-green-200",
error: "bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200",
info: "bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200",
warning: "bg-amber-50 dark:bg-amber-950/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200",
};
export function ToastItem({ toast, onClose }: ToastProps) {
const Icon = icons[toast.type];
useEffect(() => {
if (toast.duration && toast.duration > 0) {
const timer = setTimeout(() => {
onClose(toast.id);
}, toast.duration);
return () => clearTimeout(timer);
}
}, [toast, onClose]);
return (
<div
className={cn(
"flex items-start gap-3 p-4 rounded-lg border shadow-lg bg-background animate-slide-in",
styles[toast.type],
toast.onClick && "cursor-pointer hover:opacity-90 transition-opacity"
)}
onClick={() => {
if (toast.onClick) {
toast.onClick();
onClose(toast.id);
}
}}
>
<Icon className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="font-medium">{toast.title}</h4>
{toast.message && (
<p className="text-sm mt-1 opacity-90">{toast.message}</p>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
onClose(toast.id);
}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
return (
<div className="fixed bottom-4 right-4 z-50 space-y-2 max-w-sm">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={onClose} />
))}
</div>
);
}