Task #529: Restore topbar logout + collapsible Settings groups

home.tsx
- Re-import LogOut from lucide-react.
- Append a single-tap Logout icon button after <SettingsPanel /> in
  the right-side cluster, wired to the existing handleLogout flow.
- Drop the onLogout prop from <SettingsPanel />; update the topbar
  comment block to describe the Bell -> Inbox -> Settings -> Logout
  cluster.

settings-panel.tsx
- Drop the LogOut import, the onLogout prop on SettingsPanel and
  SettingsPanelBody, the local handleLogout wrapper, and the inline
  logout button (data-testid="settings-logout").
- Wrap the four groups (language, notifications, sound, clock) in
  the existing shadcn Accordion (type="multiple") so each toggles
  independently. Default = all collapsed; open state is lifted to
  SettingsPanel via useState<GroupId[]> so toggles persist while the
  panel is closed and re-opened in the same session.
- New GroupItem helper styles each AccordionItem like the prior
  GroupCard so the panel keeps its rounded-card visual rhythm. The
  trigger uses text-start so headers align correctly in RTL.
- Existing form bodies (LanguageBody, NotificationSettingsContent,
  SoundBody, ClockStyleContent) are reused unchanged, preserving all
  DB + localStorage persistence.

Notes
- Logout placement and removal from the panel were both explicit user
  requests (clarified in plan #529 after #527 unified the topbar).
- No new i18n keys; existing settingsPanel.section.* labels reused
  as accordion triggers.
- typecheck clean (pre-existing TS6305/TS7006 noise unrelated).
This commit is contained in:
riyadhafraa
2026-05-13 15:15:31 +00:00
parent dd2d731d15
commit 60fbd3dd84
2 changed files with 147 additions and 109 deletions
+128 -99
View File
@@ -9,7 +9,6 @@ import {
Settings as SettingsIcon,
Volume2,
VolumeX,
LogOut,
} from "lucide-react";
import {
Sheet,
@@ -22,6 +21,12 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { useAuth } from "@/contexts/AuthContext";
import {
NotificationSettingsContent,
@@ -73,23 +78,7 @@ function ToggleRow({
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="px-2 pb-1.5 pt-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</div>
);
}
function GroupCard({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-xl border border-foreground/10 bg-background/40 p-2">
{children}
</div>
);
}
function LanguageGroup() {
function LanguageBody() {
const { t, i18n: i18nInstance } = useTranslation();
const queryClient = useQueryClient();
const updateLanguage = useUpdateLanguage();
@@ -114,123 +103,162 @@ function LanguageGroup() {
];
return (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.language")}</SectionTitle>
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="radiogroup"
aria-label={t("settingsPanel.section.language")}
>
{options.map((opt) => {
const isActive = opt.value === current;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => choose(opt.value)}
data-testid={`settings-lang-${opt.value}`}
className={`text-sm font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
);
})}
</div>
</GroupCard>
<div
className="grid grid-cols-2 gap-1 p-1 rounded-lg bg-foreground/5"
role="radiogroup"
aria-label={t("settingsPanel.section.language")}
>
{options.map((opt) => {
const isActive = opt.value === current;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => choose(opt.value)}
data-testid={`settings-lang-${opt.value}`}
className={`text-sm font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
);
})}
</div>
);
}
function SoundGroup() {
function SoundBody() {
const { t } = useTranslation();
const { user } = useAuth();
const apply = useUpdatePrefs();
if (!user) return null;
const muted = user.notificationsMuted;
return (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.sound")}</SectionTitle>
<ToggleRow
active={!muted}
onClick={() => apply({ notificationsMuted: !muted })}
label={t("settingsPanel.sound.master")}
leftIcon={
muted ? (
<VolumeX size={16} className="text-muted-foreground shrink-0" />
) : (
<Volume2 size={16} className="text-primary shrink-0" />
)
}
/>
</GroupCard>
<ToggleRow
active={!muted}
onClick={() => apply({ notificationsMuted: !muted })}
label={t("settingsPanel.sound.master")}
leftIcon={
muted ? (
<VolumeX size={16} className="text-muted-foreground shrink-0" />
) : (
<Volume2 size={16} className="text-primary shrink-0" />
)
}
/>
);
}
type GroupId = "language" | "notifications" | "sound" | "clock";
/**
* Section wrapper that styles each accordion item like the existing
* GroupCard so the panel keeps its rounded-card visual rhythm even
* when collapsed. The chevron is rendered by AccordionTrigger and
* rotates on open via the `[&[data-state=open]>svg]:rotate-180`
* utility already baked into the shadcn primitive.
*/
function GroupItem({
id,
title,
children,
}: {
id: GroupId;
title: string;
children: React.ReactNode;
}) {
return (
<AccordionItem
value={id}
className="rounded-xl border border-foreground/10 bg-background/40 overflow-hidden border-b"
>
<AccordionTrigger
data-testid={`settings-group-${id}`}
className="px-3 py-2.5 text-sm font-semibold text-foreground text-start hover:no-underline hover:bg-foreground/5"
>
{title}
</AccordionTrigger>
<AccordionContent className="px-2 pb-2 pt-0">
{children}
</AccordionContent>
</AccordionItem>
);
}
/**
* Body of the unified per-user Settings panel (Task #527). Composes
* the existing form components from `notification-settings.tsx` and
* Body of the unified per-user Settings panel. Composes the existing
* form components from `notification-settings.tsx` and
* `clock-style-picker.tsx` rather than re-implementing them, so all
* persistence + behaviour stays identical to the topbar buttons that
* used to expose them.
*
* Task #529: groups are collapsible. Open/closed state is lifted up
* to the SettingsPanel so it persists while the panel is closed and
* re-opened in the same session. Default = all collapsed (compact
* panel on first open).
*/
function SettingsPanelBody({
onLogout,
openGroups,
onOpenChange,
}: {
onLogout: () => void;
openGroups: GroupId[];
onOpenChange: (next: GroupId[]) => void;
}) {
const { t } = useTranslation();
const { user } = useAuth();
if (!user) return null;
return (
<div className="space-y-3">
<LanguageGroup />
<GroupCard>
<SectionTitle>{t("settingsPanel.section.notifications")}</SectionTitle>
<Accordion
type="multiple"
value={openGroups}
onValueChange={(v) => onOpenChange(v as GroupId[])}
className="space-y-2"
>
<GroupItem id="language" title={t("settingsPanel.section.language")}>
<LanguageBody />
</GroupItem>
<GroupItem id="notifications" title={t("settingsPanel.section.notifications")}>
<NotificationSettingsContent />
</GroupCard>
<SoundGroup />
<GroupCard>
<SectionTitle>{t("settingsPanel.section.clock")}</SectionTitle>
</GroupItem>
<GroupItem id="sound" title={t("settingsPanel.section.sound")}>
<SoundBody />
</GroupItem>
<GroupItem id="clock" title={t("settingsPanel.section.clock")}>
<ClockStyleContent
currentStyle={user.clockStyle ?? null}
currentHour12={user.clockHour12 ?? null}
/>
</GroupCard>
<button
type="button"
onClick={onLogout}
data-testid="settings-logout"
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl border border-destructive/30 text-destructive hover:bg-destructive/10 transition-colors text-sm font-medium"
>
<LogOut size={16} />
{t("nav.logout")}
</button>
</div>
</GroupItem>
</Accordion>
);
}
/**
* Unified per-user Settings panel (Task #527).
* Unified per-user Settings panel.
*
* - Trigger: a single gear icon in the topbar that replaces the four
* - Trigger: a single gear icon in the topbar that replaces the
* per-user preference buttons (language globe, quick-mute volume,
* notification-settings bell, clock-style picker).
* - Surface: an anchored Popover on `md:` (768px) viewports;
* - Surface: an anchored Popover on `md:` (>=768px) viewports;
* a bottom Sheet on smaller screens. Both render the same body so
* behaviour and accessibility stay consistent.
* - Body composes the existing form components rather than
* re-implementing them, preserving DB + localStorage persistence.
* - Logout lives inside the panel (kept off the topbar so the
* right-side cluster shows exactly Bell, Inbox, Settings).
* - Task #529: groups are collapsible (accordion); logout stays
* in the topbar so the gear panel only hosts preferences.
*/
export function SettingsPanel({ onLogout }: { onLogout: () => void }) {
export function SettingsPanel() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
// Default = all collapsed so the panel opens compact (Task #529).
// State is lifted here so toggles persist while the panel is
// dismissed and re-opened in the same session.
const [openGroups, setOpenGroups] = useState<GroupId[]>([]);
const { user } = useAuth();
const isDesktop = useMediaQuery("(min-width: 768px)");
if (!user) return null;
@@ -247,11 +275,6 @@ export function SettingsPanel({ onLogout }: { onLogout: () => void }) {
</button>
);
const handleLogout = () => {
setOpen(false);
onLogout();
};
if (isDesktop) {
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -265,7 +288,10 @@ export function SettingsPanel({ onLogout }: { onLogout: () => void }) {
<SettingsIcon size={16} />
{t("settingsPanel.title")}
</div>
<SettingsPanelBody onLogout={handleLogout} />
<SettingsPanelBody
openGroups={openGroups}
onOpenChange={setOpenGroups}
/>
</PopoverContent>
</Popover>
);
@@ -295,7 +321,10 @@ export function SettingsPanel({ onLogout }: { onLogout: () => void }) {
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-3">
<SettingsPanelBody onLogout={handleLogout} />
<SettingsPanelBody
openGroups={openGroups}
onOpenChange={setOpenGroups}
/>
</div>
</SheetContent>
</Sheet>
+19 -10
View File
@@ -23,6 +23,7 @@ import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
import {
Bell,
Inbox,
LogOut,
Grid2X2,
Coffee,
BellRing,
@@ -565,15 +566,16 @@ export default function HomePage() {
itself can scale at the `md:` breakpoint.
*/}
{/*
Task #527: the topbar's right-side cluster previously hosted
up to seven icon buttons (clock-style, quick-mute,
notification-settings, language toggle, inbox, notifications
bell, logout). Per-user UI preferences now live behind a
single Settings (gear) entry that opens the unified panel,
and logout has moved inside that panel. The cluster now
shows exactly Bell Inbox Settings (Inbox is conditional
on the user being able to receive incoming orders). The
notification-center bell is the only bell on the page.
Task #527 + #529: per-user UI preferences (language,
notification sound + vibration, master sound, clock
style/visibility/12-24h) live behind a single Settings
(gear) entry that opens the unified panel. Logout stays
on the topbar as a single-tap action (Task #529 reverted
the brief move into the panel). The cluster now shows
exactly Bell -> Inbox -> Settings -> Logout (Inbox is
conditional on the user being able to receive incoming
orders). The notification-center bell is the only bell on
the page.
*/}
<div className="flex items-center gap-1 md:gap-2 shrink basis-0 flex-1 justify-end">
<button
@@ -602,7 +604,14 @@ export default function HomePage() {
)}
</button>
)}
<SettingsPanel onLogout={handleLogout} />
<SettingsPanel />
<button
onClick={handleLogout}
aria-label={t("nav.logout")}
className="p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<LogOut className="w-[18px] h-[18px] md:w-6 md:h-6" />
</button>
</div>
</div>