Уведомления: persistent + live (push-to-invalidate → pull)
Новая система уведомлений поверх готовой SSE-шины: события пишутся в БД,
живут 72 часа и чистятся (при чтении списка + фоновой задачей), всплывают
сверху экрана в момент прихода, доступны через колокольчик в правом верхнем
углу (бейдж непрочитанных + панель).
Типы: приглашение в группу (→ /group), старт/финиш партии участникам кроме
инициатора (→ /match/{id}). Титулы — готовый хелпер-задел (не подключён, т.к.
выдача титулов игрокам ещё не реализована).
Бэкенд: модель Notification + миграция 0008 (идемпотентная), notification_service,
notify.notifications_changed, роутер /api/notifications (GET + /read), триггеры
в groups/matches, фоновая чистка в lifespan, защита hub.publish от закрытого loop.
Фронт: useNotifications/useMarkNotificationsRead, NotificationBell/Panel/Toaster,
перекомпоновка top-bar, стили; useServerEvents знает тип notifications.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ export const qk = {
|
||||
match: (id: number) => ["match", id] as const,
|
||||
authConfig: ["authConfig"] as const,
|
||||
invitations: ["invitations"] as const,
|
||||
notifications: ["notifications"] as const,
|
||||
devUsers: ["devUsers"] as const,
|
||||
adminUsers: ["adminUsers"] as const,
|
||||
adminGroups: ["adminGroups"] as const,
|
||||
|
||||
Vendored
+116
@@ -662,6 +662,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** My Notifications */
|
||||
get: operations["my_notifications_api_notifications_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/notifications/read": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Mark Read */
|
||||
post: operations["mark_read_api_notifications_read_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/auth/login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1725,6 +1759,35 @@ export interface components {
|
||||
/** Nickname */
|
||||
nickname: string;
|
||||
};
|
||||
/** NotificationList */
|
||||
NotificationList: {
|
||||
/** Items */
|
||||
items: components["schemas"]["NotificationRead"][];
|
||||
/** Unread Count */
|
||||
unread_count: number;
|
||||
};
|
||||
/** NotificationMarkRead */
|
||||
NotificationMarkRead: {
|
||||
/** Ids */
|
||||
ids?: number[] | null;
|
||||
};
|
||||
/** NotificationRead */
|
||||
NotificationRead: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Type */
|
||||
type: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Body */
|
||||
body?: string | null;
|
||||
/** Link */
|
||||
link?: string | null;
|
||||
/** Read At */
|
||||
read_at?: string | null;
|
||||
/** Created At */
|
||||
created_at: string;
|
||||
};
|
||||
/** OkResponse */
|
||||
OkResponse: {
|
||||
/**
|
||||
@@ -3232,6 +3295,59 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
my_notifications_api_notifications_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["NotificationList"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
mark_read_api_notifications_read_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["NotificationMarkRead"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OkResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
admin_login_api_admin_auth_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useServerEvents } from "../hooks/useServerEvents";
|
||||
import { BottomBar } from "./BottomBar";
|
||||
import { NotificationBell } from "./NotificationBell";
|
||||
import { NotificationToaster } from "./NotificationToaster";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Forbidden Stars",
|
||||
@@ -26,16 +28,23 @@ export function AppShell() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
{isHome ? (
|
||||
<span className="title">{title}</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<span className="title small muted">{!isHome ? title : ""}</span>
|
||||
<div className="top-bar-left">
|
||||
{isHome ? (
|
||||
<span className="title">{title}</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="title small muted top-bar-center">{!isHome ? title : ""}</span>
|
||||
<div className="top-bar-right">
|
||||
<NotificationBell enabled={!!me} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<NotificationToaster enabled={!!me} />
|
||||
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useMarkNotificationsRead, useNotifications } from "../hooks/notifications";
|
||||
import { NotificationPanel } from "./NotificationPanel";
|
||||
|
||||
/** Колокольчик в правом верхнем углу: бейдж непрочитанных + выпадающая панель. */
|
||||
export function NotificationBell({ enabled }: { enabled: boolean }) {
|
||||
const { data } = useNotifications(enabled);
|
||||
const markRead = useMarkNotificationsRead();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const unread = data?.unread_count ?? 0;
|
||||
|
||||
// Закрытие панели по клику вне неё.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next && unread > 0) markRead.mutate(undefined); // открыли — гасим бейдж
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notif-bell-wrap" ref={ref}>
|
||||
<button className="notif-bell" aria-label="Уведомления" onClick={toggle}>
|
||||
<Bell size={20} />
|
||||
{unread > 0 && <span className="notif-badge">{unread > 99 ? "99+" : unread}</span>}
|
||||
</button>
|
||||
{open && <NotificationPanel items={data?.items ?? []} onClose={() => setOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatTime } from "../domain/format";
|
||||
import type { Notification } from "../hooks/notifications";
|
||||
|
||||
/** Выпадающий список уведомлений под колокольчиком. Клик по строке ведёт по ссылке. */
|
||||
export function NotificationPanel({
|
||||
items,
|
||||
onClose,
|
||||
}: {
|
||||
items: Notification[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const go = (n: Notification) => {
|
||||
onClose();
|
||||
if (n.link) navigate(n.link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notif-panel">
|
||||
<div className="notif-panel-head">Уведомления</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="notif-empty muted small">Уведомлений нет</div>
|
||||
) : (
|
||||
<div className="notif-list">
|
||||
{items.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
className={`notif-item${n.read_at ? "" : " unread"}`}
|
||||
onClick={() => go(n)}
|
||||
>
|
||||
<div className="notif-item-title">{n.title}</div>
|
||||
{n.body && <div className="notif-item-body muted small">{n.body}</div>}
|
||||
<div className="notif-item-time muted small">{formatTime(n.created_at)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useNotifications, type Notification } from "../hooks/notifications";
|
||||
|
||||
const TOAST_TTL_MS = 5000;
|
||||
const MAX_VISIBLE = 4;
|
||||
|
||||
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
||||
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
|
||||
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
||||
const { data } = useNotifications(enabled);
|
||||
const navigate = useNavigate();
|
||||
const seen = useRef<Set<number> | null>(null);
|
||||
const [toasts, setToasts] = useState<Notification[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const items = data.items;
|
||||
if (seen.current === null) {
|
||||
seen.current = new Set(items.map((n) => n.id)); // первая загрузка — запомнить, не всплывать
|
||||
return;
|
||||
}
|
||||
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
||||
if (fresh.length === 0) return;
|
||||
fresh.forEach((n) => seen.current!.add(n.id));
|
||||
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
|
||||
fresh.forEach((n) => {
|
||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
if (!enabled || toasts.length === 0) return null;
|
||||
|
||||
const click = (n: Notification) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== n.id));
|
||||
if (n.link) navigate(n.link);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="toast-top-wrap">
|
||||
{toasts.map((n) => (
|
||||
<button key={n.id} className="toast-top" onClick={() => click(n)}>
|
||||
<div className="toast-top-title">{n.title}</div>
|
||||
{n.body && <div className="toast-top-body">{n.body}</div>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
export type Notification = components["schemas"]["NotificationRead"];
|
||||
|
||||
export function useNotifications(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: qk.notifications,
|
||||
queryFn: async () => unwrap(await api.GET("/api/notifications")),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkNotificationsRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
// ids не задаём → сервер отметит прочитанными все непрочитанные.
|
||||
mutationFn: async (ids?: number[]) =>
|
||||
unwrap(await api.POST("/api/notifications/read", { body: { ids: ids ?? null } })),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.notifications }),
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
interface ServerEvent {
|
||||
type: "match" | "group" | "invitations";
|
||||
type: "match" | "group" | "invitations" | "notifications";
|
||||
match_id?: number;
|
||||
group_id?: number;
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export function useServerEvents(enabled: boolean) {
|
||||
}
|
||||
if (ev.type === "invitations") {
|
||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||
} else if (ev.type === "notifications") {
|
||||
qc.invalidateQueries({ queryKey: qk.notifications });
|
||||
} else if (ev.type === "match") {
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
if (ev.group_id != null) {
|
||||
|
||||
@@ -107,3 +107,110 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
text-align: center;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
||||
.top-bar-left { display: flex; align-items: center; min-width: 0; }
|
||||
.top-bar-center {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.top-bar-right { display: flex; align-items: center; }
|
||||
|
||||
.notif-bell-wrap { position: relative; }
|
||||
.notif-bell {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.notif-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notif-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: min(320px, 86vw);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
z-index: 50;
|
||||
}
|
||||
.notif-panel-head {
|
||||
padding: 10px 14px;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.notif-empty { padding: 18px 14px; text-align: center; }
|
||||
.notif-list { display: flex; flex-direction: column; }
|
||||
.notif-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.notif-item:last-child { border-bottom: none; }
|
||||
.notif-item.unread { background: var(--surface-2); }
|
||||
.notif-item-title { font-weight: 600; }
|
||||
.notif-item-body { margin-top: 2px; }
|
||||
.notif-item-time { margin-top: 4px; }
|
||||
|
||||
/* Всплывашки сверху экрана в момент прихода */
|
||||
.toast-top-wrap {
|
||||
position: fixed;
|
||||
top: calc(var(--safe-top) + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(var(--app-max-w), 94vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast-top {
|
||||
pointer-events: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.toast-top-title { font-weight: 700; }
|
||||
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
Reference in New Issue
Block a user