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>
This commit is contained in:
1 parent
6b97807856
commit
e667843b59
38 files changed
+1161
-316
No files matched your search
@@ -22,6 +22,8 @@ import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount } from "../../../lib/useAccount";
|
||||
import { initialsFor } from "../../../lib/initials";
|
||||
import { Logo } from "../../../components/Logo";
|
||||
import { ThemeToggle } from "../../../components/ThemeToggle";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
@@ -52,12 +54,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const SidebarContent = (
|
||||
<div className="flex h-full flex-col gap-4 p-3">
|
||||
<div className="flex items-center gap-2.5 px-2 py-1">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||
<div className="text-[11.5px] text-slate-400">
|
||||
{t("common.adminPortal")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
|
||||
<Link
|
||||
href="/user/mail/inbox"
|
||||
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-white/[0.05] hover:text-slate-100"
|
||||
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"
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
|
||||
@@ -103,7 +103,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge">
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
|
||||
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]">
|
||||
{initialsFor(account?.email)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
@@ -122,12 +122,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LanguageSwitcher />
|
||||
<ThemeToggle />
|
||||
<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-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
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>
|
||||
@@ -139,11 +140,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||
{/* 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_rgba(233,233,237,0.09)] md:hidden">
|
||||
<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 min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<span className="min-w-0 truncate font-medium text-slate-100">
|
||||
{t("common.appName")} {t("common.adminPortal")}
|
||||
</span>
|
||||
@@ -152,14 +151,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label={t("ui.toggleMenu")}
|
||||
aria-expanded={mobileOpen}
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
||||
className="flex h-9 w-9 flex-none 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_rgba(233,233,237,0.09)] md:flex">
|
||||
<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>
|
||||
|
||||
@@ -171,7 +170,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
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_rgba(233,233,237,0.09)] md:hidden"
|
||||
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>
|
||||
|
||||
@@ -483,7 +483,7 @@ export default function AdminMailboxesPage() {
|
||||
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
const iconButton =
|
||||
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
||||
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100";
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
||||
@@ -650,14 +650,14 @@ export default function AdminMailboxesPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
aria-label={t("ui.role")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="USER">{roleLabel(t, "USER")}</option>
|
||||
@@ -668,7 +668,7 @@ export default function AdminMailboxesPage() {
|
||||
value={activeFilter}
|
||||
onChange={(e) => setActiveFilter(e.target.value)}
|
||||
aria-label={t("ui.accountStatus")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
<option value="true">{t("ui.active")}</option>
|
||||
|
||||
@@ -167,14 +167,14 @@ export default function AdminQueuePage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label={t("common.status")}
|
||||
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.all")}</option>
|
||||
{/* Taken from the aggregate, so the choices are the states this
|
||||
|
||||
@@ -180,14 +180,14 @@ export default function AdminTestsPage() {
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("ui.searchPlaceholder")}
|
||||
aria-label={t("ui.searchPlaceholder")}
|
||||
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
aria-label={t("admin.userChecks")}
|
||||
className="min-w-[220px] rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
className="min-w-[220px] rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
|
||||
>
|
||||
<option value="">{t("ui.selectUser")}</option>
|
||||
{users.map((u) => (
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { Logo } from "../../../../components/Logo";
|
||||
import { ThemeToggle } from "../../../../components/ThemeToggle";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const t = useTranslations();
|
||||
@@ -101,15 +103,14 @@ export default function AdminLoginPage() {
|
||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||
{/* The console sign-in wears the same mark as the webmail. It used to be
|
||||
green over green, which made the second surface look like a
|
||||
different product. */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={40} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.adminLoginTitle")}</h1>
|
||||
<p className="mt-0.5 text-[12.5px] leading-relaxed text-slate-400">{t("auth.adminStepUpNote")}</p>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function AdminStepUpPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_rgb(var(--a-500))]">
|
||||
<Sliders className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState } from "react";
|
||||
import { Mail, Lock, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
import { Logo } from "../../../../components/Logo";
|
||||
import { ThemeToggle } from "../../../../components/ThemeToggle";
|
||||
|
||||
export default function UserLoginPage() {
|
||||
const t = useTranslations();
|
||||
@@ -55,12 +57,11 @@ export default function UserLoginPage() {
|
||||
/* No ambient glow behind the card any more: two blurred coloured circles
|
||||
under a sign-in form are decoration that costs a full-viewport paint and
|
||||
says nothing. Nocturne's depth is the surface and its edge. */
|
||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
|
||||
<ThemeToggle className="absolute right-4 top-4" />
|
||||
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={40} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.userLoginTitle")}</h1>
|
||||
<p className="mt-0.5 text-[12.5px] text-slate-400">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ShieldAlert,
|
||||
SlidersHorizontal,
|
||||
Menu,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../i18n/provider";
|
||||
@@ -28,6 +29,8 @@ 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();
|
||||
@@ -87,12 +90,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
{/* 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">
|
||||
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<Logo size={32} className="flex-none" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
||||
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
|
||||
<div className="text-[11.5px] text-slate-400">
|
||||
{t("common.userPortal")}
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,12 +102,24 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
<Link
|
||||
href="/user/compose"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-[10px] border border-indigo-500 px-3 py-2.5 text-sm font-medium text-indigo-500 transition-colors hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]"
|
||||
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. */}
|
||||
@@ -192,7 +205,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
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-white/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
|
||||
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>
|
||||
@@ -223,7 +236,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
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-white/[0.09] hover:text-slate-100"
|
||||
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>
|
||||
@@ -266,7 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
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-white/[0.05] hover:text-slate-100"
|
||||
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>
|
||||
@@ -283,7 +296,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
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-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
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>
|
||||
@@ -294,26 +307,25 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
|
||||
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_rgba(233,233,237,0.09)] md:hidden">
|
||||
<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">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
|
||||
{"\u03bb"}
|
||||
</div>
|
||||
<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-white/[0.07] hover:text-slate-100"
|
||||
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_rgba(233,233,237,0.09)] md:flex">
|
||||
<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>
|
||||
|
||||
@@ -325,7 +337,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
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_rgba(233,233,237,0.09)] md:hidden"
|
||||
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>
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { useAccount } from "../../../../../lib/useAccount";
|
||||
import { MessageReader } from "../../../../../components/MessageReader";
|
||||
import { ThreadReader } from "../../../../../components/ThreadReader";
|
||||
import { SEARCH_EVENT } from "../../../../../components/CommandPalette";
|
||||
import { Avatar } from "../../../../../components/Avatar";
|
||||
|
||||
interface MessageSummary {
|
||||
@@ -259,6 +260,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
return () => clearTimeout(handle);
|
||||
}, [searchQuery, loadMessages, conversations.ready]);
|
||||
|
||||
// The command menu can ask this list to search.
|
||||
useEffect(() => {
|
||||
const onSearch = (event: Event) => setSearchQuery(String((event as CustomEvent).detail ?? ""));
|
||||
window.addEventListener(SEARCH_EVENT, onSearch);
|
||||
return () => window.removeEventListener(SEARCH_EVENT, onSearch);
|
||||
}, []);
|
||||
|
||||
// Changing between the two lists closes whatever was open in the other.
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
@@ -542,6 +550,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
id="mail-search"
|
||||
placeholder={t("mail.searchPlaceholder")}
|
||||
aria-label={t("common.search")}
|
||||
className="min-h-[38px] w-full rounded-xl bg-dark-panel py-2 pl-[34px] pr-3 text-[13px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"
|
||||
@@ -605,7 +614,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
className={
|
||||
msg.seen
|
||||
? "bg-dark-card text-slate-400"
|
||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -689,7 +698,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
onClick={() => void (msg.thread_id ? deleteConversationRow(msg) : deleteMessage(msg.uid))}
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-ink/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
@@ -733,7 +742,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
e.preventDefault();
|
||||
}}
|
||||
className={`group relative hidden w-1.5 flex-none cursor-col-resize touch-none select-none md:block ${
|
||||
dragging ? "bg-indigo-500/40" : "bg-white/[0.07] hover:bg-indigo-500/30"
|
||||
dragging ? "bg-indigo-500/40" : "bg-ink/[0.07] hover:bg-indigo-500/30"
|
||||
} focus-visible:bg-indigo-500/50 focus-visible:outline-none`}
|
||||
>
|
||||
{/* A wider invisible target, so the thin line is easy to grab. */}
|
||||
|
||||
@@ -510,7 +510,7 @@ export default function UserSettingsPage() {
|
||||
type="button"
|
||||
onClick={() => void revokeAppPassword(ap.id)}
|
||||
aria-label={t("common.delete")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -551,7 +551,7 @@ export default function UserSettingsPage() {
|
||||
type="button"
|
||||
onClick={() => void revokeSession(session.id)}
|
||||
aria-label={t("common.delete")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -769,7 +769,7 @@ export default function UserSettingsPage() {
|
||||
disabled={index === 0}
|
||||
aria-label={t("settings.ruleMoveUp")}
|
||||
title={t("settings.ruleMoveUp")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -779,7 +779,7 @@ export default function UserSettingsPage() {
|
||||
disabled={index === rules.length - 1}
|
||||
aria-label={t("settings.ruleMoveDown")}
|
||||
title={t("settings.ruleMoveDown")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -787,7 +787,7 @@ export default function UserSettingsPage() {
|
||||
type="button"
|
||||
onClick={() => removeSieveRule(r.id)}
|
||||
aria-label={t("common.delete")}
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
|
||||
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
|
||||
>
|
||||
<Trash2 className="h-[15px] w-[15px]" />
|
||||
</button>
|
||||
@@ -914,12 +914,12 @@ export default function UserSettingsPage() {
|
||||
aria-label={b.title}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => applySignatureCommand(b.cmd)}
|
||||
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100 ${b.cls}`}
|
||||
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100 ${b.cls}`}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-white/10" />
|
||||
<span className="mx-1 h-4 w-px bg-ink/10" />
|
||||
<button
|
||||
type="button"
|
||||
title={t("ui.insertLink")}
|
||||
@@ -929,7 +929,7 @@ export default function UserSettingsPage() {
|
||||
const url = window.prompt(t("ui.insertLink"), "https://");
|
||||
if (url) applySignatureCommand("createLink", url);
|
||||
}}
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
|
||||
>
|
||||
{t("ui.insertLink")}
|
||||
</button>
|
||||
@@ -942,7 +942,7 @@ export default function UserSettingsPage() {
|
||||
const url = window.prompt(t("ui.insertImage"), "https://");
|
||||
if (url) applySignatureCommand("insertImage", url);
|
||||
}}
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
|
||||
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
|
||||
>
|
||||
{t("ui.insertImage")}
|
||||
</button>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -1,95 +1,218 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/**
|
||||
* Nocturne - the one visual language for both surfaces.
|
||||
* LambdaMail's visual language, in two themes.
|
||||
*
|
||||
* The webmail used to be indigo over glass and the console green over glass,
|
||||
* so the two halves of the same product did not look related. Everything below
|
||||
* comes from the Nocturne design system: a near-neutral blue-grey ground, Inter
|
||||
* at weight 500 for headings, solid surfaces with a 1px inset edge instead of
|
||||
* blur, and a single blurple accent used as a line, a mark and a glow rather
|
||||
* than a flood. Tailwind's slate/indigo/emerald scales are remapped onto these
|
||||
* same values in tailwind.config.js, so a utility class and a variable here
|
||||
* cannot drift apart.
|
||||
* Every colour in the interface is a variable below, and the Tailwind scales the
|
||||
* screens already use (slate, indigo, amber, rose, emerald) read those
|
||||
* variables, so a theme is a change of values and not a change of classes.
|
||||
* The ramps run the same direction in both themes: 100 is always the text you
|
||||
* read, 950 always the ground it sits on, 400 the quieter text. That is why a
|
||||
* screen written against `text-slate-400` is correct in light and in dark.
|
||||
*
|
||||
* Dark is the default. The theme is chosen before first paint by the script in
|
||||
* the root layout, so there is no flash of the other one.
|
||||
*/
|
||||
|
||||
/* GENERATED from the ramps in docs/design - edit the ramps, not these lines */
|
||||
:root, [data-theme='dark'] {
|
||||
--n-50: 245 247 251;
|
||||
--n-100: 230 233 240;
|
||||
--n-200: 211 216 227;
|
||||
--n-300: 181 188 204;
|
||||
--n-400: 142 150 168;
|
||||
--n-500: 111 119 138;
|
||||
--n-600: 82 90 108;
|
||||
--n-700: 56 62 77;
|
||||
--n-800: 35 40 52;
|
||||
--n-900: 23 27 37;
|
||||
--n-950: 15 18 24;
|
||||
--a-50: 241 243 255;
|
||||
--a-100: 228 232 255;
|
||||
--a-200: 203 210 255;
|
||||
--a-300: 169 180 255;
|
||||
--a-400: 135 148 255;
|
||||
--a-500: 101 116 255;
|
||||
--a-600: 79 94 240;
|
||||
--a-700: 61 73 196;
|
||||
--a-800: 44 53 144;
|
||||
--a-900: 29 35 96;
|
||||
--a-950: 20 24 66;
|
||||
--w-50: 255 248 230;
|
||||
--w-100: 255 239 194;
|
||||
--w-200: 255 224 138;
|
||||
--w-300: 255 206 82;
|
||||
--w-400: 245 184 46;
|
||||
--w-500: 227 157 15;
|
||||
--w-600: 185 122 8;
|
||||
--w-700: 138 90 10;
|
||||
--w-800: 92 61 11;
|
||||
--w-900: 58 40 16;
|
||||
--w-950: 36 25 9;
|
||||
--b-50: 255 241 242;
|
||||
--b-100: 255 224 227;
|
||||
--b-200: 255 194 201;
|
||||
--b-300: 255 154 166;
|
||||
--b-400: 248 112 127;
|
||||
--b-500: 229 72 90;
|
||||
--b-600: 192 47 66;
|
||||
--b-700: 150 38 55;
|
||||
--b-800: 101 27 40;
|
||||
--b-900: 65 22 32;
|
||||
--b-950: 42 15 22;
|
||||
--g-50: 236 253 245;
|
||||
--g-100: 209 250 229;
|
||||
--g-200: 167 243 208;
|
||||
--g-300: 110 231 183;
|
||||
--g-400: 52 211 153;
|
||||
--g-500: 16 185 129;
|
||||
--g-600: 5 150 105;
|
||||
--g-700: 4 120 87;
|
||||
--g-800: 6 95 70;
|
||||
--g-900: 6 78 59;
|
||||
--g-950: 2 44 34;
|
||||
--a-fill: 79 94 240;
|
||||
--a-fill-hover: 99 113 250;
|
||||
--ink: 233 237 245;
|
||||
--rail: 11 13 18;
|
||||
--lm-shadow: 0 18px 48px rgb(0 0 0 / 0.55);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
[data-theme='light'] {
|
||||
--n-50: 11 14 20;
|
||||
--n-100: 18 21 28;
|
||||
--n-200: 35 40 52;
|
||||
--n-300: 58 65 82;
|
||||
--n-400: 91 98 114;
|
||||
--n-500: 123 130 148;
|
||||
--n-600: 163 169 184;
|
||||
--n-700: 205 209 219;
|
||||
--n-800: 233 236 241;
|
||||
--n-900: 255 255 255;
|
||||
--n-950: 243 244 247;
|
||||
--a-50: 20 24 66;
|
||||
--a-100: 29 35 96;
|
||||
--a-200: 44 53 144;
|
||||
--a-300: 51 65 194;
|
||||
--a-400: 63 79 230;
|
||||
--a-500: 63 79 230;
|
||||
--a-600: 101 116 255;
|
||||
--a-700: 135 148 255;
|
||||
--a-800: 183 192 255;
|
||||
--a-900: 225 229 255;
|
||||
--a-950: 238 240 255;
|
||||
--w-50: 36 25 9;
|
||||
--w-100: 58 40 16;
|
||||
--w-200: 92 61 11;
|
||||
--w-300: 122 78 6;
|
||||
--w-400: 138 90 10;
|
||||
--w-500: 185 122 8;
|
||||
--w-600: 227 157 15;
|
||||
--w-700: 245 184 46;
|
||||
--w-800: 255 224 138;
|
||||
--w-900: 255 241 201;
|
||||
--w-950: 255 248 230;
|
||||
--b-50: 42 15 22;
|
||||
--b-100: 65 22 32;
|
||||
--b-200: 101 27 40;
|
||||
--b-300: 142 33 51;
|
||||
--b-400: 178 42 62;
|
||||
--b-500: 214 58 78;
|
||||
--b-600: 229 72 90;
|
||||
--b-700: 248 112 127;
|
||||
--b-800: 255 179 187;
|
||||
--b-900: 255 227 230;
|
||||
--b-950: 255 241 242;
|
||||
--g-50: 2 44 34;
|
||||
--g-100: 6 78 59;
|
||||
--g-200: 6 95 70;
|
||||
--g-300: 5 107 75;
|
||||
--g-400: 4 120 87;
|
||||
--g-500: 5 150 105;
|
||||
--g-600: 16 185 129;
|
||||
--g-700: 52 211 153;
|
||||
--g-800: 167 243 208;
|
||||
--g-900: 216 248 233;
|
||||
--g-950: 236 253 245;
|
||||
--a-fill: 63 79 230;
|
||||
--a-fill-hover: 52 66 214;
|
||||
--ink: 17 20 28;
|
||||
--rail: 236 238 243;
|
||||
--lm-shadow: 0 18px 48px rgb(20 28 50 / 0.16);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Grounds, darkest to lightest: the rail, the page, a panel, a card. */
|
||||
--lm-rail: #12131f;
|
||||
--lm-bg: #161826;
|
||||
--lm-panel: #1b1c28;
|
||||
--lm-surface: #232532;
|
||||
--lm-radius-md: 10px;
|
||||
}
|
||||
|
||||
--lm-text: #e9e9ed;
|
||||
--lm-text-muted: rgba(233, 233, 237, 0.62);
|
||||
|
||||
/* One accent, in the product's own blurple. */
|
||||
--lm-accent: #9184d9;
|
||||
--lm-accent-pale: #d2cefd;
|
||||
|
||||
/* Edges are drawn as an inset ring, not a border, so nothing shifts by 1px
|
||||
when a surface becomes active. */
|
||||
--lm-edge: rgba(233, 233, 237, 0.09);
|
||||
--lm-edge-strong: rgba(233, 233, 237, 0.16);
|
||||
--lm-divider: rgba(233, 233, 237, 0.14);
|
||||
|
||||
--lm-radius-md: 11px;
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--lm-bg);
|
||||
color: var(--lm-text);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: rgb(var(--n-950));
|
||||
color: rgb(var(--n-100));
|
||||
font-family: 'Geist Variable', 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
font-feature-settings: 'cv11', 'ss01';
|
||||
overflow-x: hidden;
|
||||
/* A theme change is a crossfade, not a flash. Skipped for people who ask for
|
||||
less motion, below. */
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Hierarchy here is size and space; headings never go past weight 500. */
|
||||
/* Hierarchy is size and weight; headings are tight and never shout. */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Keyboard focus is themed everywhere rather than left to the browser. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--lm-accent);
|
||||
outline: 2px solid rgb(var(--a-400));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
::selection {
|
||||
background: rgba(145, 132, 217, 0.3);
|
||||
background: rgb(var(--a-500) / 0.28);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
Surfaces
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/** A code or DNS-record block: the darkest ground, monospace, accent ink. */
|
||||
/** A code or DNS-record block. */
|
||||
.lm-code {
|
||||
background: var(--lm-rail);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
||||
background: rgb(var(--rail));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
|
||||
border-radius: var(--lm-radius-md);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--lm-accent-pale);
|
||||
color: rgb(var(--a-300));
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule that fades to transparent at both ends rather than stopping cleanly -
|
||||
* the one Nocturne signature that has to be CSS, because Tailwind has no
|
||||
* gradient border.
|
||||
*/
|
||||
/** A rule that fades out at both ends instead of stopping dead. */
|
||||
.lm-rule {
|
||||
height: 1px;
|
||||
border: 0;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--lm-divider) 24px,
|
||||
var(--lm-divider) calc(100% - 24px),
|
||||
rgb(var(--ink) / 0.14) 24px,
|
||||
rgb(var(--ink) / 0.14) calc(100% - 24px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
@@ -99,58 +222,51 @@ h1, h2, h3, h4, h5, h6 {
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* A sidebar item. The 3px mark on the left is the accent's whole appearance
|
||||
* here - an active item is not a filled pill. Nothing in this block is sized
|
||||
* by its text: the label wraps to a second line instead of being clipped,
|
||||
* which is what broke when the interface was read in Portuguese or Spanish.
|
||||
* A sidebar item. Active is a tint of the accent behind the label, the way
|
||||
* system sidebars do it; the text does not shift and nothing is sized by it, so
|
||||
* a long label wraps instead of being clipped.
|
||||
*/
|
||||
.lm-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 9px 8px 4px;
|
||||
padding: 7px 10px;
|
||||
border-radius: var(--lm-radius-md);
|
||||
color: var(--lm-text-muted);
|
||||
color: rgb(var(--n-300));
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.lm-nav:hover {
|
||||
background: rgba(233, 233, 237, 0.05);
|
||||
color: var(--lm-text);
|
||||
background: rgb(var(--ink) / 0.06);
|
||||
color: rgb(var(--n-100));
|
||||
}
|
||||
.lm-nav[data-active='true'] {
|
||||
background: var(--lm-surface);
|
||||
color: var(--lm-text);
|
||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.35);
|
||||
background: rgb(var(--a-500) / 0.15);
|
||||
color: rgb(var(--a-300));
|
||||
font-weight: 500;
|
||||
}
|
||||
/* The old left-hand mark is gone; the tint carries "active" now. */
|
||||
.lm-nav-mark {
|
||||
width: 3px;
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
}
|
||||
.lm-nav[data-active='true'] .lm-nav-mark {
|
||||
background: var(--lm-accent);
|
||||
display: none;
|
||||
}
|
||||
|
||||
/** A message row in the list pane. */
|
||||
.lm-row {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.12s ease;
|
||||
}
|
||||
.lm-row:hover {
|
||||
background: rgba(233, 233, 237, 0.04);
|
||||
background: rgb(var(--ink) / 0.045);
|
||||
}
|
||||
.lm-row[data-active='true'] {
|
||||
background: var(--lm-surface);
|
||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
||||
background: rgb(var(--a-500) / 0.13);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--a-500) / 0.32);
|
||||
}
|
||||
|
||||
/** A filter chip: an outline that takes the accent when it is the one chosen. */
|
||||
/** A filter chip: quiet until it is the one chosen. */
|
||||
.lm-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -159,16 +275,18 @@ h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: 9999px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--lm-text-muted);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-edge-strong);
|
||||
transition: color 0.15s ease, box-shadow 0.15s ease;
|
||||
color: rgb(var(--n-300));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.16);
|
||||
transition: color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
.lm-chip:hover {
|
||||
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
||||
background: rgb(var(--ink) / 0.05);
|
||||
color: rgb(var(--n-100));
|
||||
}
|
||||
.lm-chip[data-active='true'] {
|
||||
color: var(--lm-accent);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
||||
color: rgb(var(--a-300));
|
||||
background: rgb(var(--a-500) / 0.14);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--a-500) / 0.4);
|
||||
}
|
||||
|
||||
/** A tab inside the segmented strip the settings, domain and mailbox screens use. */
|
||||
@@ -180,17 +298,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: 9px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.3;
|
||||
color: var(--lm-text-muted);
|
||||
color: rgb(var(--n-300));
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.lm-tab:hover {
|
||||
color: var(--lm-text);
|
||||
background: rgba(233, 233, 237, 0.05);
|
||||
color: rgb(var(--n-100));
|
||||
background: rgb(var(--ink) / 0.06);
|
||||
}
|
||||
.lm-tab[data-active='true'] {
|
||||
background: var(--lm-surface);
|
||||
color: var(--lm-accent);
|
||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
||||
background: rgb(var(--n-800));
|
||||
color: rgb(var(--n-50));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.12), 0 1px 2px rgb(0 0 0 / 0.12);
|
||||
}
|
||||
.lm-tabstrip {
|
||||
display: flex;
|
||||
@@ -198,12 +316,12 @@ h1, h2, h3, h4, h5, h6 {
|
||||
gap: 6px;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--lm-panel);
|
||||
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
||||
background: rgb(var(--n-900));
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------
|
||||
Tables - the header and the row rules fade at their ends, as the rules do
|
||||
Tables
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
.lm-table {
|
||||
@@ -213,11 +331,9 @@ h1, h2, h3, h4, h5, h6 {
|
||||
}
|
||||
.lm-table th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(233, 233, 237, 0.6);
|
||||
color: rgb(var(--n-400));
|
||||
padding: 8px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -225,34 +341,25 @@ h1, h2, h3, h4, h5, h6 {
|
||||
padding: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.lm-table thead tr {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--lm-divider) 24px,
|
||||
var(--lm-divider) calc(100% - 24px),
|
||||
transparent
|
||||
)
|
||||
no-repeat bottom / 100% 1px;
|
||||
}
|
||||
.lm-table thead tr,
|
||||
.lm-table tbody tr {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(233, 233, 237, 0.08) 24px,
|
||||
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
||||
rgb(var(--ink) / 0.1) 24px,
|
||||
rgb(var(--ink) / 0.1) calc(100% - 24px),
|
||||
transparent
|
||||
)
|
||||
no-repeat bottom / 100% 1px;
|
||||
}
|
||||
.lm-table tbody tr:hover {
|
||||
background:
|
||||
linear-gradient(rgba(233, 233, 237, 0.04), rgba(233, 233, 237, 0.04)) no-repeat 0 0 / 100% 100%,
|
||||
linear-gradient(rgb(var(--ink) / 0.045), rgb(var(--ink) / 0.045)) no-repeat 0 0 / 100% 100%,
|
||||
linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
rgba(233, 233, 237, 0.08) 24px,
|
||||
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
||||
rgb(var(--ink) / 0.1) 24px,
|
||||
rgb(var(--ink) / 0.1) calc(100% - 24px),
|
||||
transparent
|
||||
)
|
||||
no-repeat bottom / 100% 1px;
|
||||
@@ -266,21 +373,17 @@ h1, h2, h3, h4, h5, h6 {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f424d;
|
||||
background: rgb(var(--n-700));
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #595d6c;
|
||||
background: rgb(var(--n-600));
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f424d transparent;
|
||||
scrollbar-color: rgb(var(--n-700)) transparent;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<defs>
|
||||
<linearGradient id="lm-tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#6F7EFF"/>
|
||||
<stop offset="1" stop-color="#3441D8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="15" fill="url(#lm-tile)"/>
|
||||
<!-- the flap of an envelope, as light on the tile: the fold every letter has -->
|
||||
<path d="M0 15C0 6.7 6.7 0 15 0h34c8.3 0 15 6.7 15 15v2L32 40 0 17z" fill="#fff" fill-opacity=".11"/>
|
||||
<!-- the lambda -->
|
||||
<path d="M21 14.5 43 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M32.1 32.4 21 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 798 B |
@@ -1,12 +1,30 @@
|
||||
import "@fontsource-variable/geist";
|
||||
import "./globals.css";
|
||||
import React from "react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { I18nProvider } from "../i18n/provider";
|
||||
import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config";
|
||||
import { THEME_BOOT_SCRIPT } from "../lib/theme";
|
||||
|
||||
export const metadata = {
|
||||
title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console",
|
||||
description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.",
|
||||
export const metadata: Metadata = {
|
||||
title: { default: "LambdaMail", template: "%s - LambdaMail" },
|
||||
description: "Your own mail server, with a webmail that is a pleasure to use.",
|
||||
applicationName: "LambdaMail",
|
||||
// The tab icon, the home-screen icon and the install manifest all come from
|
||||
// the files next to this one (icon.svg, favicon.ico, apple-icon.png) and
|
||||
// from manifest.ts.
|
||||
appleWebApp: { capable: true, title: "LambdaMail", statusBarStyle: "black-translucent" },
|
||||
};
|
||||
|
||||
// The browser's own chrome follows the theme: the address bar on a phone is
|
||||
// the page's ground in dark, and the paper in light.
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#F3F4F7" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0F1218" },
|
||||
],
|
||||
colorScheme: "dark light",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,7 +45,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
const locale = await resolveLocale();
|
||||
|
||||
return (
|
||||
<html lang={locale} className="dark">
|
||||
// data-theme is set by the script below before the page paints, and React
|
||||
// must not "correct" it back when it hydrates.
|
||||
<html lang={locale} data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
|
||||
</head>
|
||||
<body className="min-h-screen bg-dark-bg font-sans text-slate-100 antialiased">
|
||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
/**
|
||||
* What lets the webmail be installed like an app: its own window, its own
|
||||
* icon, and a splash that matches the ground it opens onto.
|
||||
*/
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: "LambdaMail",
|
||||
short_name: "LambdaMail",
|
||||
description: "Your own mail server, with a webmail that is a pleasure to use.",
|
||||
start_url: "/user/mail/inbox",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
background_color: "#0F1218",
|
||||
theme_color: "#0F1218",
|
||||
icons: [
|
||||
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
|
||||
],
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user