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:
npeter83 2026-06-11 20:45:48 +02:00
parent 1630a3d8c9
commit b46c8300d1
11 changed files with 1063 additions and 45 deletions

View file

@ -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;
}