Files
LambdaMail/services/webmail/src/app/(user)/user/layout.tsx
T
Lucas RayanandClaude Sonnet 5.5 e667843b59 feat(webmail): the new interface - logo, dark and light themes, command menu
A mark (a lambda on an ultramarine tile with the flap of an envelope), the
favicon, home-screen icons and an install manifest. Every colour is a variable
defined once per theme, and the Tailwind scales the screens already use read
them, so dark (the default) and light are a change of values and not of
classes. Geist is served by the app itself. Ctrl+K opens a command menu for
folders, actions, accounts and appearance, and typing something that matches no
command searches the mail; c writes a message and / goes to the search field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 22:33:15 -03:00

354 lines
15 KiB
TypeScript

"use client";
import React, { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
Inbox,
Send,
FileText,
Archive,
AlertTriangle,
Trash2,
Folder as FolderIcon,
Plus,
PenSquare,
LogOut,
Mail,
ShieldCheck,
ShieldAlert,
SlidersHorizontal,
Menu,
Search,
X,
} from "lucide-react";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders";
import { folderBadge, customFolders } from "../../../lib/mailCounts";
import { AccountSwitcher } from "../../../components/AccountSwitcher";
import { Logo } from "../../../components/Logo";
import { CommandPalette } from "../../../components/CommandPalette";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const account = useAccount("user");
// Live, not fetched once: these badges used to freeze at the value they had
// when the tab was opened.
const { folders, reload: reloadFolders } = useFolders();
const [newFolder, setNewFolder] = useState("");
const [folderError, setFolderError] = useState<string | null>(null);
/** Creates, renames or deletes one of the user's own folders. */
const manageFolder = async (body: Record<string, string>) => {
setFolderError(null);
try {
const res = await fetch("/api/v1/mail/folders/manage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setFolderError(data.message ?? t("errors.serverError"));
return false;
}
reloadFolders();
return true;
} catch {
setFolderError(t("errors.serverError"));
return false;
}
};
const navItems = [
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
{ href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" },
{ href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" },
{ href: "/user/mail/archive", label: t("mail.archive"), icon: Archive, role: "archive" },
{ href: "/user/mail/junk", label: t("mail.junk"), icon: AlertTriangle, role: "junk" },
{ href: "/user/mail/trash", label: t("mail.trash"), icon: Trash2, role: "trash" },
// Where delivered DMARC and TLS-RPT reports are filed. Matched by name
// rather than a special-use role, because IMAP defines none for reports.
{ href: "/user/mail/reports", label: t("mail.reports"), icon: ShieldCheck, role: "reports" },
];
const handleLogout = () => {
void fetch("/api/v1/auth/logout", { method: "POST" }).finally(() => {
window.location.href = "/user/login";
});
};
const settingsActive = pathname === "/user/settings";
const SidebarContent = (
<div className="flex h-full flex-col gap-4 p-3">
{/* The brand mark. One mark for both surfaces - the line underneath is
the only thing that says which one you are in. */}
<div className="flex items-center gap-2.5 px-2 py-1">
<Logo size={32} className="flex-none" />
<div className="min-w-0">
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
<div className="text-[11.5px] text-slate-400">
{t("common.userPortal")}
</div>
</div>
</div>
<Link
href="/user/compose"
onClick={() => setMobileOpen(false)}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-fill px-3 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-fill-hover active:brightness-95"
>
<PenSquare className="h-4 w-4 flex-none" />
<span className="min-w-0">{t("mail.compose")}</span>
</Link>
{/* The way into everything that is not on screen. Quiet on purpose: the
sidebar keeps to folders, and this is where the rest lives. */}
<button
type="button"
onClick={() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }))}
className="-mt-1 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-[13px] text-slate-400 shadow-edge transition-colors hover:bg-ink/[0.05] hover:text-slate-200"
>
<Search className="h-4 w-4 flex-none" aria-hidden />
<span className="min-w-0 flex-1 truncate">{t("palette.open")}</span>
<kbd className="rounded-md px-1.5 py-0.5 text-[11px] shadow-edge">Ctrl K</kbd>
</button>
{/* Folder navigation. Every row is a mark, an icon, a label that may wrap,
and its numbers - nothing is sized by the length of the label, which
is what clipped the Portuguese and Spanish names before. */}
<nav className="flex flex-col gap-0.5">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
// Both numbers plus which one to emphasise; see folderBadge, where
// that rule is tested. It used to fall back to printing the total in
// the unread badge's place, so an inbox of read mail showed "14" and
// reading another message never moved it.
const badge = folderBadge(folders, item.role);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
data-active={isActive}
title={badge.total > 0 ? t("mail.messagesInFolder", { count: badge.total }) : item.label}
className="lm-nav"
>
<span className="lm-nav-mark" />
<Icon className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`} />
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{item.label}</span>
<span className="flex flex-none items-center gap-1.5">
{/* Only what is waiting to be read. The size of every folder
beside its name filled the rail with numbers that never
change and that nobody is waiting on; the folder heading
still says how much mail a folder holds. */}
{badge.showsUnread && (
<span
className={`rounded-full px-1.5 py-px text-[11px] font-medium tabular-nums ${
isActive ? "bg-indigo-500/25 text-indigo-200" : "bg-indigo-500/20 text-indigo-300"
}`}
title={t("mail.unreadCount", { count: badge.unread })}
>
{badge.unread}
</span>
)}
</span>
</Link>
);
})}
{/* The folders the user made. They exist in the database and the rules
can file into them, but the sidebar listed only the fixed six, so
there was no way to see one - or to make one. */}
{customFolders(folders).length > 0 && <div className="lm-rule my-2.5" />}
{customFolders(folders).map((folder) => {
const badge = folderBadge(folders, folder.name);
const href = `/user/mail/${encodeURIComponent(folder.name)}`;
const isActive = decodeURIComponent(pathname) === decodeURIComponent(href);
return (
<div key={folder.name} className="group/folder relative">
<Link
href={href}
onClick={() => setMobileOpen(false)}
data-active={isActive}
title={folder.name}
className="lm-nav"
>
<span className="lm-nav-mark" />
<FolderIcon
className={`h-[17px] w-[17px] flex-none ${isActive ? "text-indigo-500" : "text-slate-400"}`}
/>
<span className="min-w-0 flex-1 truncate text-[13.5px] leading-snug">{folder.name}</span>
<span className="flex flex-none items-center gap-1.5 pr-5">
{badge.showsUnread && (
<span className="rounded-full bg-indigo-500/20 px-1.5 py-px text-[11px] font-medium tabular-nums text-indigo-300">
{badge.unread}
</span>
)}
</span>
</Link>
{/* Deleting takes the mail inside with it, so the confirmation
says so rather than asking a bare "are you sure". */}
<button
type="button"
aria-label={t("folders.delete", { name: folder.name })}
title={t("folders.delete", { name: folder.name })}
onClick={() => {
if (!window.confirm(t("folders.deleteConfirm", { name: folder.name }))) return;
void manageFolder({ action: "delete", name: folder.name });
}}
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-ink/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
);
})}
{/* Creating one. Inline rather than behind a settings page: a folder is
made at the moment the user realises they want one. */}
<form
className="mt-1.5 flex items-center gap-1.5 px-1"
onSubmit={async (e) => {
e.preventDefault();
const name = newFolder.trim();
if (!name) return;
if (await manageFolder({ action: "create", name })) setNewFolder("");
}}
>
<input
value={newFolder}
onChange={(e) => setNewFolder(e.target.value)}
placeholder={t("folders.newPlaceholder")}
aria-label={t("folders.new")}
maxLength={100}
className="min-w-0 flex-1 rounded-lg bg-dark-panel px-2.5 py-1.5 text-[12.5px] text-slate-200 shadow-edge placeholder:text-slate-500 focus:outline-none"
/>
<button
type="submit"
aria-label={t("folders.new")}
title={t("folders.new")}
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
>
<Plus className="h-4 w-4" />
</button>
</form>
{folderError && (
<p role="alert" className="px-1 pt-1 text-[11.5px] leading-snug text-amber-300">
{folderError}
</p>
)}
<div className="lm-rule my-2.5" />
<Link
href="/user/settings"
onClick={() => setMobileOpen(false)}
data-active={settingsActive}
className="lm-nav"
>
<span className="lm-nav-mark" />
<SlidersHorizontal
className={`h-[17px] w-[17px] flex-none ${settingsActive ? "text-indigo-500" : "text-slate-400"}`}
/>
<span className="min-w-0 flex-1 text-[13.5px] leading-snug">{t("settings.title")}</span>
</Link>
</nav>
<div className="mt-auto flex flex-col gap-2.5">
{/* The two surfaces as one control, so where you are and where else you
could be are the same question. The console goes to the step-up
rather than to the admin sign-in: the admin audience is a separate
token, so crossing over costs a second factor - but not the
password, which the session in hand proved already. */}
{isAdminRole(account?.role) && (
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
<span className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] bg-dark-card px-2 py-1.5 text-[12px] leading-snug text-indigo-500 shadow-edge-accent">
<Mail className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("common.userPortal")}</span>
</span>
<Link
href="/admin/step-up"
title={t("admin.openAdmin")}
aria-label={t("admin.openAdmin")}
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-ink/[0.05] hover:text-slate-100"
>
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
</Link>
</div>
)}
<AccountSwitcher onNavigate={() => setMobileOpen(false)} />
<div className="flex items-center gap-1.5">
<LanguageSwitcher />
<button
type="button"
onClick={handleLogout}
title={t("settings.signOut")}
aria-label={t("settings.signOut")}
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-ink/[0.14] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
>
<LogOut className="h-[15px] w-[15px]" />
</button>
</div>
</div>
</div>
);
return (
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
<CommandPalette isAdmin={isAdminRole(account?.role)} />
{/* Mobile Top Header */}
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] md:hidden">
<div className="flex items-center gap-2.5">
<Logo size={32} className="flex-none" />
<span className="font-medium text-slate-100">LambdaMail</span>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t("ui.toggleMenu")}
aria-expanded={mobileOpen}
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{/* Desktop rail */}
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:flex">
{SidebarContent}
</aside>
{/* Mobile Overlay Menu */}
<AnimatePresence>
{mobileOpen && (
<motion.aside
initial={{ x: "-100%" }}
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:hidden"
>
{SidebarContent}
</motion.aside>
)}
</AnimatePresence>
{/* Main Content Area */}
<main className="relative flex flex-1 flex-col overflow-hidden bg-dark-bg pt-14 md:pt-0">
{children}
</main>
</div>
);
}