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:
@@ -980,19 +980,22 @@ export function MailLayout() {
|
|||||||
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
|
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
|
||||||
{!collapsed && <PluginSlot name="sidebar.section" />}
|
{!collapsed && <PluginSlot name="sidebar.section" />}
|
||||||
</nav>
|
</nav>
|
||||||
|
{mobile && (
|
||||||
|
<div className="p-2 text-[15px]">
|
||||||
|
<InstallAppEntry onDone={() => setDrawerOpen(false)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t border-app-border p-2 text-[15px]">
|
<div className="border-t border-app-border p-2 text-[15px]">
|
||||||
{/* Settings has no phone layout yet either. */}
|
|
||||||
<Link
|
<Link
|
||||||
className={`flex items-center gap-2.5 rounded-lg py-2 text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
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"
|
collapsed ? "justify-center px-0" : "px-3"
|
||||||
} ${mobile ? "hidden" : ""}`}
|
}`}
|
||||||
title={collapsed ? t("nav.settings") : undefined}
|
title={collapsed ? t("nav.settings") : undefined}
|
||||||
to="/settings"
|
to="/settings"
|
||||||
>
|
>
|
||||||
<Icon name="settings" size={22} />
|
<Icon name="settings" size={22} />
|
||||||
{!collapsed && t("nav.settings")}
|
{!collapsed && t("nav.settings")}
|
||||||
</Link>
|
</Link>
|
||||||
{mobile && <InstallAppEntry onDone={() => setDrawerOpen(false)} />}
|
|
||||||
<button
|
<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 ${
|
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"
|
collapsed ? "justify-center px-0" : "px-3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
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 { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
import { RichTextEditor } from "../../components/compose/RichTextEditor";
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { prefetchRecent } from "../../lib/offlinePrefetch";
|
import { prefetchRecent } from "../../lib/offlinePrefetch";
|
||||||
import { disablePush, enablePush, usePushState } from "../../lib/push";
|
import { disablePush, enablePush, usePushState } from "../../lib/push";
|
||||||
import { useTheme } from "../../lib/theme";
|
import { useTheme } from "../../lib/theme";
|
||||||
|
import { useIsMobile } from "../../lib/useIsMobile";
|
||||||
import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat";
|
import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat";
|
||||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||||
import { usePluginSettingsSections } from "../../plugins/manifest";
|
import { usePluginSettingsSections } from "../../plugins/manifest";
|
||||||
@@ -48,13 +49,46 @@ export function Settings() {
|
|||||||
// plugin name, rather than being stacked at the bottom of Accounts.
|
// plugin name, rather than being stacked at the bottom of Accounts.
|
||||||
const pluginSections = usePluginSettingsSections();
|
const pluginSections = usePluginSettingsSections();
|
||||||
const isPluginSection = pluginSections.some((b) => b.plugin === params.section);
|
const isPluginSection = pluginSections.some((b) => b.plugin === params.section);
|
||||||
const section = (
|
// A section named in the URL. Without one, a phone shows the list of
|
||||||
addingAccount
|
// sections; the desktop layout shows Profile.
|
||||||
? "accounts"
|
const chosen = addingAccount || SECTIONS.includes(params.section as Section) || isPluginSection;
|
||||||
: SECTIONS.includes(params.section as Section) || isPluginSection
|
const section = (addingAccount ? "accounts" : chosen ? params.section : "profile") as Section;
|
||||||
? params.section
|
const mobile = useIsMobile();
|
||||||
: "profile"
|
|
||||||
) as Section;
|
const content = (
|
||||||
|
<>
|
||||||
|
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
||||||
|
{!addingAccount && section === "profile" && <ProfileSection />}
|
||||||
|
{!addingAccount && section === "security" && <SecuritySection />}
|
||||||
|
{!addingAccount && section === "accounts" && <AccountsSection />}
|
||||||
|
{section === "calendars" && <CalendarsSection />}
|
||||||
|
{section === "appearance" && <AppearanceSection />}
|
||||||
|
{section === "integrations" && <IntegrationsSection />}
|
||||||
|
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
|
||||||
|
per plugin, selected from the nav like any built-in section. */}
|
||||||
|
{isPluginSection && (
|
||||||
|
<div className="max-w-5xl">
|
||||||
|
<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 (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
@@ -66,52 +100,153 @@ export function Settings() {
|
|||||||
</header>
|
</header>
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<nav className="w-52 shrink-0 border-r border-app-border p-2">
|
<nav className="w-52 shrink-0 border-r border-app-border p-2">
|
||||||
{SECTIONS.map((s) => (
|
{entries.map((e) => (
|
||||||
<Link
|
<Link
|
||||||
key={s}
|
key={e.key}
|
||||||
to={`/settings/${s}${fromQuery}`}
|
to={`/settings/${e.key}${fromQuery}`}
|
||||||
className={`block rounded px-3 py-2 text-sm ${
|
className={`block rounded px-3 py-2 text-sm ${
|
||||||
section === s ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
|
section === e.key ? "bg-app-active font-medium text-app-fg" : "hover:bg-app-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`settings.${s}` as const)}
|
{e.label}
|
||||||
</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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
<main className="flex-1 overflow-y-auto p-6">{content}</main>
|
||||||
{addingAccount && <AccountWizard embedded onDone={() => navigate("/settings/accounts")} />}
|
|
||||||
{!addingAccount && section === "profile" && <ProfileSection />}
|
|
||||||
{!addingAccount && section === "security" && <SecuritySection />}
|
|
||||||
{!addingAccount && section === "accounts" && <AccountsSection />}
|
|
||||||
{section === "calendars" && <CalendarsSection />}
|
|
||||||
{section === "appearance" && <AppearanceSection />}
|
|
||||||
{section === "integrations" && <IntegrationsSection />}
|
|
||||||
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section
|
|
||||||
per plugin, selected from the nav like any built-in section. */}
|
|
||||||
{isPluginSection && (
|
|
||||||
<div className="max-w-5xl">
|
|
||||||
<PluginSlot name="settings.page" plugin={section} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</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() {
|
function ProfileSection() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { data } = useMe();
|
const { data } = useMe();
|
||||||
@@ -559,10 +694,11 @@ function AccountsSection() {
|
|||||||
) : editing?.id === a.id && editing.mode === "signature" ? (
|
) : editing?.id === a.id && editing.mode === "signature" ? (
|
||||||
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
|
<SignatureEditor accountId={a.id} onCancel={() => setEditing(null)} onSaved={close} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
// Wraps on a narrow screen: the actions move below the address.
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
|
||||||
<span className="h-3 w-3 rounded-full" style={{ backgroundColor: a.color }} />
|
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
<span>{a.email}</span>
|
<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" ? (
|
{a.state === "auth_failed" ? (
|
||||||
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
|
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
|
||||||
{t("settings.stateAuthFailed")}
|
{t("settings.stateAuthFailed")}
|
||||||
@@ -585,7 +721,7 @@ function AccountsSection() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</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" && (
|
{a.sync_window !== "all" && (
|
||||||
<button
|
<button
|
||||||
className="text-xs text-app-accent disabled:opacity-50"
|
className="text-xs text-app-accent disabled:opacity-50"
|
||||||
|
|||||||
Reference in New Issue
Block a user