feat(m5a): channel manager, tabbed settings panel, per-user sync status
Backend: /api/channels (list + PATCH priority/hidden + attach/detach user tags), user-tag CRUD on /api/tags, /api/sync/my-status (per-user channel sync counts). Frontend: feed|channels page nav (URL-synced) from the account menu; a slide-in tabbed Settings panel (Appearance, Notifications=6b sound+duration, Sync status + actions + admin pause/resume, Account); a channel manager with priority, hide, per-channel user tags, sync badges and 'view in feed'. Notifications now honor the configurable sound + auto-dismiss settings.
This commit is contained in:
parent
b8a8605797
commit
a8822d3935
11 changed files with 1063 additions and 45 deletions
|
|
@ -44,10 +44,58 @@ export interface NotifyInput {
|
|||
}
|
||||
|
||||
const HISTORY_KEY = "subfeed.notifications";
|
||||
const SETTINGS_KEY = "subfeed.notifSettings";
|
||||
const MAX_HISTORY = 100;
|
||||
const DEFAULT_TTL = 6000;
|
||||
const ERROR_TTL = 15000;
|
||||
|
||||
// 6b: per-account notification settings (persisted by the Settings panel into the
|
||||
// server `preferences`, mirrored to localStorage so they apply before login).
|
||||
export interface NotifSettings {
|
||||
sound: boolean; // play a short tone on attention-needing notifications
|
||||
durationMs: number; // auto-dismiss time for info/success toasts
|
||||
}
|
||||
const DEFAULT_SETTINGS: NotifSettings = { sound: false, durationMs: 6000 };
|
||||
let config: NotifSettings = loadSettings();
|
||||
|
||||
function loadSettings(): NotifSettings {
|
||||
try {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}") };
|
||||
} catch {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNotifSettings(): NotifSettings {
|
||||
return config;
|
||||
}
|
||||
|
||||
export function configureNotifications(p: Partial<NotifSettings>): void {
|
||||
config = { ...config, ...p };
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
function beep(): void {
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
audioCtx = audioCtx || new Ctx();
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.frequency.value = 660;
|
||||
gain.gain.value = 0.04;
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.12);
|
||||
} catch {
|
||||
/* autoplay blocked or unsupported — ignore */
|
||||
}
|
||||
}
|
||||
|
||||
let items: Notification[] = load();
|
||||
let listeners: Array<() => void> = [];
|
||||
let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1;
|
||||
|
|
@ -107,7 +155,7 @@ export function notify(input: NotifyInput): number {
|
|||
? undefined
|
||||
: level === "error" || level === "fatal"
|
||||
? ERROR_TTL
|
||||
: DEFAULT_TTL;
|
||||
: config.durationMs;
|
||||
items = [
|
||||
...items,
|
||||
{
|
||||
|
|
@ -125,6 +173,9 @@ export function notify(input: NotifyInput): number {
|
|||
},
|
||||
];
|
||||
emit();
|
||||
if (config.sound && (requiresInteraction || level === "error" || level === "fatal")) {
|
||||
beep();
|
||||
}
|
||||
if (duration) setTimeout(() => dismiss(id), duration);
|
||||
return id;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue