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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user