Files
kandev/apps/web/components/settings/notification-sound-section.tsx
T
2026-09-22 21:24:43 +01:00

123 lines
4.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { IconVolume } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Switch } from "@kandev/ui/switch";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import {
getSoundPreferences,
isSoundPresetId,
playSoundPreset,
setSoundPreferences,
SOUND_PRESETS,
type SoundPreferences,
type SoundPresetId,
} from "@/lib/notifications/sound";
import { useSettingsSaveContributor } from "./settings-save-provider";
import { SettingsGroup } from "./settings-group";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
/**
* Catalog keys for the preset names, keyed by the preset id — the id is the
* persisted sentinel and must never be translated. Keys, not `t()` calls: a
* module-scope `t` would freeze the names at the boot locale (docs/i18n.md).
*/
const SOUND_PRESET_LABEL_KEYS: Record<SoundPresetId, string> = {
plim: "settings:soundPresetPlim",
chime: "settings:soundPresetChime",
ding: "settings:soundPresetDing",
pop: "settings:soundPresetPop",
};
export function NotificationSoundSection({
onDirtyChange,
}: {
onDirtyChange?: (isDirty: boolean) => void;
}) {
const { t } = useTranslation();
const [saved, setSaved] = useState<SoundPreferences>(getSoundPreferences);
const [prefs, setPrefs] = useState<SoundPreferences>(saved);
const revision = JSON.stringify(prefs);
const isDirty = revision !== JSON.stringify(saved);
useEffect(() => onDirtyChange?.(isDirty), [isDirty, onDirtyChange]);
useSettingsSaveContributor({
id: "general-notification-sound",
revision,
isDirty,
save: () => {
const submitted = prefs;
setSoundPreferences(submitted);
setSaved(submitted);
},
discard: () => setPrefs(saved),
});
return (
<SettingsGroup
title={t("settings:notificationSound")}
description={t("settings:notificationSoundDescription")}
discoveryTargetId={GENERAL_SETTINGS_TARGETS.notificationSound}
isDirty={isDirty}
data-testid="notification-sound-group"
>
<div className="flex items-center justify-between gap-4">
<Switch
checked={prefs.enabled}
data-settings-dirty={prefs.enabled !== saved.enabled}
onCheckedChange={(enabled) => setPrefs({ ...prefs, enabled })}
aria-label={t("settings:enableNotificationSound")}
className="cursor-pointer"
/>
</div>
{prefs.enabled && (
<div className="flex items-center gap-2">
<Select
value={prefs.presetId}
onValueChange={(presetId) => {
if (!isSoundPresetId(presetId)) return;
setPrefs({ ...prefs, presetId });
playSoundPreset(presetId);
}}
>
<SelectTrigger
className="w-44 cursor-pointer"
aria-label={t("settings:notificationSoundSelect")}
data-settings-dirty={prefs.presetId !== saved.presetId}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SOUND_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id} className="cursor-pointer">
{t(SOUND_PRESET_LABEL_KEYS[preset.id])}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="cursor-pointer"
aria-label={t("settings:previewSound")}
onClick={() => playSoundPreset(prefs.presetId)}
>
<IconVolume className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings:previewSound")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</SettingsGroup>
);
}