Feat: pushover notif
This commit is contained in:
23
README.md
23
README.md
@@ -499,6 +499,29 @@ Régler le délai à `0` clôt dès la première lecture hors-ligne ; décocher
|
||||
l'enregistrement quand le flux passe hors-ligne » désactive les deux temps, y compris la
|
||||
pause.
|
||||
|
||||
## Notifications Pushover
|
||||
|
||||
Onglet **Notifications** du dashboard. Complète — sans le remplacer — le mécanisme déjà en
|
||||
place (notification navigateur sur l'onglet Streamers, cloche 🔔/🔕 par profil) : celui-ci
|
||||
exige un onglet du dashboard ouvert et l'autorisation du navigateur ; Pushover atteint
|
||||
l'opérateur même dashboard fermé, sur son téléphone.
|
||||
|
||||
Réglage global, pas par agent : crée une application sur
|
||||
[pushover.net/apps/build](https://pushover.net/apps/build) (une seule suffit pour tous les
|
||||
usages de Stream Control), renseigne son jeton et ta clé utilisateur, coche « Activer », puis
|
||||
« Envoyer un test » avant d'enregistrer — une notification de test part directement sur les
|
||||
identifiants du formulaire, sans attendre un vrai passage en direct pour découvrir une faute
|
||||
de frappe.
|
||||
|
||||
Déclenché sur le même évènement que la notification navigateur : un profil dont `notify` est
|
||||
coché (la cloche 🔔) passant à l'état public. Un échec d'envoi (identifiants invalides,
|
||||
Pushover injoignable) finit dans le journal, jamais en erreur qui interromprait la veille —
|
||||
c'est un canal secondaire, pas une condition de son fonctionnement.
|
||||
|
||||
Les identifiants ne repartent jamais en clair vers le navigateur : `GET /api/settings/pushover`
|
||||
les renvoie masqués (`********`), comme le mot de passe obs-websocket ailleurs dans
|
||||
l'interface. Les renvoyer tels quels au `PUT` laisse la valeur enregistrée inchangée.
|
||||
|
||||
## Enregistrement automatique
|
||||
|
||||
Sur la fiche d'un streamer (onglet Streamers), assigne un agent puis coche
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DEFAULT_OBS_SETTINGS,
|
||||
isAgentAction,
|
||||
normalizeBrowserSettings,
|
||||
normalizePushoverSettings,
|
||||
normalizeRecordingSettings,
|
||||
normalizeWatchSettings,
|
||||
parseStripchatUsername,
|
||||
@@ -14,9 +15,12 @@ import { config } from './config.ts';
|
||||
import { generateToken, hashToken, issueSession, requireSession, safeEqual } from './auth.ts';
|
||||
import { agentsRepo, logsRepo, targetsRepo } from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
import { getPushoverSettings, savePushoverSettings, sendPushover } from './pushover.ts';
|
||||
import { startTargetRecording } from './recorder.ts';
|
||||
import { watchlist } from './watchlist.ts';
|
||||
|
||||
const PUSHOVER_MASK = '********';
|
||||
|
||||
export const api: Router = Router();
|
||||
|
||||
// --- Authentification -------------------------------------------------------
|
||||
@@ -386,6 +390,63 @@ api.get('/logs', (req, res) => {
|
||||
res.json({ logs: logsRepo.recent(limit) });
|
||||
});
|
||||
|
||||
// --- Notifications Pushover --------------------------------------------------
|
||||
|
||||
api.get('/settings/pushover', (_req, res) => {
|
||||
const settings = getPushoverSettings();
|
||||
res.json({
|
||||
settings: {
|
||||
enabled: settings.enabled,
|
||||
// Jamais renvoyés en clair : un accès à l'écran du dashboard ne doit pas
|
||||
// suffire à voler des identifiants qui permettent d'envoyer en son nom.
|
||||
userKey: settings.userKey ? PUSHOVER_MASK : '',
|
||||
appToken: settings.appToken ? PUSHOVER_MASK : '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
api.put('/settings/pushover', (req, res) => {
|
||||
const current = getPushoverSettings();
|
||||
const input = normalizePushoverSettings({
|
||||
enabled: req.body?.enabled,
|
||||
// Revenu masqué depuis le formulaire : la valeur enregistrée ne change pas.
|
||||
userKey: req.body?.userKey === PUSHOVER_MASK ? current.userKey : req.body?.userKey,
|
||||
appToken: req.body?.appToken === PUSHOVER_MASK ? current.appToken : req.body?.appToken,
|
||||
});
|
||||
|
||||
savePushoverSettings(input);
|
||||
hub.log(null, 'info', 'Réglages Pushover modifiés depuis le dashboard');
|
||||
res.json({
|
||||
settings: {
|
||||
enabled: input.enabled,
|
||||
userKey: input.userKey ? PUSHOVER_MASK : '',
|
||||
appToken: input.appToken ? PUSHOVER_MASK : '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Notification d'essai : accepte des identifiants fournis dans la requête pour
|
||||
* pouvoir tester avant d'enregistrer, sans quoi une faute de frappe ne se
|
||||
* découvrirait qu'au premier vrai passage en direct.
|
||||
*/
|
||||
api.post('/settings/pushover/test', async (req, res) => {
|
||||
const current = getPushoverSettings();
|
||||
const settings = normalizePushoverSettings({
|
||||
enabled: true,
|
||||
userKey: req.body?.userKey === PUSHOVER_MASK ? current.userKey : (req.body?.userKey ?? current.userKey),
|
||||
appToken:
|
||||
req.body?.appToken === PUSHOVER_MASK ? current.appToken : (req.body?.appToken ?? current.appToken),
|
||||
});
|
||||
|
||||
const result = await sendPushover(
|
||||
{ title: 'Stream Control', message: 'Notification de test — tout fonctionne.' },
|
||||
settings,
|
||||
);
|
||||
if (result.ok) res.json({ ok: true });
|
||||
else res.status(502).json({ ok: false, error: result.error });
|
||||
});
|
||||
|
||||
/** Infos nécessaires pour configurer un nouvel agent. */
|
||||
api.get('/enrollment', (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -80,6 +80,14 @@ db.exec(`
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_targets_identity
|
||||
ON watch_targets (provider, username);
|
||||
|
||||
-- Réglages globaux, hors agents et profils : une ligne par clé, en JSON.
|
||||
-- Pensé pour grandir (un jour d'autres canaux que Pushover) sans nouvelle
|
||||
-- table à chaque fois.
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
/** Migrations additives : sûres à rejouer sur une base déjà peuplée. */
|
||||
@@ -621,3 +629,24 @@ export const logsRepo = {
|
||||
return rows.map(toLogEntry).reverse();
|
||||
},
|
||||
};
|
||||
|
||||
const settingsStmts = {
|
||||
get: db.prepare('SELECT value FROM settings WHERE key = ?'),
|
||||
set: db.prepare(`
|
||||
INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT (key) DO UPDATE SET value = excluded.value
|
||||
`),
|
||||
};
|
||||
|
||||
/** Réglages globaux en JSON, une clé = un bloc de configuration. */
|
||||
export const settingsRepo = {
|
||||
get<T>(key: string, fallback: T): T {
|
||||
const row = settingsStmts.get.get(key) as unknown as { value: string } | undefined;
|
||||
if (!row) return fallback;
|
||||
return safeJsonParse<T>(row.value) ?? fallback;
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T): void {
|
||||
settingsStmts.set.run(key, JSON.stringify(value));
|
||||
},
|
||||
};
|
||||
|
||||
90
packages/server/src/pushover.ts
Normal file
90
packages/server/src/pushover.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { PushoverSettings } from '@stream-control/shared';
|
||||
import { settingsRepo } from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
|
||||
const SETTINGS_KEY = 'pushover';
|
||||
const API_URL = 'https://api.pushover.net/1/messages.json';
|
||||
|
||||
export function getPushoverSettings(): PushoverSettings {
|
||||
return settingsRepo.get(SETTINGS_KEY, { enabled: false, userKey: '', appToken: '' });
|
||||
}
|
||||
|
||||
export function savePushoverSettings(settings: PushoverSettings): void {
|
||||
settingsRepo.set(SETTINGS_KEY, settings);
|
||||
}
|
||||
|
||||
export interface PushoverMessage {
|
||||
title: string;
|
||||
message: string;
|
||||
/** Lien ouvert depuis la notification (l'app Pushover l'affiche en bouton). */
|
||||
url?: string;
|
||||
urlTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification, avec les réglages fournis ou ceux enregistrés.
|
||||
*
|
||||
* L'API Pushover attend un corps `application/x-www-form-urlencoded`, pas du
|
||||
* JSON — une erreur facile à faire en copiant le reste de ce fichier, où tout
|
||||
* le reste parle JSON.
|
||||
*
|
||||
* N'échoue jamais bruyamment vers l'appelant : un envoi raté finit dans le
|
||||
* journal, pas en exception qui remonterait jusqu'à casser l'évènement qui
|
||||
* en est à l'origine (un streamer passé en direct, par exemple).
|
||||
*/
|
||||
export async function sendPushover(
|
||||
content: PushoverMessage,
|
||||
settings: PushoverSettings = getPushoverSettings(),
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
if (!settings.enabled) return { ok: false, error: 'Pushover désactivé' };
|
||||
if (!settings.userKey || !settings.appToken) {
|
||||
return { ok: false, error: 'Clé utilisateur ou jeton d\'application manquant' };
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
token: settings.appToken,
|
||||
user: settings.userKey,
|
||||
title: content.title,
|
||||
message: content.message,
|
||||
});
|
||||
if (content.url) body.set('url', content.url);
|
||||
if (content.urlTitle) body.set('url_title', content.urlTitle);
|
||||
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (response.ok) return { ok: true };
|
||||
|
||||
// Pushover répond en JSON même sur erreur, avec le détail dans `errors`.
|
||||
const payload = (await response.json().catch(() => null)) as { errors?: string[] } | null;
|
||||
return { ok: false, error: payload?.errors?.join(', ') ?? `HTTP ${response.status}` };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifie le passage en direct d'un profil surveillé.
|
||||
*
|
||||
* Complète — sans le remplacer — la notification navigateur déjà en place :
|
||||
* celle-ci exige un onglet du dashboard ouvert, Pushover atteint l'opérateur
|
||||
* même absent. Un échec est journalisé mais n'empêche rien d'autre : c'est un
|
||||
* canal secondaire, pas une condition du fonctionnement de la veille.
|
||||
*/
|
||||
export async function notifyTargetLive(name: string, url: string): Promise<void> {
|
||||
const settings = getPushoverSettings();
|
||||
if (!settings.enabled) return;
|
||||
|
||||
const result = await sendPushover(
|
||||
{ title: 'Stream Control', message: `${name} est en direct`, url, urlTitle: 'Ouvrir' },
|
||||
settings,
|
||||
);
|
||||
if (!result.ok) {
|
||||
hub.log(null, 'warn', `Notification Pushover (${name} en direct) non envoyée : ${result.error}`);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_WATCH_SETTINGS, fetchStripchatStatus } from '@stream-control/sh
|
||||
import { config } from './config.ts';
|
||||
import { targetsRepo } from './db.ts';
|
||||
import { hub } from './hub.ts';
|
||||
import { notifyTargetLive } from './pushover.ts';
|
||||
import { startTargetRecording } from './recorder.ts';
|
||||
|
||||
/**
|
||||
@@ -122,6 +123,7 @@ class Watchlist {
|
||||
// Seul le passage effectif au flux public déclenche une notification.
|
||||
if (next === 'public' && target.state !== 'public' && updated.notify) {
|
||||
hub.publishTargetLive(updated);
|
||||
void notifyTargetLive(updated.label ?? updated.username, updated.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1099,3 +1099,38 @@ export function mapStreamStatus(raw: string | undefined, privateStatuses: string
|
||||
// off / offline / deleted / notFound : le modèle n'est plus là du tout.
|
||||
return 'offline';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications Pushover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Identifiants Pushover, réglage serveur unique (pas par agent) : les
|
||||
* notifications concernent l'opérateur, pas une VM en particulier.
|
||||
*
|
||||
* Complète — sans le remplacer — le mécanisme de notification déjà en place
|
||||
* (notification navigateur, `WatchTarget.notify`) : ce canal-ci atteint
|
||||
* l'opérateur même dashboard fermé, ce que le navigateur ne peut pas faire.
|
||||
*/
|
||||
export interface PushoverSettings {
|
||||
enabled: boolean;
|
||||
/** Clé utilisateur ou de groupe Pushover (`Your User Key` sur pushover.net). */
|
||||
userKey: string;
|
||||
/** Jeton de l'application créée sur pushover.net/apps/build. */
|
||||
appToken: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PUSHOVER_SETTINGS: PushoverSettings = {
|
||||
enabled: false,
|
||||
userKey: '',
|
||||
appToken: '',
|
||||
};
|
||||
|
||||
export function normalizePushoverSettings(raw: unknown): PushoverSettings {
|
||||
const input = (raw ?? {}) as Partial<PushoverSettings>;
|
||||
return {
|
||||
enabled: input.enabled === true,
|
||||
userKey: typeof input.userKey === 'string' ? input.userKey.trim() : '',
|
||||
appToken: typeof input.appToken === 'string' ? input.appToken.trim() : '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AgentCard } from './components/AgentCard';
|
||||
import { AgentSettings } from './components/AgentSettings';
|
||||
import { AgentHistory } from './components/AgentHistory';
|
||||
import { LogPanel } from './components/LogPanel';
|
||||
import { NotificationsPage } from './components/NotificationsPage';
|
||||
import { StreamersPage } from './components/StreamersPage';
|
||||
import { useHashRoute } from './useHashRoute';
|
||||
|
||||
@@ -185,6 +186,12 @@ export function App() {
|
||||
>
|
||||
Journal
|
||||
</button>
|
||||
<button
|
||||
className={route === 'notifications' ? 'tab active' : 'tab'}
|
||||
onClick={() => setRoute('notifications')}
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="topbar-actions">
|
||||
@@ -228,6 +235,8 @@ export function App() {
|
||||
|
||||
{route === 'logs' ? (
|
||||
<LogPanel logs={logs} />
|
||||
) : route === 'notifications' ? (
|
||||
<NotificationsPage notify={notify} />
|
||||
) : route === 'streamers' ? (
|
||||
<StreamersPage
|
||||
targets={targets}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { AgentAction, AgentView, LogEntry, WatchTarget } from '@stream-control/shared';
|
||||
import type {
|
||||
AgentAction,
|
||||
AgentView,
|
||||
LogEntry,
|
||||
PushoverSettings,
|
||||
WatchTarget,
|
||||
} from '@stream-control/shared';
|
||||
|
||||
const TOKEN_KEY = 'stream-control.session';
|
||||
|
||||
@@ -130,6 +136,22 @@ export const api = {
|
||||
request<{ ok: boolean }>(`/watchlist/${id}/record`, { method: 'POST' }),
|
||||
|
||||
stopTarget: (id: string) => request<{ ok: boolean }>(`/watchlist/${id}/stop`, { method: 'POST' }),
|
||||
|
||||
// --- Notifications ----------------------------------------------------------
|
||||
|
||||
pushoverSettings: () => request<{ settings: PushoverSettings }>('/settings/pushover'),
|
||||
|
||||
updatePushoverSettings: (body: PushoverSettings) =>
|
||||
request<{ settings: PushoverSettings }>('/settings/pushover', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
testPushover: (body: Pick<PushoverSettings, 'userKey' | 'appToken'>) =>
|
||||
request<{ ok: true }>('/settings/pushover/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
};
|
||||
|
||||
/** URL du flux temps réel, jeton en query (les WS ne portent pas d'en-tête). */
|
||||
|
||||
133
packages/web/src/components/NotificationsPage.tsx
Normal file
133
packages/web/src/components/NotificationsPage.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PushoverSettings } from '@stream-control/shared';
|
||||
import { api } from '../api';
|
||||
|
||||
interface Props {
|
||||
notify: (message: string, tone?: 'info' | 'error') => void;
|
||||
}
|
||||
|
||||
const EMPTY: PushoverSettings = { enabled: false, userKey: '', appToken: '' };
|
||||
|
||||
/**
|
||||
* Réglages Pushover — un canal qui atteint l'opérateur même dashboard fermé,
|
||||
* à la différence de la notification navigateur déjà en place sur l'onglet
|
||||
* « Streamers » (qui exige un onglet ouvert et le focus accordé une fois).
|
||||
*/
|
||||
export function NotificationsPage({ notify }: Props) {
|
||||
const [settings, setSettings] = useState<PushoverSettings>(EMPTY);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.pushoverSettings()
|
||||
.then(({ settings }) => setSettings(settings))
|
||||
.catch((err) => notify(err instanceof Error ? err.message : 'Lecture impossible', 'error'))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function patch(update: Partial<PushoverSettings>) {
|
||||
setSettings((current) => ({ ...current, ...update }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const { settings: saved } = await api.updatePushoverSettings(settings);
|
||||
setSettings(saved);
|
||||
notify('Réglages Pushover enregistrés');
|
||||
} catch (err) {
|
||||
notify(err instanceof Error ? err.message : 'Enregistrement impossible', 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
setTesting(true);
|
||||
try {
|
||||
await api.testPushover({ userKey: settings.userKey, appToken: settings.appToken });
|
||||
notify('Notification de test envoyée — vérifie ton téléphone');
|
||||
} catch (err) {
|
||||
notify(err instanceof Error ? err.message : 'Envoi impossible', 'error');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Le test exige des identifiants dans le champ, masqués ou non : un envoi
|
||||
// sur des champs vides échouerait toujours côté Pushover, autant l'éviter ici.
|
||||
const canTest = Boolean(settings.userKey) && Boolean(settings.appToken);
|
||||
|
||||
if (loading) return <main className="page"><p className="muted">Chargement…</p></main>;
|
||||
|
||||
return (
|
||||
<main className="page notifications-page">
|
||||
<h2>Notifications</h2>
|
||||
<p className="muted">
|
||||
Alerte l'opérateur — sur son téléphone, dashboard fermé compris — quand un streamer
|
||||
surveillé passe en direct. Complète la notification navigateur déjà proposée sur
|
||||
l'onglet « Streamers », qui exige un onglet ouvert.
|
||||
</p>
|
||||
|
||||
<fieldset className="group">
|
||||
<legend>Pushover</legend>
|
||||
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(event) => patch({ enabled: event.target.checked })}
|
||||
/>
|
||||
<span>Activer les notifications Pushover</span>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>Clé utilisateur (User Key)</span>
|
||||
<input
|
||||
value={settings.userKey}
|
||||
onChange={(event) => patch({ userKey: event.target.value })}
|
||||
onFocus={() => {
|
||||
// Le champ masqué ne se ré-édite pas caractère par caractère :
|
||||
// il se remplace en entier, comme le mot de passe OBS ailleurs.
|
||||
if (settings.userKey === '********') patch({ userKey: '' });
|
||||
}}
|
||||
placeholder="visible sur la page d'accueil de ton compte pushover.net"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span>Jeton d'application (API Token)</span>
|
||||
<input
|
||||
value={settings.appToken}
|
||||
onChange={(event) => patch({ appToken: event.target.value })}
|
||||
onFocus={() => {
|
||||
if (settings.appToken === '********') patch({ appToken: '' });
|
||||
}}
|
||||
placeholder="créé sur pushover.net/apps/build"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="muted small">
|
||||
Une seule application suffit pour tous les usages de Stream Control ; crée-la sur{' '}
|
||||
<a href="https://pushover.net/apps/build" target="_blank" rel="noreferrer">
|
||||
pushover.net/apps/build
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="row">
|
||||
<button className="primary" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
<button className="ghost" disabled={testing || !canTest} onClick={() => void test()}>
|
||||
{testing ? 'Envoi…' : 'Envoyer un test'}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -578,6 +578,10 @@ fieldset.group > legend {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notifications-page fieldset.group {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.streamer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const ROUTES = ['agents', 'streamers', 'logs'] as const;
|
||||
export const ROUTES = ['agents', 'streamers', 'logs', 'notifications'] as const;
|
||||
export type Route = (typeof ROUTES)[number];
|
||||
|
||||
function currentRoute(): Route {
|
||||
|
||||
Reference in New Issue
Block a user