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>