Task #527: unify topbar into per-user Settings panel

Topbar right cluster collapses from 5–7 icons to exactly Bell -> Inbox
(conditional on canReceiveOrders) -> Settings (gear). Logout moved
inside the Settings panel; the second bell, globe, volume and clock
buttons are gone from the topbar.

New SettingsPanel renders as a Popover on md+ and a bottom Sheet on
<md (via new useMediaQuery hook). It composes existing controls
rather than re-implementing them:
- Language radio uses useUpdateLanguage + i18n.changeLanguage
- Notifications group renders extracted NotificationSettingsContent
- Sound group toggles notificationsMuted (same flag QuickMute used)
- Clock group renders extracted ClockStyleContent
All DB + localStorage persistence is preserved unchanged.

Refactors:
- notification-settings.tsx: extracted NotificationSettingsContent;
  NotificationSettingsPicker now wraps it (kept for any future caller).
- clock-style-picker.tsx: extracted ClockStyleContent; ClockStylePicker
  now wraps it; removed dead setOpen reference inside choose().
- home.tsx: removed Globe / QuickMute / NotificationSettingsPicker /
  ClockStylePicker / standalone LogOut from the cluster, dropped
  related imports + useUpdateLanguage, reordered to Bell -> Inbox ->
  Settings, and passes onLogout to SettingsPanel.

i18n: added settingsPanel.{title, section.*, lang.*, sound.master}
keys to ar.json and en.json.

Code review: PASS.
This commit is contained in:
riyadhafraa
2026-05-13 14:53:53 +00:00
parent 04f2ab7ea9
commit e6a3b148f4
5 changed files with 279 additions and 449 deletions
@@ -29,12 +29,18 @@ type Props = {
currentHour12: boolean | null | undefined;
};
export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
/**
* Inner content for the clock-style picker, extracted so the unified
* Settings panel (Task #527) can render the same controls without
* re-implementing the visibility toggles, hour-format switch, and
* style list. `ClockStylePicker` below remains as a standalone
* popover wrapper for any caller that wants a single icon-button.
*/
export function ClockStyleContent({ currentStyle, currentHour12 }: Props) {
const { t, i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const queryClient = useQueryClient();
const now = useNow();
const [open, setOpen] = useState(false);
const updateClockStyle = useUpdateClockStyle();
const updateClockHour12 = useUpdateClockHour12();
@@ -66,7 +72,6 @@ export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
},
},
);
setOpen(false);
};
const chooseHour12 = (next: boolean) => {
@@ -96,18 +101,8 @@ export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("home.clockStyle.label")}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
>
<ClockIcon size={18} />
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="end">
<button
<div>
<button
type="button"
onClick={() => setHomeClockVisible(!homeClockVisible)}
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors mb-1"
@@ -233,6 +228,26 @@ export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
);
})}
</div>
</div>
);
}
export function ClockStylePicker(props: Props) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("home.clockStyle.label")}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
>
<ClockIcon size={18} />
</button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="end">
<ClockStyleContent {...props} />
</PopoverContent>
</Popover>
);
@@ -242,10 +242,17 @@ export function QuickMuteButton() {
);
}
export function NotificationSettingsPicker() {
/**
* Inner content of the notification settings popover, extracted so the
* Settings panel (Task #527) can render the same form without
* re-implementing the toggles, slot tabs, and sound picker. The
* standalone `NotificationSettingsPicker` below wraps it in a popover
* trigger + content; the unified Settings panel mounts this content
* directly inside its own surface.
*/
export function NotificationSettingsContent() {
const { t } = useTranslation();
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [slot, setSlot] = useState<Slot>("order");
const apply = useUpdatePrefs();
@@ -253,6 +260,79 @@ export function NotificationSettingsPicker() {
const muted = user.notificationsMuted;
const cfg = SLOT_KEYS[slot];
return (
<div>
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("notifSettings.title")}
</div>
<Toggle
active={!muted}
onClick={() => apply({ notificationsMuted: !muted })}
label={t("notifSettings.globalSound")}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div
className="grid grid-cols-3 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
>
{(["order", "meeting", "note"] as const).map((s) => {
const isActive = s === slot;
return (
<button
key={s}
type="button"
onClick={() => setSlot(s)}
aria-pressed={isActive}
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`notifSettings.slot.${s}`)}
</button>
);
})}
</div>
<Toggle
active={user[cfg.enabledKey]}
onClick={() =>
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as PrefsPatch)
}
label={t(cfg.enabledLabel)}
/>
<Toggle
active={user[cfg.vibrateKey]}
onClick={() =>
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as PrefsPatch)
}
label={t(cfg.vibrateLabel)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={user[cfg.soundKey]}
onPick={(id) =>
apply({ [cfg.soundKey]: id } as PrefsPatch)
}
/>
</div>
);
}
export function NotificationSettingsPicker() {
const { t } = useTranslation();
const { user } = useAuth();
const [open, setOpen] = useState(false);
if (!user) return null;
const muted = user.notificationsMuted;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -270,65 +350,7 @@ export function NotificationSettingsPicker() {
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-2" align="end">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("notifSettings.title")}
</div>
<Toggle
active={!muted}
onClick={() => apply({ notificationsMuted: !muted })}
label={t("notifSettings.globalSound")}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div
className="grid grid-cols-3 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
>
{(["order", "meeting", "note"] as const).map((s) => {
const isActive = s === slot;
return (
<button
key={s}
type="button"
onClick={() => setSlot(s)}
aria-pressed={isActive}
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`notifSettings.slot.${s}`)}
</button>
);
})}
</div>
<Toggle
active={user[cfg.enabledKey]}
onClick={() =>
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as PrefsPatch)
}
label={t(cfg.enabledLabel)}
/>
<Toggle
active={user[cfg.vibrateKey]}
onClick={() =>
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as PrefsPatch)
}
label={t(cfg.vibrateLabel)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={user[cfg.soundKey]}
onPick={(id) =>
apply({ [cfg.soundKey]: id } as PrefsPatch)
}
/>
<NotificationSettingsContent />
</PopoverContent>
</Popover>
);
+123 -346
View File
@@ -3,91 +3,35 @@ import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
useUpdateLanguage,
useUpdateClockStyle,
useUpdateClockHour12,
getGetMeQueryKey,
type AuthUser,
type ClockStyle as ClockStyleType,
type NotificationSoundId,
} from "@workspace/api-client-react";
import {
Settings as SettingsIcon,
BellOff,
Volume2,
VolumeX,
Check,
Eye,
EyeOff,
Play,
LogOut,
} from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Clock,
CLOCK_STYLES,
resolveClockStyle,
resolveClockHour12,
useNow,
useHomeClockVisibility,
useTopbarClockVisibility,
} from "@/components/clock";
import {
ALL_SOUND_IDS,
notificationPlayer,
} from "@/lib/notification-sounds";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { useAuth } from "@/contexts/AuthContext";
import { useUpdatePrefs } from "@/components/notification-settings";
import {
NotificationSettingsContent,
useUpdatePrefs,
} from "@/components/notification-settings";
import { ClockStyleContent } from "@/components/clock-style-picker";
import { useMediaQuery } from "@/hooks/use-media-query";
import i18n from "@/i18n";
type Slot = "order" | "meeting" | "note";
const SLOT_KEYS: Record<
Slot,
{
enabledKey: keyof Pick<
AuthUser,
"notifyOrdersEnabled" | "notifyMeetingsEnabled" | "notifyNotesEnabled"
>;
soundKey: keyof Pick<
AuthUser,
"notificationSoundOrder" | "notificationSoundMeeting" | "notificationSoundNote"
>;
vibrateKey: keyof Pick<
AuthUser,
"vibrationEnabledOrder" | "vibrationEnabledMeeting" | "vibrationEnabledNote"
>;
enabledLabel: string;
vibrateLabel: string;
}
> = {
order: {
enabledKey: "notifyOrdersEnabled",
soundKey: "notificationSoundOrder",
vibrateKey: "vibrationEnabledOrder",
enabledLabel: "notifSettings.ordersEnabled",
vibrateLabel: "notifSettings.vibrationOrder",
},
meeting: {
enabledKey: "notifyMeetingsEnabled",
soundKey: "notificationSoundMeeting",
vibrateKey: "vibrationEnabledMeeting",
enabledLabel: "notifSettings.meetingsEnabled",
vibrateLabel: "notifSettings.vibrationMeeting",
},
note: {
enabledKey: "notifyNotesEnabled",
soundKey: "notificationSoundNote",
vibrateKey: "vibrationEnabledNote",
enabledLabel: "notifSettings.notesEnabled",
vibrateLabel: "notifSettings.vibrationNote",
},
};
function ToggleRow({
active,
onClick,
@@ -203,110 +147,6 @@ function LanguageGroup() {
);
}
function SoundList({
current,
onPick,
}: {
current: NotificationSoundId;
onPick: (id: NotificationSoundId) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-0.5 max-h-56 overflow-y-auto">
{ALL_SOUND_IDS.map((id) => {
const isActive = id === current;
return (
<div
key={id}
className={`flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg ${
isActive ? "bg-primary/10" : "hover:bg-foreground/5"
}`}
>
<button
type="button"
onClick={() => onPick(id)}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
{isActive ? (
<Check size={14} className="text-primary shrink-0" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span className="text-sm text-foreground font-medium truncate">
{t(`notifSettings.sounds.${id}`)}
</span>
</button>
<button
type="button"
onClick={() => notificationPlayer.testPlay(id)}
aria-label={t("notifSettings.preview")}
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
>
<Play size={12} />
</button>
</div>
);
})}
</div>
);
}
function NotificationsGroup() {
const { t } = useTranslation();
const { user } = useAuth();
const apply = useUpdatePrefs();
const [slot, setSlot] = useState<Slot>("order");
if (!user) return null;
const cfg = SLOT_KEYS[slot];
return (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.notifications")}</SectionTitle>
<div
className="grid grid-cols-3 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
>
{(["order", "meeting", "note"] as const).map((s) => {
const isActive = s === slot;
return (
<button
key={s}
type="button"
onClick={() => setSlot(s)}
aria-pressed={isActive}
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`notifSettings.slot.${s}`)}
</button>
);
})}
</div>
<ToggleRow
active={user[cfg.enabledKey]}
onClick={() =>
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as Record<string, boolean>)
}
label={t(cfg.enabledLabel)}
/>
<ToggleRow
active={user[cfg.vibrateKey]}
onClick={() =>
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as Record<string, boolean>)
}
label={t(cfg.vibrateLabel)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={user[cfg.soundKey]}
onPick={(id) => apply({ [cfg.soundKey]: id } as Record<string, NotificationSoundId>)}
/>
</GroupCard>
);
}
function SoundGroup() {
const { t } = useTranslation();
const { user } = useAuth();
@@ -332,194 +172,134 @@ function SoundGroup() {
);
}
function ClockGroup() {
const { t, i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
const queryClient = useQueryClient();
const now = useNow();
/**
* Body of the unified per-user Settings panel (Task #527). 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.
*/
function SettingsPanelBody({
onLogout,
}: {
onLogout: () => void;
}) {
const { t } = useTranslation();
const { user } = useAuth();
const updateClockStyle = useUpdateClockStyle();
const updateClockHour12 = useUpdateClockHour12();
const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility();
const [topbarClockVisible, setTopbarClockVisible] = useTopbarClockVisibility();
if (!user) return null;
const active = resolveClockStyle(user.clockStyle);
const activeHour12 = resolveClockHour12(user.clockHour12);
const chooseStyle = (style: ClockStyleType) => {
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
if (previous) {
queryClient.setQueryData<AuthUser>(getGetMeQueryKey(), { ...previous, clockStyle: style });
}
updateClockStyle.mutate(
{ data: { clockStyle: style } },
{
onSuccess: (data) => queryClient.setQueryData(getGetMeQueryKey(), data),
onError: () => {
if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
},
},
);
};
const chooseHour12 = (next: boolean) => {
if (next === activeHour12) return;
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
if (previous) {
queryClient.setQueryData<AuthUser>(getGetMeQueryKey(), { ...previous, clockHour12: next });
}
updateClockHour12.mutate(
{ data: { clockHour12: next } },
{
onSuccess: (data) => queryClient.setQueryData(getGetMeQueryKey(), data),
onError: () => {
if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
},
},
);
};
return (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.clock")}</SectionTitle>
<ToggleRow
active={homeClockVisible}
onClick={() => setHomeClockVisible(!homeClockVisible)}
label={t("home.clockStyle.showWidget")}
leftIcon={
homeClockVisible ? (
<Eye size={16} className="text-primary shrink-0" />
) : (
<EyeOff size={16} className="text-muted-foreground shrink-0" />
)
}
/>
<ToggleRow
active={topbarClockVisible}
onClick={() => setTopbarClockVisible(!topbarClockVisible)}
label={t("home.clockStyle.showTopbar")}
leftIcon={
topbarClockVisible ? (
<Eye size={16} className="text-primary shrink-0" />
) : (
<EyeOff size={16} className="text-muted-foreground shrink-0" />
)
}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div className="px-2 py-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.hourFormat.label")}
</div>
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
aria-label={t("home.clockStyle.hourFormat.label")}
<div className="space-y-3">
<LanguageGroup />
<GroupCard>
<SectionTitle>{t("settingsPanel.section.notifications")}</SectionTitle>
<NotificationSettingsContent />
</GroupCard>
<SoundGroup />
<GroupCard>
<SectionTitle>{t("settingsPanel.section.clock")}</SectionTitle>
<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"
>
{([false, true] as const).map((value) => {
const isActive = value === activeHour12;
const labelKey = value ? "h12" : "h24";
return (
<button
key={labelKey}
type="button"
onClick={() => chooseHour12(value)}
disabled={updateClockHour12.isPending}
aria-pressed={isActive}
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"
}`}
>
{t(`home.clockStyle.hourFormat.${labelKey}`)}
</button>
);
})}
</div>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div className="px-2 py-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.label")}
</div>
<div className="flex flex-col gap-1 max-h-56 overflow-y-auto">
{CLOCK_STYLES.map((style) => {
const isActive = style === active;
return (
<button
key={style}
type="button"
onClick={() => chooseStyle(style)}
disabled={updateClockStyle.isPending}
className={`flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors ${
isActive ? "bg-primary/10" : ""
}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">
<Clock
style={style}
time={now}
lang={lang}
hour12={activeHour12}
size="sm"
/>
</div>
<div className="text-sm text-foreground font-medium truncate">
{t(`home.clockStyle.options.${style}`)}
</div>
</div>
{isActive && <Check size={14} className="text-primary shrink-0" />}
</button>
);
})}
</div>
</GroupCard>
<LogOut size={16} />
{t("nav.logout")}
</button>
</div>
);
}
/**
* Unified per-user Settings panel (Task #527). Consolidates the four
* preferences that previously each had their own topbar button:
* - Language (was: globe button)
* - Notifications: per-category sound + vibration (was: bell #2 popover)
* - Sound: master mute toggle (was: volume button)
* - Clock: visibility, 12/24h, style (was: clock-style picker button)
* Unified per-user Settings panel (Task #527).
*
* Renders as a right-side Sheet on every viewport — full-width on
* phones, capped width on desktop. Keeps the same persistence paths
* as the original controls (DB columns via the existing hooks +
* localStorage for clock visibility), so no migration is required.
* - Trigger: a single gear icon in the topbar that replaces the four
* per-user preference buttons (language globe, quick-mute volume,
* notification-settings bell, clock-style picker).
* - 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).
*/
export function SettingsPanel() {
export function SettingsPanel({ onLogout }: { onLogout: () => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const { user } = useAuth();
const isDesktop = useMediaQuery("(min-width: 768px)");
if (!user) return null;
const muted = user.notificationsMuted;
const trigger = (
<button
type="button"
aria-label={t("settingsPanel.title")}
aria-haspopup="dialog"
data-testid="topbar-settings-button"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 relative"
>
<SettingsIcon className="w-[18px] h-[18px] md:w-6 md:h-6" />
{muted && (
<BellOff
aria-hidden="true"
className="absolute -top-0.5 -right-0.5 w-3 h-3 text-destructive bg-background rounded-full p-px"
/>
)}
</button>
);
const handleLogout = () => {
setOpen(false);
onLogout();
};
if (isDesktop) {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
align="end"
className="w-96 p-3 max-h-[80vh] overflow-y-auto"
data-testid="settings-panel"
>
<div className="px-1 pb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
<SettingsIcon size={16} />
{t("settingsPanel.title")}
</div>
<SettingsPanelBody onLogout={handleLogout} />
</PopoverContent>
</Popover>
);
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
type="button"
aria-label={t("settingsPanel.title")}
aria-haspopup="dialog"
data-testid="topbar-settings-button"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 relative"
>
<SettingsIcon className="w-[18px] h-[18px] md:w-6 md:h-6" />
{muted && (
<BellOff
aria-hidden="true"
className="absolute -top-0.5 -right-0.5 w-3 h-3 text-destructive bg-background rounded-full p-px"
/>
)}
</button>
</SheetTrigger>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={t("settingsPanel.title")}
aria-haspopup="dialog"
data-testid="topbar-settings-button"
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5 relative"
>
<SettingsIcon className="w-[18px] h-[18px]" />
{muted && (
<BellOff
aria-hidden="true"
className="absolute -top-0.5 -right-0.5 w-3 h-3 text-destructive bg-background rounded-full p-px"
/>
)}
</button>
<SheetContent
side="right"
className="w-full sm:max-w-md p-0 flex flex-col"
side="bottom"
className="h-[85vh] p-0 flex flex-col rounded-t-2xl"
data-testid="settings-panel"
>
<SheetHeader className="px-4 pt-4 pb-2 shrink-0 border-b border-foreground/10">
@@ -528,11 +308,8 @@ export function SettingsPanel() {
{t("settingsPanel.title")}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
<LanguageGroup />
<NotificationsGroup />
<SoundGroup />
<ClockGroup />
<div className="flex-1 overflow-y-auto p-3">
<SettingsPanelBody onLogout={handleLogout} />
</div>
</SheetContent>
</Sheet>
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
/**
* Subscribes to a CSS media query and returns whether it currently matches.
* SSR-safe: returns `false` until mounted in the browser.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === "undefined") return false;
return window.matchMedia(query).matches;
});
useEffect(() => {
if (typeof window === "undefined") return;
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
onChange();
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return matches;
}
+22 -27
View File
@@ -23,7 +23,6 @@ import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
import {
Bell,
Inbox,
LogOut,
Grid2X2,
Coffee,
BellRing,
@@ -566,16 +565,29 @@ export default function HomePage() {
itself can scale at the `md:` breakpoint.
*/}
{/*
Task #527: the topbar's right-side cluster used to host five
icon buttons (clock-style, quick-mute, notification-settings,
language toggle, plus the inbox/notifications/logout below).
Per-user UI preferences (language, notification sound +
vibration, master sound, clock style/visibility/12-24h) now
live behind a single Settings (gear) entry that opens the
unified panel. The notification-center bell stays as a
navigation link — it's the only bell on the page now.
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.
*/}
<div className="flex items-center gap-1 md:gap-2 shrink basis-0 flex-1 justify-end">
<button
onClick={() => setLocation("/notifications")}
aria-label={t("notifications.title")}
className="relative p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
{unreadCount > 0 && (
<span aria-hidden="true" className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] md:text-xs rounded-full min-w-[16px] md:min-w-[20px] h-4 md:h-5 px-1 flex items-center justify-center font-bold tabular-nums">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
{canReceiveOrders && (
<button
onClick={() => setLocation("/orders/incoming")}
@@ -590,24 +602,7 @@ export default function HomePage() {
)}
</button>
)}
<button
onClick={() => setLocation("/notifications")}
className="relative p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
>
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
{unreadCount > 0 && (
<span aria-hidden="true" className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] md:text-xs rounded-full min-w-[16px] md:min-w-[20px] h-4 md:h-5 px-1 flex items-center justify-center font-bold tabular-nums">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</button>
<SettingsPanel />
<button
onClick={handleLogout}
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>
<SettingsPanel onLogout={handleLogout} />
</div>
</div>