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
@@ -564,5 +564,27 @@
|
|||||||
"draftDiscarded": "Reply discarded",
|
"draftDiscarded": "Reply discarded",
|
||||||
"sentReply": "Reply sent",
|
"sentReply": "Reply sent",
|
||||||
"moreMessages": "Show {count} earlier messages"
|
"moreMessages": "Show {count} earlier messages"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"dark": "Dark",
|
||||||
|
"light": "Light",
|
||||||
|
"system": "System"
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"placeholder": "Type a command, a folder or something to search for",
|
||||||
|
"empty": "Nothing matches. Press Enter to search your mail for this.",
|
||||||
|
"searchFor": "Search mail for \"{query}\"",
|
||||||
|
"goTo": "Go to {name}",
|
||||||
|
"actions": "Actions",
|
||||||
|
"navigate": "Go to",
|
||||||
|
"theme": "Appearance",
|
||||||
|
"accounts": "Accounts",
|
||||||
|
"switchTo": "Switch to {email}",
|
||||||
|
"hintMove": "move",
|
||||||
|
"hintOpen": "open",
|
||||||
|
"hintClose": "close",
|
||||||
|
"open": "Command menu",
|
||||||
|
"signOut": "Sign out"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,5 +564,27 @@
|
|||||||
"draftDiscarded": "Respuesta descartada",
|
"draftDiscarded": "Respuesta descartada",
|
||||||
"sentReply": "Respuesta enviada",
|
"sentReply": "Respuesta enviada",
|
||||||
"moreMessages": "Mostrar {count} mensajes anteriores"
|
"moreMessages": "Mostrar {count} mensajes anteriores"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Apariencia",
|
||||||
|
"dark": "Oscuro",
|
||||||
|
"light": "Claro",
|
||||||
|
"system": "Sistema"
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"placeholder": "Escribe un comando, una carpeta o algo que buscar",
|
||||||
|
"empty": "Sin resultados. Pulsa Enter para buscar esto en tu correo.",
|
||||||
|
"searchFor": "Buscar \"{query}\" en el correo",
|
||||||
|
"goTo": "Ir a {name}",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"navigate": "Ir a",
|
||||||
|
"theme": "Apariencia",
|
||||||
|
"accounts": "Cuentas",
|
||||||
|
"switchTo": "Cambiar a {email}",
|
||||||
|
"hintMove": "mover",
|
||||||
|
"hintOpen": "abrir",
|
||||||
|
"hintClose": "cerrar",
|
||||||
|
"open": "Menú de comandos",
|
||||||
|
"signOut": "Cerrar sesión"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -564,5 +564,27 @@
|
|||||||
"draftDiscarded": "Resposta descartada",
|
"draftDiscarded": "Resposta descartada",
|
||||||
"sentReply": "Resposta enviada",
|
"sentReply": "Resposta enviada",
|
||||||
"moreMessages": "Mostrar {count} mensagens anteriores"
|
"moreMessages": "Mostrar {count} mensagens anteriores"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Aparência",
|
||||||
|
"dark": "Escuro",
|
||||||
|
"light": "Claro",
|
||||||
|
"system": "Sistema"
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"placeholder": "Digite um comando, uma pasta ou algo para buscar",
|
||||||
|
"empty": "Nada encontrado. Pressione Enter para buscar este texto nos seus e-mails.",
|
||||||
|
"searchFor": "Buscar \"{query}\" nos e-mails",
|
||||||
|
"goTo": "Ir para {name}",
|
||||||
|
"actions": "Ações",
|
||||||
|
"navigate": "Ir para",
|
||||||
|
"theme": "Aparência",
|
||||||
|
"accounts": "Contas",
|
||||||
|
"switchTo": "Trocar para {email}",
|
||||||
|
"hintMove": "mover",
|
||||||
|
"hintOpen": "abrir",
|
||||||
|
"hintClose": "fechar",
|
||||||
|
"open": "Menu de comandos",
|
||||||
|
"signOut": "Sair"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "@lambdamail/webmail",
|
"name": "@lambdamail/webmail",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
@@ -718,6 +719,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/geist": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/colour": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"lint": "tsc --noEmit"
|
"lint": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.3.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dompurify": "^3.4.14",
|
"dompurify": "^3.4.14",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -22,6 +22,8 @@ import { useTranslations } from "../../../i18n/provider";
|
|||||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||||
import { useAccount } from "../../../lib/useAccount";
|
import { useAccount } from "../../../lib/useAccount";
|
||||||
import { initialsFor } from "../../../lib/initials";
|
import { initialsFor } from "../../../lib/initials";
|
||||||
|
import { Logo } from "../../../components/Logo";
|
||||||
|
import { ThemeToggle } from "../../../components/ThemeToggle";
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -52,12 +54,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
const SidebarContent = (
|
const SidebarContent = (
|
||||||
<div className="flex h-full flex-col gap-4 p-3">
|
<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 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]">
|
<Logo size={32} className="flex-none" />
|
||||||
{"\u03bb"}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
<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")}
|
{t("common.adminPortal")}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
|
||||||
<Link
|
<Link
|
||||||
href="/user/mail/inbox"
|
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" />
|
<Mail className="h-3.5 w-3.5 flex-none" />
|
||||||
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
|
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
|
||||||
@@ -103,7 +103,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge">
|
<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)}
|
{initialsFor(account?.email)}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<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">
|
<div className="flex items-center gap-1.5">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title={t("settings.signOut")}
|
title={t("settings.signOut")}
|
||||||
aria-label={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]" />
|
<LogOut className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -139,11 +140,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||||
{/* Mobile Top Header */}
|
{/* 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 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]">
|
<Logo size={32} className="flex-none" />
|
||||||
{"\u03bb"}
|
|
||||||
</div>
|
|
||||||
<span className="min-w-0 truncate font-medium text-slate-100">
|
<span className="min-w-0 truncate font-medium text-slate-100">
|
||||||
{t("common.appName")} {t("common.adminPortal")}
|
{t("common.appName")} {t("common.adminPortal")}
|
||||||
</span>
|
</span>
|
||||||
@@ -152,14 +151,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label={t("ui.toggleMenu")}
|
aria-label={t("ui.toggleMenu")}
|
||||||
aria-expanded={mobileOpen}
|
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" />}
|
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop rail */}
|
{/* 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}
|
{SidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -171,7 +170,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
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}
|
{SidebarContent}
|
||||||
</motion.aside>
|
</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";
|
"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 fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||||
const iconButton =
|
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 (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
|
<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)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t("ui.searchPlaceholder")}
|
placeholder={t("ui.searchPlaceholder")}
|
||||||
aria-label={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>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={roleFilter}
|
value={roleFilter}
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
aria-label={t("ui.role")}
|
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="">{t("ui.all")}</option>
|
||||||
<option value="USER">{roleLabel(t, "USER")}</option>
|
<option value="USER">{roleLabel(t, "USER")}</option>
|
||||||
@@ -668,7 +668,7 @@ export default function AdminMailboxesPage() {
|
|||||||
value={activeFilter}
|
value={activeFilter}
|
||||||
onChange={(e) => setActiveFilter(e.target.value)}
|
onChange={(e) => setActiveFilter(e.target.value)}
|
||||||
aria-label={t("ui.accountStatus")}
|
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="">{t("ui.all")}</option>
|
||||||
<option value="true">{t("ui.active")}</option>
|
<option value="true">{t("ui.active")}</option>
|
||||||
|
|||||||
@@ -167,14 +167,14 @@ export default function AdminQueuePage() {
|
|||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t("ui.searchPlaceholder")}
|
placeholder={t("ui.searchPlaceholder")}
|
||||||
aria-label={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>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value)}
|
onChange={(e) => setStatus(e.target.value)}
|
||||||
aria-label={t("common.status")}
|
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>
|
<option value="">{t("ui.all")}</option>
|
||||||
{/* Taken from the aggregate, so the choices are the states this
|
{/* 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)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder={t("ui.searchPlaceholder")}
|
placeholder={t("ui.searchPlaceholder")}
|
||||||
aria-label={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>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={(e) => setSelected(e.target.value)}
|
onChange={(e) => setSelected(e.target.value)}
|
||||||
aria-label={t("admin.userChecks")}
|
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>
|
<option value="">{t("ui.selectUser")}</option>
|
||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Link from "next/link";
|
|||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
import { Logo } from "../../../../components/Logo";
|
||||||
|
import { ThemeToggle } from "../../../../components/ThemeToggle";
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
export default function AdminLoginPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -101,15 +103,14 @@ export default function AdminLoginPage() {
|
|||||||
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
|
||||||
|
|
||||||
return (
|
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">
|
<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
|
{/* 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
|
green over green, which made the second surface look like a
|
||||||
different product. */}
|
different product. */}
|
||||||
<div className="mb-6 flex items-center gap-3">
|
<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]">
|
<Logo size={40} className="flex-none" />
|
||||||
{"\u03bb"}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.adminLoginTitle")}</h1>
|
<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>
|
<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="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="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="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" />
|
<Sliders className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import React, { useState } from "react";
|
|||||||
import { Mail, Lock, AlertCircle } from "lucide-react";
|
import { Mail, Lock, AlertCircle } from "lucide-react";
|
||||||
import { useTranslations } from "../../../../i18n/provider";
|
import { useTranslations } from "../../../../i18n/provider";
|
||||||
import { Button } from "../../../../components/ui/Button";
|
import { Button } from "../../../../components/ui/Button";
|
||||||
|
import { Logo } from "../../../../components/Logo";
|
||||||
|
import { ThemeToggle } from "../../../../components/ThemeToggle";
|
||||||
|
|
||||||
export default function UserLoginPage() {
|
export default function UserLoginPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -55,12 +57,11 @@ export default function UserLoginPage() {
|
|||||||
/* No ambient glow behind the card any more: two blurred coloured circles
|
/* 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
|
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. */
|
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="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="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]">
|
<Logo size={40} className="flex-none" />
|
||||||
{"\u03bb"}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.userLoginTitle")}</h1>
|
<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">
|
<p className="mt-0.5 text-[12.5px] text-slate-400">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
Menu,
|
Menu,
|
||||||
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "../../../i18n/provider";
|
import { useTranslations } from "../../../i18n/provider";
|
||||||
@@ -28,6 +29,8 @@ import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
|||||||
import { useFolders } from "../../../lib/useFolders";
|
import { useFolders } from "../../../lib/useFolders";
|
||||||
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
import { folderBadge, customFolders } from "../../../lib/mailCounts";
|
||||||
import { AccountSwitcher } from "../../../components/AccountSwitcher";
|
import { AccountSwitcher } from "../../../components/AccountSwitcher";
|
||||||
|
import { Logo } from "../../../components/Logo";
|
||||||
|
import { CommandPalette } from "../../../components/CommandPalette";
|
||||||
|
|
||||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||||
const t = useTranslations();
|
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 brand mark. One mark for both surfaces - the line underneath is
|
||||||
the only thing that says which one you are in. */}
|
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 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]">
|
<Logo size={32} className="flex-none" />
|
||||||
{"\u03bb"}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
|
<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")}
|
{t("common.userPortal")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,12 +102,24 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
<Link
|
<Link
|
||||||
href="/user/compose"
|
href="/user/compose"
|
||||||
onClick={() => setMobileOpen(false)}
|
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" />
|
<PenSquare className="h-4 w-4 flex-none" />
|
||||||
<span className="min-w-0">{t("mail.compose")}</span>
|
<span className="min-w-0">{t("mail.compose")}</span>
|
||||||
</Link>
|
</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,
|
{/* 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
|
and its numbers - nothing is sized by the length of the label, which
|
||||||
is what clipped the Portuguese and Spanish names before. */}
|
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;
|
if (!window.confirm(t("folders.deleteConfirm", { name: folder.name }))) return;
|
||||||
void manageFolder({ action: "delete", name: folder.name });
|
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" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -223,7 +236,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
type="submit"
|
type="submit"
|
||||||
aria-label={t("folders.new")}
|
aria-label={t("folders.new")}
|
||||||
title={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" />
|
<Plus className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -266,7 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
href="/admin/step-up"
|
href="/admin/step-up"
|
||||||
title={t("admin.openAdmin")}
|
title={t("admin.openAdmin")}
|
||||||
aria-label={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" />
|
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
|
||||||
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
|
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
|
||||||
@@ -283,7 +296,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
title={t("settings.signOut")}
|
title={t("settings.signOut")}
|
||||||
aria-label={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]" />
|
<LogOut className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -294,26 +307,25 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
|
||||||
|
<CommandPalette isAdmin={isAdminRole(account?.role)} />
|
||||||
{/* Mobile Top Header */}
|
{/* 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 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]">
|
<Logo size={32} className="flex-none" />
|
||||||
{"\u03bb"}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-slate-100">LambdaMail</span>
|
<span className="font-medium text-slate-100">LambdaMail</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(!mobileOpen)}
|
onClick={() => setMobileOpen(!mobileOpen)}
|
||||||
aria-label={t("ui.toggleMenu")}
|
aria-label={t("ui.toggleMenu")}
|
||||||
aria-expanded={mobileOpen}
|
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" />}
|
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop rail */}
|
{/* 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}
|
{SidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -325,7 +337,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
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}
|
{SidebarContent}
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
import { useAccount } from "../../../../../lib/useAccount";
|
import { useAccount } from "../../../../../lib/useAccount";
|
||||||
import { MessageReader } from "../../../../../components/MessageReader";
|
import { MessageReader } from "../../../../../components/MessageReader";
|
||||||
import { ThreadReader } from "../../../../../components/ThreadReader";
|
import { ThreadReader } from "../../../../../components/ThreadReader";
|
||||||
|
import { SEARCH_EVENT } from "../../../../../components/CommandPalette";
|
||||||
import { Avatar } from "../../../../../components/Avatar";
|
import { Avatar } from "../../../../../components/Avatar";
|
||||||
|
|
||||||
interface MessageSummary {
|
interface MessageSummary {
|
||||||
@@ -259,6 +260,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
return () => clearTimeout(handle);
|
return () => clearTimeout(handle);
|
||||||
}, [searchQuery, loadMessages, conversations.ready]);
|
}, [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.
|
// Changing between the two lists closes whatever was open in the other.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@@ -542,6 +550,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
type="search"
|
type="search"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
id="mail-search"
|
||||||
placeholder={t("mail.searchPlaceholder")}
|
placeholder={t("mail.searchPlaceholder")}
|
||||||
aria-label={t("common.search")}
|
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"
|
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={
|
className={
|
||||||
msg.seen
|
msg.seen
|
||||||
? "bg-dark-card text-slate-400"
|
? "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))}
|
onClick={() => void (msg.thread_id ? deleteConversationRow(msg) : deleteMessage(msg.uid))}
|
||||||
title={t("common.delete")}
|
title={t("common.delete")}
|
||||||
aria-label={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" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -733,7 +742,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
className={`group relative hidden w-1.5 flex-none cursor-col-resize touch-none select-none md:block ${
|
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`}
|
} focus-visible:bg-indigo-500/50 focus-visible:outline-none`}
|
||||||
>
|
>
|
||||||
{/* A wider invisible target, so the thin line is easy to grab. */}
|
{/* A wider invisible target, so the thin line is easy to grab. */}
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ export default function UserSettingsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void revokeAppPassword(ap.id)}
|
onClick={() => void revokeAppPassword(ap.id)}
|
||||||
aria-label={t("common.delete")}
|
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]" />
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -551,7 +551,7 @@ export default function UserSettingsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => void revokeSession(session.id)}
|
onClick={() => void revokeSession(session.id)}
|
||||||
aria-label={t("common.delete")}
|
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]" />
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -769,7 +769,7 @@ export default function UserSettingsPage() {
|
|||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
aria-label={t("settings.ruleMoveUp")}
|
aria-label={t("settings.ruleMoveUp")}
|
||||||
title={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]" />
|
<ChevronUp className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -779,7 +779,7 @@ export default function UserSettingsPage() {
|
|||||||
disabled={index === rules.length - 1}
|
disabled={index === rules.length - 1}
|
||||||
aria-label={t("settings.ruleMoveDown")}
|
aria-label={t("settings.ruleMoveDown")}
|
||||||
title={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]" />
|
<ChevronDown className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -787,7 +787,7 @@ export default function UserSettingsPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeSieveRule(r.id)}
|
onClick={() => removeSieveRule(r.id)}
|
||||||
aria-label={t("common.delete")}
|
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]" />
|
<Trash2 className="h-[15px] w-[15px]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -914,12 +914,12 @@ export default function UserSettingsPage() {
|
|||||||
aria-label={b.title}
|
aria-label={b.title}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => applySignatureCommand(b.cmd)}
|
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}
|
{b.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<span className="mx-1 h-4 w-px bg-white/10" />
|
<span className="mx-1 h-4 w-px bg-ink/10" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t("ui.insertLink")}
|
title={t("ui.insertLink")}
|
||||||
@@ -929,7 +929,7 @@ export default function UserSettingsPage() {
|
|||||||
const url = window.prompt(t("ui.insertLink"), "https://");
|
const url = window.prompt(t("ui.insertLink"), "https://");
|
||||||
if (url) applySignatureCommand("createLink", url);
|
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")}
|
{t("ui.insertLink")}
|
||||||
</button>
|
</button>
|
||||||
@@ -942,7 +942,7 @@ export default function UserSettingsPage() {
|
|||||||
const url = window.prompt(t("ui.insertImage"), "https://");
|
const url = window.prompt(t("ui.insertImage"), "https://");
|
||||||
if (url) applySignatureCommand("insertImage", url);
|
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")}
|
{t("ui.insertImage")}
|
||||||
</button>
|
</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 base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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,
|
* Every colour in the interface is a variable below, and the Tailwind scales the
|
||||||
* so the two halves of the same product did not look related. Everything below
|
* screens already use (slate, indigo, amber, rose, emerald) read those
|
||||||
* comes from the Nocturne design system: a near-neutral blue-grey ground, Inter
|
* variables, so a theme is a change of values and not a change of classes.
|
||||||
* at weight 500 for headings, solid surfaces with a 1px inset edge instead of
|
* The ramps run the same direction in both themes: 100 is always the text you
|
||||||
* blur, and a single blurple accent used as a line, a mark and a glow rather
|
* read, 950 always the ground it sits on, 400 the quieter text. That is why a
|
||||||
* than a flood. Tailwind's slate/indigo/emerald scales are remapped onto these
|
* screen written against `text-slate-400` is correct in light and in dark.
|
||||||
* same values in tailwind.config.js, so a utility class and a variable here
|
*
|
||||||
* cannot drift apart.
|
* 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 {
|
:root {
|
||||||
/* Grounds, darkest to lightest: the rail, the page, a panel, a card. */
|
--lm-radius-md: 10px;
|
||||||
--lm-rail: #12131f;
|
}
|
||||||
--lm-bg: #161826;
|
|
||||||
--lm-panel: #1b1c28;
|
|
||||||
--lm-surface: #232532;
|
|
||||||
|
|
||||||
--lm-text: #e9e9ed;
|
html {
|
||||||
--lm-text-muted: rgba(233, 233, 237, 0.62);
|
-webkit-text-size-adjust: 100%;
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--lm-bg);
|
background-color: rgb(var(--n-950));
|
||||||
color: var(--lm-text);
|
color: rgb(var(--n-100));
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Geist Variable', 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-feature-settings: 'cv11', 'ss01';
|
||||||
overflow-x: hidden;
|
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 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keyboard focus is themed everywhere rather than left to the browser. */
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--lm-accent);
|
outline: 2px solid rgb(var(--a-400));
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
::selection {
|
::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
|
Surfaces
|
||||||
-------------------------------------------------------------------- */
|
-------------------------------------------------------------------- */
|
||||||
|
|
||||||
/** A code or DNS-record block: the darkest ground, monospace, accent ink. */
|
/** A code or DNS-record block. */
|
||||||
.lm-code {
|
.lm-code {
|
||||||
background: var(--lm-rail);
|
background: rgb(var(--rail));
|
||||||
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
|
||||||
border-radius: var(--lm-radius-md);
|
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;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--lm-accent-pale);
|
color: rgb(var(--a-300));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A rule that fades out at both ends instead of stopping dead. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
.lm-rule {
|
.lm-rule {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent,
|
transparent,
|
||||||
var(--lm-divider) 24px,
|
rgb(var(--ink) / 0.14) 24px,
|
||||||
var(--lm-divider) calc(100% - 24px),
|
rgb(var(--ink) / 0.14) calc(100% - 24px),
|
||||||
transparent
|
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
|
* A sidebar item. Active is a tint of the accent behind the label, the way
|
||||||
* here - an active item is not a filled pill. Nothing in this block is sized
|
* system sidebars do it; the text does not shift and nothing is sized by it, so
|
||||||
* by its text: the label wraps to a second line instead of being clipped,
|
* a long label wraps instead of being clipped.
|
||||||
* which is what broke when the interface was read in Portuguese or Spanish.
|
|
||||||
*/
|
*/
|
||||||
.lm-nav {
|
.lm-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 9px 8px 4px;
|
padding: 7px 10px;
|
||||||
border-radius: var(--lm-radius-md);
|
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;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
.lm-nav:hover {
|
.lm-nav:hover {
|
||||||
background: rgba(233, 233, 237, 0.05);
|
background: rgb(var(--ink) / 0.06);
|
||||||
color: var(--lm-text);
|
color: rgb(var(--n-100));
|
||||||
}
|
}
|
||||||
.lm-nav[data-active='true'] {
|
.lm-nav[data-active='true'] {
|
||||||
background: var(--lm-surface);
|
background: rgb(var(--a-500) / 0.15);
|
||||||
color: var(--lm-text);
|
color: rgb(var(--a-300));
|
||||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.35);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
/* The old left-hand mark is gone; the tint carries "active" now. */
|
||||||
.lm-nav-mark {
|
.lm-nav-mark {
|
||||||
width: 3px;
|
display: none;
|
||||||
flex: none;
|
|
||||||
align-self: stretch;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.lm-nav[data-active='true'] .lm-nav-mark {
|
|
||||||
background: var(--lm-accent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A message row in the list pane. */
|
/** A message row in the list pane. */
|
||||||
.lm-row {
|
.lm-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 11px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
padding: 12px;
|
padding: 11px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: background-color 0.12s ease;
|
transition: background-color 0.12s ease;
|
||||||
}
|
}
|
||||||
.lm-row:hover {
|
.lm-row:hover {
|
||||||
background: rgba(233, 233, 237, 0.04);
|
background: rgb(var(--ink) / 0.045);
|
||||||
}
|
}
|
||||||
.lm-row[data-active='true'] {
|
.lm-row[data-active='true'] {
|
||||||
background: var(--lm-surface);
|
background: rgb(var(--a-500) / 0.13);
|
||||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
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 {
|
.lm-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -159,16 +275,18 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: var(--lm-text-muted);
|
color: rgb(var(--n-300));
|
||||||
box-shadow: inset 0 0 0 1px var(--lm-edge-strong);
|
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.16);
|
||||||
transition: color 0.15s ease, box-shadow 0.15s ease;
|
transition: color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
.lm-chip:hover {
|
.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'] {
|
.lm-chip[data-active='true'] {
|
||||||
color: var(--lm-accent);
|
color: rgb(var(--a-300));
|
||||||
box-shadow: inset 0 0 0 1px var(--lm-accent);
|
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. */
|
/** 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;
|
border-radius: 9px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
color: var(--lm-text-muted);
|
color: rgb(var(--n-300));
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
.lm-tab:hover {
|
.lm-tab:hover {
|
||||||
color: var(--lm-text);
|
color: rgb(var(--n-100));
|
||||||
background: rgba(233, 233, 237, 0.05);
|
background: rgb(var(--ink) / 0.06);
|
||||||
}
|
}
|
||||||
.lm-tab[data-active='true'] {
|
.lm-tab[data-active='true'] {
|
||||||
background: var(--lm-surface);
|
background: rgb(var(--n-800));
|
||||||
color: var(--lm-accent);
|
color: rgb(var(--n-50));
|
||||||
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
|
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.12), 0 1px 2px rgb(0 0 0 / 0.12);
|
||||||
}
|
}
|
||||||
.lm-tabstrip {
|
.lm-tabstrip {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -198,12 +316,12 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: var(--lm-panel);
|
background: rgb(var(--n-900));
|
||||||
box-shadow: inset 0 0 0 1px var(--lm-edge);
|
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 {
|
.lm-table {
|
||||||
@@ -213,11 +331,9 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
}
|
}
|
||||||
.lm-table th {
|
.lm-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.08em;
|
color: rgb(var(--n-400));
|
||||||
text-transform: uppercase;
|
|
||||||
color: rgba(233, 233, 237, 0.6);
|
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -225,34 +341,25 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.lm-table thead tr {
|
.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 tbody tr {
|
.lm-table tbody tr {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent,
|
transparent,
|
||||||
rgba(233, 233, 237, 0.08) 24px,
|
rgb(var(--ink) / 0.1) 24px,
|
||||||
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
rgb(var(--ink) / 0.1) calc(100% - 24px),
|
||||||
transparent
|
transparent
|
||||||
)
|
)
|
||||||
no-repeat bottom / 100% 1px;
|
no-repeat bottom / 100% 1px;
|
||||||
}
|
}
|
||||||
.lm-table tbody tr:hover {
|
.lm-table tbody tr:hover {
|
||||||
background:
|
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(
|
linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
transparent,
|
transparent,
|
||||||
rgba(233, 233, 237, 0.08) 24px,
|
rgb(var(--ink) / 0.1) 24px,
|
||||||
rgba(233, 233, 237, 0.08) calc(100% - 24px),
|
rgb(var(--ink) / 0.1) calc(100% - 24px),
|
||||||
transparent
|
transparent
|
||||||
)
|
)
|
||||||
no-repeat bottom / 100% 1px;
|
no-repeat bottom / 100% 1px;
|
||||||
@@ -266,21 +373,17 @@ h1, h2, h3, h4, h5, h6 {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #3f424d;
|
background: rgb(var(--n-700));
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #595d6c;
|
background: rgb(var(--n-600));
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
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 "./globals.css";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
import { I18nProvider } from "../i18n/provider";
|
import { I18nProvider } from "../i18n/provider";
|
||||||
import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config";
|
import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config";
|
||||||
|
import { THEME_BOOT_SCRIPT } from "../lib/theme";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console",
|
title: { default: "LambdaMail", template: "%s - LambdaMail" },
|
||||||
description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.",
|
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();
|
const locale = await resolveLocale();
|
||||||
|
|
||||||
return (
|
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">
|
<body className="min-h-screen bg-dark-bg font-sans text-slate-100 antialiased">
|
||||||
<I18nProvider locale={locale}>{children}</I18nProvider>
|
<I18nProvider locale={locale}>{children}</I18nProvider>
|
||||||
</body>
|
</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" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react";
|
import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react";
|
||||||
import { useTranslations } from "../i18n/provider";
|
import { useTranslations } from "../i18n/provider";
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
interface SignedInAccount {
|
interface SignedInAccount {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,7 +74,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const item =
|
const item =
|
||||||
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-white/[0.07]";
|
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-ink/[0.07]";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={root} className="relative">
|
<div ref={root} className="relative">
|
||||||
@@ -85,7 +86,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
title={t("accounts.switchAccount")}
|
title={t("accounts.switchAccount")}
|
||||||
className="flex w-full items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 text-left shadow-edge transition-colors hover:bg-dark-card"
|
className="flex w-full items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 text-left shadow-edge transition-colors hover:bg-dark-card"
|
||||||
>
|
>
|
||||||
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
|
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]" />
|
||||||
<span className="min-w-0 flex-1 truncate text-[12.5px]">{active?.email ?? t("common.loading")}</span>
|
<span className="min-w-0 flex-1 truncate text-[12.5px]">{active?.email ?? t("common.loading")}</span>
|
||||||
<ChevronsUpDown className="h-3.5 w-3.5 flex-none text-slate-400" />
|
<ChevronsUpDown className="h-3.5 w-3.5 flex-none text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
@@ -93,7 +94,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
role="menu"
|
role="menu"
|
||||||
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10"
|
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-ink/10"
|
||||||
>
|
>
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
<button
|
<button
|
||||||
@@ -112,7 +113,13 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
<Plus className="h-4 w-4 flex-none text-slate-400" />
|
<Plus className="h-4 w-4 flex-none text-slate-400" />
|
||||||
{t("accounts.addAccount")}
|
{t("accounts.addAccount")}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="my-1 h-px bg-white/10" />
|
<div className="my-1 h-px bg-ink/10" />
|
||||||
|
{/* Three choices in one row, so changing the look is one click. */}
|
||||||
|
<div className="flex items-center gap-2 px-2.5 py-1.5">
|
||||||
|
<span className="flex-1 text-[12.5px] text-slate-400">{t("appearance.title")}</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<div className="my-1 h-px bg-ink/10" />
|
||||||
<Link href="/user/settings" role="menuitem" onClick={onNavigate} className={item}>
|
<Link href="/user/settings" role="menuitem" onClick={onNavigate} className={item}>
|
||||||
<Settings className="h-4 w-4 flex-none text-slate-400" />
|
<Settings className="h-4 w-4 flex-none text-slate-400" />
|
||||||
{t("accounts.settings")}
|
{t("accounts.settings")}
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
AlertTriangle,
|
||||||
|
CornerDownLeft,
|
||||||
|
FileText,
|
||||||
|
Folder as FolderIcon,
|
||||||
|
Inbox,
|
||||||
|
Laptop,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
PenSquare,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Sun,
|
||||||
|
Trash2,
|
||||||
|
UserRound,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslations } from "../i18n/provider";
|
||||||
|
import { useFolders } from "../lib/useFolders";
|
||||||
|
import { customFolders } from "../lib/mailCounts";
|
||||||
|
import { setThemeChoice } from "../lib/theme";
|
||||||
|
|
||||||
|
/** Fired to ask the open mail list to show a search. */
|
||||||
|
export const SEARCH_EVENT = "lm:search";
|
||||||
|
|
||||||
|
interface Command {
|
||||||
|
id: string;
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
/** Extra words the label should also be found by. */
|
||||||
|
keywords?: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
hint?: string;
|
||||||
|
run: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignedIn {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place to reach anything: Ctrl+K or Cmd+K opens it.
|
||||||
|
*
|
||||||
|
* The screens stay quiet - a few folders, one main button - because everything
|
||||||
|
* else is a few keystrokes away here. Typing text that matches no command
|
||||||
|
* offers to search the mail for it, so it is also the search box.
|
||||||
|
*
|
||||||
|
* `c` writes a new message and `/` goes to the search field, from anywhere that
|
||||||
|
* is not itself a place to type.
|
||||||
|
*/
|
||||||
|
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const router = useRouter();
|
||||||
|
const { folders } = useFolders();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [cursor, setCursor] = useState(0);
|
||||||
|
const [accounts, setAccounts] = useState<SignedIn[]>([]);
|
||||||
|
const input = useRef<HTMLInputElement>(null);
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
setCursor(0);
|
||||||
|
previouslyFocused.current?.focus?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Opening, and the two single-key shortcuts.
|
||||||
|
useEffect(() => {
|
||||||
|
const typingIn = (target: EventTarget | null) => {
|
||||||
|
const el = target as HTMLElement | null;
|
||||||
|
if (!el) return false;
|
||||||
|
return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
|
||||||
|
};
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
setOpen((v) => !v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey || typingIn(event.target)) return;
|
||||||
|
if (event.key === "c") {
|
||||||
|
event.preventDefault();
|
||||||
|
router.push("/user/compose");
|
||||||
|
} else if (event.key === "/") {
|
||||||
|
const field = document.getElementById("mail-search");
|
||||||
|
if (field) {
|
||||||
|
event.preventDefault();
|
||||||
|
field.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
input.current?.focus();
|
||||||
|
fetch("/api/v1/auth/accounts", { cache: "no-store" })
|
||||||
|
.then((res) => (res.ok ? res.json() : { accounts: [] }))
|
||||||
|
.then((data) => setAccounts(data.accounts ?? []))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const go = useCallback(
|
||||||
|
(href: string) => () => {
|
||||||
|
close();
|
||||||
|
router.push(href);
|
||||||
|
},
|
||||||
|
[close, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commands = useMemo<Command[]>(() => {
|
||||||
|
const list: Command[] = [
|
||||||
|
{ id: "compose", group: t("palette.actions"), label: t("mail.compose"), keywords: "new write message nova escrever", icon: PenSquare, hint: "C", run: go("/user/compose") },
|
||||||
|
{ id: "settings", group: t("palette.actions"), label: t("settings.title"), keywords: "preferences signature filters", icon: Settings, run: go("/user/settings") },
|
||||||
|
{ id: "inbox", group: t("palette.navigate"), label: t("mail.inbox"), icon: Inbox, run: go("/user/mail/inbox") },
|
||||||
|
{ id: "sent", group: t("palette.navigate"), label: t("mail.sent"), icon: Send, run: go("/user/mail/sent") },
|
||||||
|
{ id: "drafts", group: t("palette.navigate"), label: t("mail.drafts"), icon: FileText, run: go("/user/mail/drafts") },
|
||||||
|
{ id: "archive", group: t("palette.navigate"), label: t("mail.archive"), icon: Archive, run: go("/user/mail/archive") },
|
||||||
|
{ id: "junk", group: t("palette.navigate"), label: t("mail.junk"), keywords: "spam", icon: AlertTriangle, run: go("/user/mail/junk") },
|
||||||
|
{ id: "trash", group: t("palette.navigate"), label: t("mail.trash"), keywords: "bin lixeira papelera", icon: Trash2, run: go("/user/mail/trash") },
|
||||||
|
{ id: "reports", group: t("palette.navigate"), label: t("mail.reports"), icon: ShieldCheck, run: go("/user/mail/reports") },
|
||||||
|
...customFolders(folders).map<Command>((folder) => ({
|
||||||
|
id: `folder-${folder.name}`,
|
||||||
|
group: t("palette.navigate"),
|
||||||
|
label: folder.name,
|
||||||
|
icon: FolderIcon,
|
||||||
|
run: go(`/user/mail/${encodeURIComponent(folder.name)}`),
|
||||||
|
})),
|
||||||
|
{ id: "theme-dark", group: t("palette.theme"), label: t("appearance.dark"), keywords: "theme tema", icon: Moon, run: () => { setThemeChoice("dark"); close(); } },
|
||||||
|
{ id: "theme-light", group: t("palette.theme"), label: t("appearance.light"), keywords: "theme tema", icon: Sun, run: () => { setThemeChoice("light"); close(); } },
|
||||||
|
{ id: "theme-system", group: t("palette.theme"), label: t("appearance.system"), keywords: "theme tema auto", icon: Laptop, run: () => { setThemeChoice("system"); close(); } },
|
||||||
|
...accounts
|
||||||
|
.filter((a) => !a.active)
|
||||||
|
.map<Command>((a) => ({
|
||||||
|
id: `account-${a.id}`,
|
||||||
|
group: t("palette.accounts"),
|
||||||
|
label: t("palette.switchTo", { email: a.email }),
|
||||||
|
keywords: a.email,
|
||||||
|
icon: UserRound,
|
||||||
|
run: () => {
|
||||||
|
void fetch("/api/v1/auth/switch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: a.id }),
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.ok) window.location.href = "/user/mail/inbox";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
{ id: "add-account", group: t("palette.accounts"), label: t("accounts.addAccount"), icon: Plus, run: go("/user/login?add=1") },
|
||||||
|
{
|
||||||
|
id: "signout",
|
||||||
|
group: t("palette.accounts"),
|
||||||
|
label: t("palette.signOut"),
|
||||||
|
icon: LogOut,
|
||||||
|
run: () => {
|
||||||
|
void fetch("/api/v1/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
})
|
||||||
|
.then((res) => (res.ok ? res.json() : { remaining: [] as unknown[] }))
|
||||||
|
.then((data: { remaining?: unknown[] }) => {
|
||||||
|
window.location.href = data.remaining?.length ? "/user/mail/inbox" : "/user/login";
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (isAdmin) {
|
||||||
|
// Next to the other actions, not at the end of the list, so the group stays whole.
|
||||||
|
list.splice(2, 0, { id: "admin", group: t("palette.actions"), label: t("admin.openAdminShort"), icon: ShieldCheck, run: go("/admin/dashboard") });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [t, go, close, folders, accounts, isAdmin]);
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (words.length === 0) return commands;
|
||||||
|
return commands.filter((c) => {
|
||||||
|
const haystack = `${c.label} ${c.keywords ?? ""} ${c.group}`.toLowerCase();
|
||||||
|
return words.every((w) => haystack.includes(w));
|
||||||
|
});
|
||||||
|
}, [commands, query]);
|
||||||
|
|
||||||
|
// Typing something no command matches is a search, so Enter is never a dead key.
|
||||||
|
const searchText = query.trim();
|
||||||
|
const items: Command[] = useMemo(() => {
|
||||||
|
if (searchText.length === 0) return shown;
|
||||||
|
const search: Command = {
|
||||||
|
id: "search",
|
||||||
|
group: t("palette.actions"),
|
||||||
|
label: t("palette.searchFor", { query: searchText }),
|
||||||
|
icon: Search,
|
||||||
|
run: () => {
|
||||||
|
close();
|
||||||
|
router.push("/user/mail/inbox");
|
||||||
|
// The list mounts after the route changes, so the request waits a beat.
|
||||||
|
setTimeout(() => window.dispatchEvent(new CustomEvent(SEARCH_EVENT, { detail: searchText })), 250);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return shown.length === 0 ? [search] : [...shown, search];
|
||||||
|
}, [shown, searchText, t, close, router]);
|
||||||
|
|
||||||
|
useEffect(() => setCursor(0), [query]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setCursor((c) => (c + 1) % Math.max(items.length, 1));
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setCursor((c) => (c - 1 + items.length) % Math.max(items.length, 1));
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
items[cursor]?.run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group headings appear where the group changes, in the order given.
|
||||||
|
let lastGroup = "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[14vh]" onKeyDown={onKeyDown}>
|
||||||
|
<div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={close} aria-hidden />
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("palette.open")}
|
||||||
|
className="relative flex max-h-[min(520px,70vh)] w-full max-w-[580px] flex-col overflow-hidden rounded-2xl bg-dark-panel shadow-lift"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 px-4 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)]">
|
||||||
|
<Search className="h-4 w-4 flex-none text-slate-400" aria-hidden />
|
||||||
|
<input
|
||||||
|
ref={input}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={t("palette.placeholder")}
|
||||||
|
aria-label={t("palette.placeholder")}
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="lm-palette-list"
|
||||||
|
aria-activedescendant={items[cursor] ? `lm-cmd-${items[cursor].id}` : undefined}
|
||||||
|
className="h-12 min-w-0 flex-1 bg-transparent text-[15px] text-slate-100 placeholder-slate-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<kbd className="hidden rounded-md px-1.5 py-0.5 text-[11px] text-slate-400 shadow-edge sm:block">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="lm-palette-list" role="listbox" className="overflow-y-auto p-1.5">
|
||||||
|
{items.length === 0 && <div className="px-3 py-6 text-center text-[13px] text-slate-400">{t("palette.empty")}</div>}
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const heading = item.group !== lastGroup ? item.group : null;
|
||||||
|
lastGroup = item.group;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.id}>
|
||||||
|
{heading && <div className="px-3 pb-1 pt-2.5 text-[12px] font-medium text-slate-400">{heading}</div>}
|
||||||
|
<button
|
||||||
|
id={`lm-cmd-${item.id}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={index === cursor}
|
||||||
|
type="button"
|
||||||
|
onMouseMove={() => setCursor(index)}
|
||||||
|
onClick={item.run}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-[14px] ${
|
||||||
|
index === cursor ? "bg-indigo-500/15 text-slate-50" : "text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 flex-none ${index === cursor ? "text-indigo-300" : "text-slate-400"}`} />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||||
|
{item.hint && <kbd className="rounded-md px-1.5 py-0.5 text-[11px] text-slate-400 shadow-edge">{item.hint}</kbd>}
|
||||||
|
</button>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden items-center gap-4 px-4 py-2 text-[12px] text-slate-400 shadow-[inset_0_1px_0_0_rgb(var(--ink)/0.09)] sm:flex">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<kbd className="rounded px-1 shadow-edge">↑</kbd>
|
||||||
|
<kbd className="rounded px-1 shadow-edge">↓</kbd>
|
||||||
|
{t("palette.hintMove")}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<CornerDownLeft className="h-3 w-3" aria-hidden />
|
||||||
|
{t("palette.hintOpen")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -378,12 +378,12 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** One row of the compose sheet: a fixed-width label beside the control. */
|
/** One row of the compose sheet: a fixed-width label beside the control. */
|
||||||
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
|
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]";
|
||||||
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
|
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
|
||||||
const bareInput =
|
const bareInput =
|
||||||
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
|
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
|
||||||
const toolButton =
|
const toolButton =
|
||||||
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
|
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={inline ? "" : "flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8"}>
|
<div className={inline ? "" : "flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8"}>
|
||||||
@@ -409,7 +409,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
|||||||
disabled={sending}
|
disabled={sending}
|
||||||
title={t("mail.highImportanceHint")}
|
title={t("mail.highImportanceHint")}
|
||||||
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
|
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-ink/[0.07]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
|
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
|
||||||
@@ -531,7 +531,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Formatting Toolbar */}
|
{/* Formatting Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
|
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]">
|
||||||
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
|
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
|
||||||
<Bold className="h-4 w-4" />
|
<Bold className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -554,7 +554,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
|||||||
>
|
>
|
||||||
<Strikethrough className="h-4 w-4" />
|
<Strikethrough className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
<span className="mx-1.5 h-[18px] w-px bg-ink/[0.14]" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => executeFormat("insertUnorderedList")}
|
onClick={() => executeFormat("insertUnorderedList")}
|
||||||
@@ -571,7 +571,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
|||||||
>
|
>
|
||||||
<ListOrdered className="h-4 w-4" />
|
<ListOrdered className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
|
<span className="mx-1.5 h-[18px] w-px bg-ink/[0.14]" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -598,8 +598,8 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Attachment Selector & Display */}
|
{/* Attachment Selector & Display */}
|
||||||
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
|
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgb(var(--ink)/0.07)]">
|
||||||
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
|
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-ink/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07]">
|
||||||
<Paperclip className="h-[15px] w-[15px]" />
|
<Paperclip className="h-[15px] w-[15px]" />
|
||||||
<span>{t("mail.addFiles")}</span>
|
<span>{t("mail.addFiles")}</span>
|
||||||
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
|
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useId } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LambdaMail mark: a lambda on an ultramarine tile, with the flap of an
|
||||||
|
* envelope lit across the top. The same drawing as app/icon.svg, inline so it
|
||||||
|
* costs no request and takes any size.
|
||||||
|
*/
|
||||||
|
export function Logo({ size = 32, className = "" }: { size?: number; className?: string }) {
|
||||||
|
// Several of these can be on one page, and a gradient id must be unique.
|
||||||
|
const id = useId().replace(/:/g, "");
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
className={className}
|
||||||
|
role="img"
|
||||||
|
aria-label="LambdaMail"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`tile-${id}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stopColor="#6F7EFF" />
|
||||||
|
<stop offset="1" stopColor="#3441D8" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="15" fill={`url(#tile-${id})`} />
|
||||||
|
<path
|
||||||
|
d="M0 15C0 6.7 6.7 0 15 0h34c8.3 0 15 6.7 15 15v2L32 40 0 17z"
|
||||||
|
fill="#fff"
|
||||||
|
fillOpacity=".11"
|
||||||
|
/>
|
||||||
|
<path d="M21 14.5 43 50" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M32.1 32.4 21 50" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ export function MessageFrame({ document: html }: { document: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
<div className="overflow-hidden rounded-xl bg-white shadow-edge ring-1 ring-ink/10">
|
||||||
<iframe
|
<iframe
|
||||||
ref={frame}
|
ref={frame}
|
||||||
title={t("ui.messageBody")}
|
title={t("ui.messageBody")}
|
||||||
|
|||||||
@@ -105,21 +105,21 @@ function initialsOf(name: string, address: string): string {
|
|||||||
/** A short type tag and colour for an attachment, from its extension. */
|
/** A short type tag and colour for an attachment, from its extension. */
|
||||||
function attachmentKind(filename: string): { tag: string; className: string } {
|
function attachmentKind(filename: string): { tag: string; className: string } {
|
||||||
const ext = (filename.split(".").pop() ?? "").toLowerCase();
|
const ext = (filename.split(".").pop() ?? "").toLowerCase();
|
||||||
if (ext === "pdf") return { tag: "PDF", className: "bg-rose-700" };
|
if (ext === "pdf") return { tag: "PDF", className: "bg-[#c2352f]" };
|
||||||
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-sky-700" };
|
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-[#1f7aa8]" };
|
||||||
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-blue-700" };
|
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-[#2f5fb8]" };
|
||||||
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-emerald-700" };
|
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-[#1f8a5b]" };
|
||||||
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-amber-700" };
|
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-[#a8690f]" };
|
||||||
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-slate-600" };
|
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-[#5b6272]" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// One look for every control on the command bar, so the bar reads as a single
|
// One look for every control on the command bar, so the bar reads as a single
|
||||||
// strip. The label hides on narrow screens and the icon stays, which is what
|
// strip. The label hides on narrow screens and the icon stays, which is what
|
||||||
// keeps six actions on one row at phone width.
|
// keeps six actions on one row at phone width.
|
||||||
const commandClass =
|
const commandClass =
|
||||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||||
const infoLinkClass =
|
const infoLinkClass =
|
||||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||||
|
|
||||||
export function MessageReader(props: Props) {
|
export function MessageReader(props: Props) {
|
||||||
const { message, row, folder, actions } = props;
|
const { message, row, folder, actions } = props;
|
||||||
@@ -181,7 +181,7 @@ export function MessageReader(props: Props) {
|
|||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={t("mail.messageActions")}
|
aria-label={t("mail.messageActions")}
|
||||||
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgba(233,233,237,0.09)] sm:px-4"
|
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgb(var(--ink)/0.09)] sm:px-4"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -218,7 +218,7 @@ export function MessageReader(props: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span aria-hidden className="mx-1 h-5 w-px bg-white/[0.12]" />
|
<span aria-hidden className="mx-1 h-5 w-px bg-ink/[0.12]" />
|
||||||
|
|
||||||
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
|
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -252,7 +252,7 @@ export function MessageReader(props: Props) {
|
|||||||
setMoveOpen(false);
|
setMoveOpen(false);
|
||||||
props.onMove(target.name);
|
props.onMove(target.name);
|
||||||
}}
|
}}
|
||||||
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:bg-white/[0.07] focus-visible:outline-none"
|
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:bg-ink/[0.07] focus-visible:outline-none"
|
||||||
>
|
>
|
||||||
{target.label}
|
{target.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -321,7 +321,7 @@ export function MessageReader(props: Props) {
|
|||||||
address={row?.sender_address ?? address}
|
address={row?.sender_address ?? address}
|
||||||
initials={initialsOf(displayName, address)}
|
initials={initialsOf(displayName, address)}
|
||||||
size={44}
|
size={44}
|
||||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||||
/>
|
/>
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
@@ -339,7 +339,7 @@ export function MessageReader(props: Props) {
|
|||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
props.senderTrusted
|
props.senderTrusted
|
||||||
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
: "text-slate-400 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] hover:bg-ink/[0.07] hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-3 w-3" aria-hidden />
|
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||||
@@ -364,7 +364,7 @@ export function MessageReader(props: Props) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRecipientsOpen((open) => !open)}
|
onClick={() => setRecipientsOpen((open) => !open)}
|
||||||
aria-expanded={recipientsOpen}
|
aria-expanded={recipientsOpen}
|
||||||
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||||
>
|
>
|
||||||
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
|
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
|
||||||
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
|
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function RecipientInput({ id, value, onChange, placeholder, disabled, inv
|
|||||||
title={parsed.valid ? parsed.address : t("mail.invalidAddress")}
|
title={parsed.valid ? parsed.address : t("mail.invalidAddress")}
|
||||||
className={`inline-flex max-w-full items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-[12.5px] ${
|
className={`inline-flex max-w-full items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-[12.5px] ${
|
||||||
parsed.valid
|
parsed.valid
|
||||||
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_#5d5294]"
|
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||||
: "bg-rose-900/60 text-rose-100 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.5)]"
|
: "bg-rose-900/60 text-rose-100 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.5)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -116,7 +116,7 @@ export function RecipientInput({ id, value, onChange, placeholder, disabled, inv
|
|||||||
remove(index);
|
remove(index);
|
||||||
}}
|
}}
|
||||||
aria-label={t("mail.removeRecipient", { name: parsed.label })}
|
aria-label={t("mail.removeRecipient", { name: parsed.label })}
|
||||||
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
|
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-ink/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Laptop, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTranslations } from "../i18n/provider";
|
||||||
|
import { useTheme, type ThemeChoice } from "../lib/theme";
|
||||||
|
|
||||||
|
/** Dark, light or follow the system: three buttons, one click each. */
|
||||||
|
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const theme = useTheme();
|
||||||
|
const options: [ThemeChoice, React.ComponentType<{ className?: string }>, string][] = [
|
||||||
|
["dark", Moon, t("appearance.dark")],
|
||||||
|
["light", Sun, t("appearance.light")],
|
||||||
|
["system", Laptop, t("appearance.system")],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label={t("appearance.title")} className={`flex rounded-lg p-0.5 shadow-edge ${className}`}>
|
||||||
|
{options.map(([value, Icon, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={theme.choice === value}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
onClick={() => theme.set(value)}
|
||||||
|
className={`flex h-7 w-8 items-center justify-center rounded-md transition-colors ${
|
||||||
|
theme.choice === value ? "bg-indigo-500/20 text-indigo-300" : "text-slate-400 hover:text-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -75,11 +75,11 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commandClass =
|
const commandClass =
|
||||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||||
const infoLinkClass =
|
const infoLinkClass =
|
||||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||||
const replyButtonClass =
|
const replyButtonClass =
|
||||||
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||||
|
|
||||||
/** The name and address in a header like `Jenny <jenny@x.test>`. */
|
/** The name and address in a header like `Jenny <jenny@x.test>`. */
|
||||||
function splitSender(from: string): { name: string; address: string } {
|
function splitSender(from: string): { name: string; address: string } {
|
||||||
@@ -306,7 +306,7 @@ export function ThreadReader(props: Props) {
|
|||||||
aria-label={thread.subject}
|
aria-label={thread.subject}
|
||||||
>
|
>
|
||||||
{/* The commands that apply to the whole conversation. */}
|
{/* The commands that apply to the whole conversation. */}
|
||||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] sm:px-4">
|
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] sm:px-4">
|
||||||
<button type="button" onClick={props.onClose} className={`${commandClass} md:hidden`} aria-label={t("mail.backToList")}>
|
<button type="button" onClick={props.onClose} className={`${commandClass} md:hidden`} aria-label={t("mail.backToList")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -320,7 +320,7 @@ export function ThreadReader(props: Props) {
|
|||||||
<span className="hidden sm:inline">{t("mail.replyAll")}</span>
|
<span className="hidden sm:inline">{t("mail.replyAll")}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="mx-1 hidden h-5 w-px bg-white/10 sm:block" />
|
<span className="mx-1 hidden h-5 w-px bg-ink/10 sm:block" />
|
||||||
<button type="button" onClick={() => void deleteThread()} disabled={busy || inThisFolder.length === 0} className={commandClass}>
|
<button type="button" onClick={() => void deleteThread()} disabled={busy || inThisFolder.length === 0} className={commandClass}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
<span className="hidden lg:inline">{props.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
|
<span className="hidden lg:inline">{props.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
|
||||||
@@ -339,14 +339,14 @@ export function ThreadReader(props: Props) {
|
|||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
{moveOpen && (
|
{moveOpen && (
|
||||||
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10">
|
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-ink/10">
|
||||||
{props.moveTargets.map((target) => (
|
{props.moveTargets.map((target) => (
|
||||||
<button
|
<button
|
||||||
key={target.name}
|
key={target.name}
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void moveThread(target.name)}
|
onClick={() => void moveThread(target.name)}
|
||||||
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-white/[0.07]"
|
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-ink/[0.07]"
|
||||||
>
|
>
|
||||||
{target.label}
|
{target.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -422,7 +422,7 @@ export function ThreadReader(props: Props) {
|
|||||||
address={info.sender_address}
|
address={info.sender_address}
|
||||||
initials={initialsOf(displayName, info.sender_address)}
|
initials={initialsOf(displayName, info.sender_address)}
|
||||||
size={isOpen ? 40 : 30}
|
size={isOpen ? 40 : 30}
|
||||||
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
|
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
|
||||||
/>
|
/>
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="flex items-baseline gap-2">
|
<span className="flex items-baseline gap-2">
|
||||||
@@ -430,7 +430,7 @@ export function ThreadReader(props: Props) {
|
|||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
{isOpen && !info.own && sender.address && (
|
{isOpen && !info.own && sender.address && (
|
||||||
<span className="min-w-0 truncate text-[12.5px] text-slate-400"><{sender.address}></span>
|
<span className="hidden min-w-0 truncate text-[12.5px] text-slate-400 sm:inline"><{sender.address}></span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
@@ -477,7 +477,7 @@ export function ThreadReader(props: Props) {
|
|||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
|
||||||
trustedHere
|
trustedHere
|
||||||
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
|
||||||
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
|
: "text-slate-400 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] hover:bg-ink/[0.07] hover:text-slate-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-3 w-3" aria-hidden />
|
<ShieldCheck className="h-3 w-3" aria-hidden />
|
||||||
@@ -486,7 +486,7 @@ export function ThreadReader(props: Props) {
|
|||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
href={`/user/compose?mode=forward&subject=${encodeURIComponent(loaded.subject)}`}
|
href={`/user/compose?mode=forward&subject=${encodeURIComponent(loaded.subject)}`}
|
||||||
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-white/[0.07]"
|
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-ink/[0.07]"
|
||||||
>
|
>
|
||||||
<Forward className="h-3.5 w-3.5" aria-hidden />
|
<Forward className="h-3.5 w-3.5" aria-hidden />
|
||||||
{t("mail.forward")}
|
{t("mail.forward")}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function TotpEnrolment({ secret, uri }: { secret: string; uri: string | n
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copySecret()}
|
onClick={() => void copySecret()}
|
||||||
className="flex-none rounded-[10px] border border-white/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
|
className="flex-none rounded-[10px] border border-ink/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
|
||||||
>
|
>
|
||||||
{copied ? t("common.copied") : t("common.copy")}
|
{copied ? t("common.copied") : t("common.copy")}
|
||||||
</button>
|
</button>
|
||||||
@@ -132,7 +132,7 @@ export function RecoveryCodes({
|
|||||||
<p className="mb-3 leading-relaxed">{t("settings.saveRecoveryCodes")}</p>
|
<p className="mb-3 leading-relaxed">{t("settings.saveRecoveryCodes")}</p>
|
||||||
<div className="lm-code grid grid-cols-2 gap-1.5 p-3 text-center text-[11px] sm:grid-cols-3">
|
<div className="lm-code grid grid-cols-2 gap-1.5 p-3 text-center text-[11px] sm:grid-cols-3">
|
||||||
{codes.map((code) => (
|
{codes.map((code) => (
|
||||||
<span key={code} className="rounded bg-white/[0.05] p-1">
|
<span key={code} className="rounded bg-ink/[0.05] p-1">
|
||||||
{code}
|
{code}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -143,7 +143,7 @@ export function RecoveryCodes({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyAll()}
|
onClick={() => void copyAll()}
|
||||||
className="flex-1 rounded-[10px] border border-white/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.07]"
|
className="flex-1 rounded-[10px] border border-ink/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-ink/[0.07]"
|
||||||
>
|
>
|
||||||
{copied ? t("common.copied") : t("common.copy")}
|
{copied ? t("common.copied") : t("common.copy")}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export function Button({
|
|||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary:
|
primary:
|
||||||
'border-indigo-500 text-indigo-500 bg-transparent hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]',
|
'border-transparent bg-fill text-white shadow-sm hover:bg-fill-hover active:brightness-95',
|
||||||
secondary:
|
secondary:
|
||||||
'border-white/[0.14] text-slate-100 bg-transparent hover:bg-white/[0.07] active:bg-white/[0.14]',
|
'border-ink/[0.14] text-slate-100 bg-transparent hover:bg-ink/[0.07] active:bg-ink/[0.14]',
|
||||||
outline:
|
outline:
|
||||||
'border-white/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-white/[0.07] active:bg-white/[0.14]',
|
'border-ink/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-ink/[0.07] active:bg-ink/[0.14]',
|
||||||
danger:
|
danger:
|
||||||
'border-rose-400 text-rose-400 bg-transparent hover:bg-rose-400/[0.12] active:bg-rose-400/[0.22]',
|
'border-rose-400 text-rose-400 bg-transparent hover:bg-rose-400/[0.12] active:bg-rose-400/[0.22]',
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The interface has two themes, dark and light, and a third choice that
|
||||||
|
* follows the system. Dark is what a first visit gets.
|
||||||
|
*
|
||||||
|
* The choice is stored in this browser, not on the account: a laptop in a
|
||||||
|
* bright office and a phone in bed are different answers to the same question.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ThemeChoice = "dark" | "light" | "system";
|
||||||
|
export type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
export const THEME_KEY = "lm_theme";
|
||||||
|
const CHANGED = "lm:theme-changed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs in <head>, before anything is painted, so the page never shows the
|
||||||
|
* wrong theme for a moment. It is a string rather than a function because it
|
||||||
|
* has to run as written, outside React, and must not throw: a browser that
|
||||||
|
* refuses storage still gets the default.
|
||||||
|
*/
|
||||||
|
export const THEME_BOOT_SCRIPT = `(function(){var d=document.documentElement,t="dark";try{var c=localStorage.getItem("${THEME_KEY}");if(c==="light")t="light";else if(c==="system")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}catch(e){}d.setAttribute("data-theme",t);d.style.colorScheme=t})();`;
|
||||||
|
|
||||||
|
export function readChoice(): ThemeChoice {
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(THEME_KEY);
|
||||||
|
return stored === "light" || stored === "system" ? stored : "dark";
|
||||||
|
} catch {
|
||||||
|
return "dark";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a choice means right now: "system" is whatever the system is set to. */
|
||||||
|
export function resolve(choice: ThemeChoice): Theme {
|
||||||
|
if (choice === "system") {
|
||||||
|
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(theme: Theme) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.setAttribute("data-theme", theme);
|
||||||
|
root.style.colorScheme = theme;
|
||||||
|
// Keeps the browser's own bar in step with a choice made on the page, which
|
||||||
|
// the viewport metadata alone cannot follow.
|
||||||
|
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
||||||
|
if (meta) meta.content = theme === "light" ? "#F3F4F7" : "#0F1218";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setThemeChoice(choice: ThemeChoice) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(THEME_KEY, choice);
|
||||||
|
} catch {
|
||||||
|
// The choice holds for this visit and is not remembered.
|
||||||
|
}
|
||||||
|
apply(resolve(choice));
|
||||||
|
window.dispatchEvent(new Event(CHANGED));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [choice, setChoice] = useState<ThemeChoice>("dark");
|
||||||
|
const [theme, setTheme] = useState<Theme>("dark");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => {
|
||||||
|
const next = readChoice();
|
||||||
|
setChoice(next);
|
||||||
|
setTheme(resolve(next));
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||||
|
const onSystem = () => {
|
||||||
|
if (readChoice() === "system") {
|
||||||
|
apply(resolve("system"));
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(CHANGED, sync);
|
||||||
|
window.addEventListener("storage", sync);
|
||||||
|
media.addEventListener("change", onSystem);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(CHANGED, sync);
|
||||||
|
window.removeEventListener("storage", sync);
|
||||||
|
media.removeEventListener("change", onSystem);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = useCallback((next: ThemeChoice) => setThemeChoice(next), []);
|
||||||
|
return { choice, theme, set };
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import { THEME_BOOT_SCRIPT, THEME_KEY } from "./lib/theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two themes, and what keeps them from drifting apart.
|
||||||
|
*
|
||||||
|
* Every colour is a variable defined once per theme, and the screens read the
|
||||||
|
* Tailwind scales that point at those variables. The failures worth guarding
|
||||||
|
* are the quiet ones: a variable one theme forgot, a class that bypasses the
|
||||||
|
* variables and is right in dark and unreadable in light, and a first paint in
|
||||||
|
* the wrong theme.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const read = (path: string) => readFileSync(resolve(root, path), "utf8");
|
||||||
|
|
||||||
|
function sourceFiles(dir: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
const path = join(dir, entry);
|
||||||
|
if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
|
||||||
|
else if (path.endsWith(".tsx")) out.push(path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the theme variables", () => {
|
||||||
|
const css = read("src/app/globals.css");
|
||||||
|
const block = (selector: string) => {
|
||||||
|
const start = css.indexOf(selector);
|
||||||
|
return css.slice(start, css.indexOf("\n}", start));
|
||||||
|
};
|
||||||
|
const names = (text: string) => [...text.matchAll(/--([a-z0-9-]+):/g)].map((m) => m[1]).sort();
|
||||||
|
|
||||||
|
it("defines the same variables in dark and in light", () => {
|
||||||
|
const dark = names(block(":root, [data-theme='dark']"));
|
||||||
|
const light = names(block("[data-theme='light']"));
|
||||||
|
expect(dark.length).toBeGreaterThan(50);
|
||||||
|
expect(light).toEqual(dark);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the ramps running the same way: 100 is the text, 950 the ground", () => {
|
||||||
|
const channel = (selector: string, name: string) =>
|
||||||
|
block(selector)
|
||||||
|
.match(new RegExp(`--${name}: (\\d+) (\\d+) (\\d+);`))!
|
||||||
|
.slice(1)
|
||||||
|
.map(Number)
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
const dark = ":root, [data-theme='dark']";
|
||||||
|
const light = "[data-theme='light']";
|
||||||
|
// In dark the text is lighter than the ground; in light it is darker.
|
||||||
|
expect(channel(dark, "n-100")).toBeGreaterThan(channel(dark, "n-950"));
|
||||||
|
expect(channel(light, "n-100")).toBeLessThan(channel(light, "n-950"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the first paint", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
const boot = () => new Function(THEME_BOOT_SCRIPT)();
|
||||||
|
|
||||||
|
it("is dark when nothing was ever chosen", () => {
|
||||||
|
boot();
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is light when light was chosen", () => {
|
||||||
|
localStorage.setItem(THEME_KEY, "light");
|
||||||
|
boot();
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is dark for anything it does not recognise", () => {
|
||||||
|
localStorage.setItem(THEME_KEY, "purple");
|
||||||
|
boot();
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is dark, and does not throw, when storage refuses", () => {
|
||||||
|
const original = Storage.prototype.getItem;
|
||||||
|
Storage.prototype.getItem = () => {
|
||||||
|
throw new Error("blocked");
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
expect(() => boot()).not.toThrow();
|
||||||
|
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||||
|
} finally {
|
||||||
|
Storage.prototype.getItem = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is set before anything renders: the layout puts it in the head", () => {
|
||||||
|
expect(read("src/app/layout.tsx")).toMatch(/<head>[\s\S]*THEME_BOOT_SCRIPT[\s\S]*<\/head>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("colours that would only be right in one theme", () => {
|
||||||
|
const files = sourceFiles(resolve(root, "src"));
|
||||||
|
|
||||||
|
// white at low opacity is a tint that vanishes on a light ground; `ink` is the
|
||||||
|
// colour that sits on whichever ground there is.
|
||||||
|
it("does not tint with white", () => {
|
||||||
|
for (const file of files) {
|
||||||
|
expect(readFileSync(file, "utf8"), file).not.toMatch(/\b(bg|border|text|ring|shadow|divide)-white\/\d/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hard-code the old accent", () => {
|
||||||
|
for (const file of files) {
|
||||||
|
expect(readFileSync(file, "utf8"), file).not.toMatch(/#9184d9|#5d5294/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads colours from variables in the Tailwind config", () => {
|
||||||
|
const config = read("tailwind.config.js");
|
||||||
|
expect(config).toContain("var(--${name}-${step})");
|
||||||
|
expect(config).toContain("[data-theme='dark']");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the app's identity", () => {
|
||||||
|
it("has a tab icon, a home-screen icon and an install manifest", () => {
|
||||||
|
for (const file of ["src/app/icon.svg", "src/app/favicon.ico", "src/app/apple-icon.png", "src/app/manifest.ts"]) {
|
||||||
|
expect(existsSync(resolve(root, file)), file).toBe(true);
|
||||||
|
}
|
||||||
|
for (const file of ["icon-192.png", "icon-512.png", "maskable-512.png"]) {
|
||||||
|
expect(existsSync(resolve(root, "public/icons", file)), file).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serves its font itself rather than asking a third party on every visit", () => {
|
||||||
|
expect(read("src/app/globals.css")).not.toMatch(/fonts\.googleapis\.com/);
|
||||||
|
expect(read("src/app/layout.tsx")).toContain("@fontsource-variable/geist");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the choice of theme on the account menu, the admin console and both sign-ins", () => {
|
||||||
|
expect(read("src/components/AccountSwitcher.tsx")).toContain("ThemeToggle");
|
||||||
|
expect(read("src/app/(admin)/admin/layout.tsx")).toContain("ThemeToggle");
|
||||||
|
expect(read("src/app/(auth)/user/login/page.tsx")).toContain("ThemeToggle");
|
||||||
|
expect(read("src/app/(auth)/admin/login/page.tsx")).toContain("ThemeToggle");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,83 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* Nocturne, expressed as Tailwind scales.
|
* The interface's colours, as Tailwind scales that read CSS variables.
|
||||||
*
|
*
|
||||||
* The redesign replaces two visual languages with one: the webmail's indigo and
|
* The values live in globals.css, once per theme. The scales the screens
|
||||||
* the console's green both become the single blurple accent, and every grey
|
* already name - slate for neutrals, indigo for the accent, amber, rose and
|
||||||
* comes from one perceptual ramp instead of Tailwind's stock slate. Rather than
|
* emerald for the signals - point at those variables, so switching theme
|
||||||
* rename several thousand utility classes across the screens, the scales those
|
* changes what `text-slate-400` is without touching a class. `ink` is the
|
||||||
* classes already name are remapped onto the design system's own values - so
|
* colour that sits on the ground (white on dark, near-black on light): hover
|
||||||
* `bg-slate-900`, `text-emerald-400` and `border-indigo-500/30` all resolve
|
* tints and hairlines are `ink` at low opacity.
|
||||||
* into Nocturne, and a colour cannot enter the interface from outside it.
|
|
||||||
*
|
|
||||||
* The values here are the same ones globals.css carries as CSS variables.
|
|
||||||
*
|
*
|
||||||
* @type {import('tailwindcss').Config}
|
* @type {import('tailwindcss').Config}
|
||||||
*/
|
*/
|
||||||
|
const ramp = (name) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].map((step) => [
|
||||||
|
step,
|
||||||
|
`rgb(var(--${name}-${step}) / <alpha-value>)`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
// The neutral ramp, ordered the way Tailwind expects (light to dark) so the
|
const neutral = ramp('n');
|
||||||
// existing `text-slate-400` / `bg-slate-900` idiom keeps meaning what it meant.
|
const accent = ramp('a');
|
||||||
const neutral = {
|
const warn = ramp('w');
|
||||||
50: '#f3f5fe',
|
const bad = ramp('b');
|
||||||
100: '#e9e9ed', // body text
|
const good = ramp('g');
|
||||||
200: '#e4e7f5',
|
|
||||||
300: '#cfd3e5',
|
|
||||||
400: '#9397ab', // muted text
|
|
||||||
500: '#75798c',
|
|
||||||
600: '#595d6c',
|
|
||||||
700: '#3f424d', // borders
|
|
||||||
800: '#232532', // card
|
|
||||||
900: '#1b1c28', // panel
|
|
||||||
950: '#161826', // page ground
|
|
||||||
};
|
|
||||||
|
|
||||||
// The one accent, in the product's own blurple (OKLCH hue 289).
|
|
||||||
const accent = {
|
|
||||||
50: '#faf9ff',
|
|
||||||
100: '#f5f4ff',
|
|
||||||
200: '#e7e5fe',
|
|
||||||
300: '#d2cefd',
|
|
||||||
400: '#b5abfc',
|
|
||||||
500: '#9184d9',
|
|
||||||
600: '#796cbf',
|
|
||||||
700: '#5d5294',
|
|
||||||
800: '#423a6a',
|
|
||||||
900: '#2b2741',
|
|
||||||
950: '#211d33',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Warning and danger are the only other hues, and both are desaturated: on this
|
|
||||||
// ground a saturated amber or rose reads as an error in the design, not in the
|
|
||||||
// data.
|
|
||||||
const warn = {
|
|
||||||
50: '#fbf8ee',
|
|
||||||
100: '#f6efd8',
|
|
||||||
200: '#ecdfb2',
|
|
||||||
300: '#dfcb8b',
|
|
||||||
400: '#cbb46a',
|
|
||||||
500: '#b39b52',
|
|
||||||
600: '#8f7b41',
|
|
||||||
700: '#6b5c31',
|
|
||||||
800: '#473d21',
|
|
||||||
900: '#2f2917',
|
|
||||||
950: '#1f1b10',
|
|
||||||
};
|
|
||||||
|
|
||||||
const bad = {
|
|
||||||
50: '#fcf3f4',
|
|
||||||
100: '#f8e3e5',
|
|
||||||
200: '#f0c8cc',
|
|
||||||
300: '#e2a8ae',
|
|
||||||
400: '#d08a92',
|
|
||||||
500: '#b96f78',
|
|
||||||
600: '#985860',
|
|
||||||
700: '#74434a',
|
|
||||||
800: '#4e2e33',
|
|
||||||
900: '#3a2a2d',
|
|
||||||
950: '#241a1c',
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: 'class',
|
darkMode: ['selector', "[data-theme='dark']"],
|
||||||
content: [
|
content: [
|
||||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
@@ -90,46 +38,51 @@ module.exports = {
|
|||||||
gray: neutral,
|
gray: neutral,
|
||||||
zinc: neutral,
|
zinc: neutral,
|
||||||
neutral: neutral,
|
neutral: neutral,
|
||||||
// Every accent name in the codebase collapses onto the one accent. The
|
// Every blue, violet and teal name in the codebase is the one accent.
|
||||||
// console's emerald and teal are the point of the exercise: the two
|
|
||||||
// surfaces are one product and now look like it.
|
|
||||||
indigo: accent,
|
indigo: accent,
|
||||||
violet: accent,
|
violet: accent,
|
||||||
purple: accent,
|
purple: accent,
|
||||||
emerald: accent,
|
|
||||||
teal: accent,
|
|
||||||
cyan: accent,
|
cyan: accent,
|
||||||
sky: accent,
|
sky: accent,
|
||||||
blue: accent,
|
blue: accent,
|
||||||
brand: accent,
|
brand: accent,
|
||||||
|
// Green means answered, trusted and healthy, and nothing else.
|
||||||
|
emerald: good,
|
||||||
|
teal: good,
|
||||||
|
green: good,
|
||||||
amber: warn,
|
amber: warn,
|
||||||
yellow: warn,
|
yellow: warn,
|
||||||
orange: warn,
|
orange: warn,
|
||||||
rose: bad,
|
rose: bad,
|
||||||
red: bad,
|
red: bad,
|
||||||
pink: bad,
|
pink: bad,
|
||||||
|
ink: 'rgb(var(--ink) / <alpha-value>)',
|
||||||
|
// The solid fill of the one main action on a screen.
|
||||||
|
fill: {
|
||||||
|
DEFAULT: 'rgb(var(--a-fill) / <alpha-value>)',
|
||||||
|
hover: 'rgb(var(--a-fill-hover) / <alpha-value>)',
|
||||||
|
},
|
||||||
dark: {
|
dark: {
|
||||||
rail: '#12131f',
|
rail: 'rgb(var(--rail) / <alpha-value>)',
|
||||||
bg: '#161826',
|
bg: 'rgb(var(--n-950) / <alpha-value>)',
|
||||||
panel: '#1b1c28',
|
panel: 'rgb(var(--n-900) / <alpha-value>)',
|
||||||
card: '#232532',
|
card: 'rgb(var(--n-800) / <alpha-value>)',
|
||||||
border: 'rgba(233, 233, 237, 0.09)',
|
border: 'rgb(var(--ink) / 0.09)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
sans: ['"Geist Variable"', 'Geist', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'],
|
mono: ['ui-monospace', '"SF Mono"', 'Menlo', 'Consolas', 'monospace'],
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
xl: '12px',
|
xl: '12px',
|
||||||
'2xl': '14px',
|
'2xl': '16px',
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
// On a dark ground elevation is an edge plus ambient darkness, never a
|
// Elevation is a hairline plus ambient shade; never a coloured glow.
|
||||||
// stack of coloured glows.
|
edge: 'inset 0 0 0 1px rgb(var(--ink) / 0.09)',
|
||||||
edge: 'inset 0 0 0 1px rgba(233, 233, 237, 0.09)',
|
'edge-accent': 'inset 0 0 0 1px rgb(var(--a-500) / 0.45)',
|
||||||
'edge-accent': 'inset 0 0 0 1px rgba(145, 132, 217, 0.4)',
|
lift: '0 0 0 1px rgb(var(--ink) / 0.12), var(--lm-shadow)',
|
||||||
lift: '0 0 0 1px #3f424d, 0 16px 40px rgba(0, 0, 0, 0.5)',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user