Phone: Settings in the menu, sliding Settings layout

The left panel shows Settings on a phone again, with Install app moved above
the divider. On a phone, Settings is a full-width list of sections; the chosen
one slides in from the right over it and back out on ‹ Settings or the phone's
Back (each section is its own URL; opened directly, ‹ Settings goes to the
list). A section's own first heading is hidden there when it repeats the title
in the header. The account rows wrap on a narrow screen instead of running off
it. Desktop Settings is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 19:12:54 +02:00
co-authored by Claude Opus 5.5
parent fda4cc6f16
commit 843b48cd59
2 changed files with 189 additions and 50 deletions
+6 -3
View File
@@ -980,19 +980,22 @@ export function MailLayout() {
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
{!collapsed && <PluginSlot name="sidebar.section" />}
</nav>
{mobile && (
<div className="p-2 text-[15px]">
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
</div>
)}
<div className="border-t border-app-border p-2 text-[15px]">
{/* Settings has no phone layout yet either. */}
<Link
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
} ${mobile ? "hidden" : ""}`}
}`}
title={collapsed ? t("nav.settings") : undefined}
to="/settings"
>
<Icon name="settings" size={22} />
{!collapsed && t("nav.settings")}
</Link>
{mobile && <InstallAppEntry onDone={() => setDrawerOpen(false)} />}
<button
className={`wm-online-only flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
collapsed ? "justify-center px-0" : "px-3"
+185 -49
View File
@@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { QRCodeSVG } from "qrcode.react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { RichTextEditor } from "../../components/compose/RichTextEditor";
@@ -23,6 +23,7 @@ import {
import { prefetchRecent } from "../../lib/offlinePrefetch";
import { disablePush, enablePush, usePushState } from "../../lib/push";
import { useTheme } from "../../lib/theme";
import { useIsMobile } from "../../lib/useIsMobile";
import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot";
import { usePluginSettingsSections } from "../../plugins/manifest";
@@ -48,50 +49,14 @@ export function Settings() {
// plugin name, rather than being stacked at the bottom of Accounts.
const pluginSections = usePluginSettingsSections();
const isPluginSection = pluginSections.some((b) => b.plugin === params.section);
const section = (
addingAccount
? "accounts"
: SECTIONS.includes(params.section as Section) || isPluginSection
? params.section
: "profile"
) as Section;
// A section named in the URL. Without one, a phone shows the list of
// sections; the desktop layout shows Profile.
const chosen = addingAccount || SECTIONS.includes(params.section as Section) || isPluginSection;
const section = (addingAccount ? "accounts" : chosen ? params.section : "profile") as Section;
const mobile = useIsMobile();
return (
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link to={from} className="text-sm text-app-accent">
← {t("nav.back")}
</Link>
<span className="font-semibold">{t("settings.title")}</span>
</header>
<div className="flex min-h-0 flex-1">
<nav className="w-52 shrink-0 border-r border-app-border p-2">
{SECTIONS.map((s) => (
<Link
key={s}
to={`/settings/${s}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
}`}
>
{t(`settings.${s}` as const)}
</Link>
))}
{pluginSections.map((b) => (
<Link
key={b.plugin}
to={`/settings/${b.plugin}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${
section === (b.plugin as Section)
? "bg-app-active font-medium text-app-fg"
: "hover:bg-app-hover"
}`}
>
{b.title ?? b.plugin}
</Link>
))}
</nav>
<main className="flex-1 overflow-y-auto p-6">
const content = (
<>
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
{!addingAccount && section === "profile" && <ProfileSection />}
{!addingAccount && section === "security" && <SecuritySection />}
@@ -106,12 +71,182 @@ export function Settings() {
<PluginSlot name="settings.page" plugin={section} />
</div>
)}
</>
);
const entries = [
...SECTIONS.map((s) => ({ key: s as string, label: t(`settings.${s}` as const) })),
...pluginSections.map((b) => ({ key: b.plugin, label: b.title ?? b.plugin })),
];
if (mobile) {
return (
<MobileSettings
from={from}
fromQuery={fromQuery}
entries={entries}
open={chosen ? section : null}
content={content}
/>
);
}
return (
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link to={from} className="text-sm text-app-accent">
← {t("nav.back")}
</Link>
<span className="font-semibold">{t("settings.title")}</span>
</header>
<div className="flex min-h-0 flex-1">
<nav className="w-52 shrink-0 border-r border-app-border p-2">
{entries.map((e) => (
<Link
key={e.key}
to={`/settings/${e.key}${fromQuery}`}
className={`block rounded px-3 py-2 text-sm ${
section === e.key ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
}`}
>
{e.label}
</Link>
))}
</nav>
<main className="flex-1 overflow-y-auto p-6">{content}</main>
</div>
</div>
);
}
/**
* Settings on a phone: the sections as a full-width list; the chosen one slides
* in over it from the right and back out on ‹ Settings (or the phone's Back,
* since each section is its own URL).
*/
function MobileSettings({
from,
fromQuery,
entries,
open,
content,
}: {
from: string;
fromQuery: string;
entries: { key: string; label: string }[];
open: string | null;
content: JSX.Element;
}) {
const t = useT();
const navigate = useNavigate();
const location = useLocation();
// What the panel shows: the open section, and while it slides out, the one
// that was open.
const [shown, setShown] = useState<{ key: string; content: JSX.Element } | null>(
open ? { key: open, content } : null,
);
useEffect(() => {
if (open) setShown({ key: open, content });
}, [open, content]);
const key = open ?? shown?.key;
const title = entries.find((e) => e.key === key)?.label ?? "";
// Most sections open with their own name as a heading, which the header
// already shows: hide that one (plugin sections render later, hence the observer).
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
const main = mainRef.current;
if (!main || !title) return;
const mark = () => {
const first = main.querySelector("h1, h2, h3");
if (first && !first.hasAttribute("data-dup-title") && first.textContent?.trim() === title) {
first.setAttribute("data-dup-title", "");
}
};
mark();
const observer = new MutationObserver(mark);
observer.observe(main, { childList: true, subtree: true });
return () => observer.disconnect();
}, [title, key]);
const back = () => {
// Opened from the list: step back in history, so the phone's Back button
// and this one agree. Opened directly (a link, a reload): go to the list.
if ((location.state as { fromList?: boolean } | null)?.fromList) navigate(-1);
else navigate(`/settings${fromQuery}`, { replace: true });
};
return (
<div className="relative h-[100dvh] overflow-hidden bg-app-bg text-app-fg">
<div className="flex h-full flex-col pt-[env(safe-area-inset-top)]">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-app-border px-2">
<Link to={from} className="flex items-center rounded-lg px-2 py-2 text-app-accent" aria-label={t("nav.back")}>
<Chevron left />
<span className="ml-0.5">{t("nav.back")}</span>
</Link>
<h1 className="text-lg font-semibold">{t("settings.title")}</h1>
</header>
<nav className="flex-1 overflow-y-auto pb-[env(safe-area-inset-bottom)]">
{entries.map((e) => (
<Link
key={e.key}
to={`/settings/${e.key}${fromQuery}`}
state={{ fromList: true }}
className="flex items-center justify-between border-b border-app-border px-5 py-4 text-base active:bg-app-hover"
>
{e.label}
<span className="text-app-faint">
<Chevron />
</span>
</Link>
))}
</nav>
</div>
<div
className={`absolute inset-0 flex flex-col bg-app-bg pt-[env(safe-area-inset-top)] transition-transform duration-300 ease-out ${
open ? "translate-x-0" : "translate-x-full"
} ${key ? "shadow-2xl" : ""}`}
onTransitionEnd={() => !open && setShown(null)}
aria-hidden={!open}
>
{key && (
<>
<header className="flex h-14 shrink-0 items-center gap-1 border-b border-app-border px-2">
<button
onClick={back}
className="flex items-center rounded-lg px-2 py-2 text-app-accent"
aria-label={t("settings.title")}
>
<Chevron left />
<span className="ml-0.5">{t("settings.title")}</span>
</button>
<h1 className="min-w-0 flex-1 truncate pr-3 text-center text-lg font-semibold">{title}</h1>
{/* Balances the back button, so the title sits in the middle. */}
<span className="invisible flex items-center px-2" aria-hidden>
<Chevron left />
<span className="ml-0.5">{t("settings.title")}</span>
</span>
</header>
<main
ref={mainRef}
className="flex-1 overflow-y-auto overflow-x-hidden p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] [&_[data-dup-title]]:hidden"
>
{open ? content : shown?.content}
</main>
</>
)}
</div>
</div>
);
}
function Chevron({ left = false }: { left?: boolean }) {
return (
<svg viewBox="0 0 24 24" width={20} height={20} fill="none" stroke="currentColor" strokeWidth={2} aria-hidden>
<path d={left ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"} strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function ProfileSection() {
const t = useT();
const { data } = useMe();
@@ -559,10 +694,11 @@ function AccountsSection() {
) : editing?.id === a.id && editing.mode === "signature" ? (
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
) : (
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
<span>{a.email}</span>
// Wraps on a narrow screen: the actions move below the address.
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
<span className="min-w-0 break-all">{a.email}</span>
{a.state === "auth_failed" ? (
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
{t("settings.stateAuthFailed")}
@@ -585,7 +721,7 @@ function AccountsSection() {
</>
)}
</span>
<span className="flex items-center gap-3">
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap">
{a.sync_window !== "all" && (
<button
className="text-xs text-app-accent disabled:opacity-50"