mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-28 18:08:15 +03:00
feat: notifications UI
minimal, but just works (tm)
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { ErrorPage } from "app/pages/ErrorPage";
|
||||
import useSetSubheader from "components/layout/header/useSetSubheader";
|
||||
import NotificationRow from "components/notifications/NotificationRow";
|
||||
import MiniTable from "components/tables/components/MiniTable";
|
||||
import ApiError from "components/util/ApiError";
|
||||
import Loading from "components/util/Loading";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useMemo } from "react";
|
||||
import { Button, Col, Row } from "react-bootstrap";
|
||||
import { NotificationDocument, PublicUserDocument } from "tachi-common";
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { user } = useContext(UserContext);
|
||||
|
||||
useSetSubheader(["Dashboard", "Notifications"]);
|
||||
|
||||
if (!user) {
|
||||
return <ErrorPage statusCode={401} />;
|
||||
}
|
||||
|
||||
return <NotificationsInnerPage user={user} />;
|
||||
}
|
||||
|
||||
function NotificationsInnerPage({ user }: { user: PublicUserDocument }) {
|
||||
const { data, error } = useApiQuery<Array<NotificationDocument>>(
|
||||
`/users/${user.id}/notifications`
|
||||
);
|
||||
|
||||
const unread = useMemo(() => {
|
||||
if (!data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return data.filter((e) => e.read === false).length;
|
||||
}, [data]);
|
||||
|
||||
if (error) {
|
||||
return <ApiError error={error} />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="d-flex w-100 justify-content-center">
|
||||
<div>You've got no mail!</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<MiniTable headers={["Read"]}>
|
||||
{data.map((e) => (
|
||||
<NotificationRow notif={e} key={e.notifID} />
|
||||
))}
|
||||
</MiniTable>
|
||||
</Col>
|
||||
<Col xs={12}>
|
||||
<div className="w-100 d-flex">
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<div style={{ flex: 1, display: "flex", justifyContent: "center" }}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await APIFetchV1(`/users/${user.id}/delete-all`, {
|
||||
method: "POST",
|
||||
});
|
||||
}}
|
||||
variant="outline-danger"
|
||||
>
|
||||
Clear Notifications
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: "right" }}>
|
||||
{data.length} Notifications.
|
||||
{unread > 0 && (
|
||||
<>
|
||||
{" "}
|
||||
<strong>({unread} Unread)</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -210,9 +210,7 @@ function PreferencesForm({ reqUser }: { reqUser: PublicUserDocument }) {
|
||||
onChange={formik.handleChange}
|
||||
label="Advanced Mode"
|
||||
/>
|
||||
<Form.Text>
|
||||
Enable advanced stuff, like being able to copy a tables contents into a CSV.
|
||||
</Form.Text>
|
||||
<Form.Text>Enable advanced stuff.</Form.Text>
|
||||
</Form.Group>
|
||||
{/* <Form.Group>
|
||||
<Form.Check
|
||||
|
||||
@@ -10,6 +10,7 @@ import { UserContext } from "context/UserContext";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Redirect, Route, Switch } from "react-router-dom";
|
||||
import { BackgroundContext } from "context/BackgroundContext";
|
||||
import NotificationsPage from "app/pages/dashboard/users/NotificationsPage";
|
||||
import { DashboardPage } from "../pages/dashboard/DashboardPage";
|
||||
import CreditsPage from "../pages/dashboard/misc/CreditsPage";
|
||||
import GameRoutes from "./GameRoutes";
|
||||
@@ -106,6 +107,10 @@ export default function DashboardRoutes() {
|
||||
<UtilRoutes />
|
||||
</Route>
|
||||
|
||||
<Route path="/dashboard/notifications">
|
||||
<NotificationsPage />
|
||||
</Route>
|
||||
|
||||
<Route path="*">
|
||||
<ErrorPage statusCode={404} />
|
||||
</Route>
|
||||
|
||||
@@ -3,6 +3,7 @@ import LinkButton from "components/util/LinkButton";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import React, { useContext } from "react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { UserNotificationButton } from "./UserNotificationButton";
|
||||
|
||||
export function Topbar() {
|
||||
const { user } = useContext(UserContext);
|
||||
@@ -13,32 +14,7 @@ export function Topbar() {
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{/* commented out quick panel stuff for future */}
|
||||
{/* <OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={<Tooltip id="quick-panel-tooltip">Quick panel</Tooltip>}
|
||||
>
|
||||
<div
|
||||
className="topbar-item"
|
||||
data-toggle="tooltip"
|
||||
title="Quick panel"
|
||||
data-placement="right"
|
||||
>
|
||||
<div
|
||||
className="btn btn-icon btn-hover-transparent-white btn-lg mr-1"
|
||||
id="kt_quick_panel_toggle"
|
||||
>
|
||||
<span className="svg-icon svg-icon-xl">
|
||||
<SVG
|
||||
src={toAbsoluteUrl(
|
||||
"/cdn/svg/icons/Layout/Layout-4-blocks.svg"
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayTrigger> */}
|
||||
|
||||
<UserNotificationButton user={user} />
|
||||
<UserProfileDropdown user={user} />
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import Icon from "components/util/Icon";
|
||||
import useApiQuery from "components/util/query/useApiQuery";
|
||||
import React, { useMemo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { NotificationDocument, PublicUserDocument } from "tachi-common";
|
||||
|
||||
export function UserNotificationButton({ user }: { user: PublicUserDocument }) {
|
||||
// don't care whether it errors or not, tbh
|
||||
const { data } = useApiQuery<Array<NotificationDocument>>(`/users/${user.id}/notifications`);
|
||||
|
||||
const unread = useMemo(() => {
|
||||
if (!data) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return data.filter((e) => e.read === false).length;
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="topbar-item">
|
||||
<Link to={"/dashboard/notifications"}>
|
||||
<div
|
||||
className="btn btn-icon btn-hover-transparent-white btn-dropdown btn-lg mr-1"
|
||||
id="kt_quick_notifications_toggle"
|
||||
>
|
||||
{unread > 0 ? (
|
||||
<Icon type="envelope" colour="primary" />
|
||||
) : (
|
||||
<Icon type="envelope" regular colour="muted" />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { PublicUserDocument } from "tachi-common";
|
||||
|
||||
export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
|
||||
const { setUser } = useContext(UserContext);
|
||||
|
||||
const [heySplash] = useState(RFA(heySplashes));
|
||||
|
||||
return (
|
||||
@@ -28,7 +27,7 @@ export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
|
||||
<span className="text-white opacity-70 font-weight-bold font-size-base d-none d-md-inline mr-1">
|
||||
{heySplash},
|
||||
</span>{" "}
|
||||
<span className="text-white opacity-90 font-weight-bolder font-size-base d-none d-md-inline mr-4">
|
||||
<span className="text-white opacity-90 font-weight-bolder font-size-base d-none d-md-inline mr-2">
|
||||
{user.username}
|
||||
</span>
|
||||
<span className="symbol symbol-35">
|
||||
@@ -37,17 +36,6 @@ export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
|
||||
</div>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="p-0 m-0 dropdown-menu-right dropdown-menu-anim dropdown-menu-top-unround dropdown-menu-xl">
|
||||
<div className="d-flex align-items-center justify-content-between flex-wrap p-8 rounded-top">
|
||||
<div className="symbol bg-white-o-15 mr-3">
|
||||
<img className="hidden" src={ToAPIURL("/users/me/pfp")} />
|
||||
</div>
|
||||
<div className="text-white m-0 flex-grow-1 mr-3 font-size-h5">
|
||||
{user.username}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="navi navi-spacer-x-0 pt-5">
|
||||
<Link
|
||||
to={`/dashboard/users/${user.username}`}
|
||||
@@ -63,6 +51,20 @@ export function UserProfileDropdown({ user }: { user: PublicUserDocument }) {
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/dashboard/users/${user.username}`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
>
|
||||
<div className="navi-link">
|
||||
<div className="navi-icon mr-2">
|
||||
<Icon type="envelope" colour="success" />
|
||||
</div>
|
||||
<div className="navi-text">
|
||||
<div className="font-weight-bold cursor-pointer">Notifications</div>
|
||||
<div className="text-muted">View your notifications!</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/dashboard/users/${user.username}/settings`}
|
||||
className="navi-item px-8 cursor-pointer"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import TimestampCell from "components/tables/cells/TimestampCell";
|
||||
import Icon from "components/util/Icon";
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { NotificationDocument } from "tachi-common";
|
||||
|
||||
export default function NotificationRow({ notif }: { notif: NotificationDocument }) {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
{notif.read ? (
|
||||
<Icon type="envelope" regular colour="muted" />
|
||||
) : (
|
||||
<Icon type="envelope-open" />
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<strong>
|
||||
<Link className="gentle-link" to={NotifToURL(notif)}>
|
||||
{notif.title}
|
||||
</Link>
|
||||
</strong>
|
||||
</td>
|
||||
<TimestampCell time={notif.sentAt} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function NotifToURL(notif: NotificationDocument) {
|
||||
switch (notif.body.type) {
|
||||
case "QUEST_CHANGED":
|
||||
return "/idkyet";
|
||||
case "RIVALED_BY":
|
||||
return `/dashboard/users/${notif.body.content.userID}/games${notif.body.content.game}/${notif.body.content.playtype}`;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export default function Icon({
|
||||
brand?: boolean;
|
||||
regular?: boolean;
|
||||
onClick?: () => void;
|
||||
colour?: "info" | "primary" | "danger" | "warning";
|
||||
colour?: "info" | "primary" | "danger" | "warning" | "success" | "muted";
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
|
||||
@@ -1595,19 +1595,6 @@ interface BaseNotification {
|
||||
}
|
||||
|
||||
export type NotificationBody =
|
||||
| {
|
||||
type: "CHALLENGE_BEAT";
|
||||
content: {
|
||||
userID: integer;
|
||||
challenge: ChallengeWallDocument;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "CHALLENGE_RECEIVED";
|
||||
content: {
|
||||
challenge: ChallengeWallDocument;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: "QUEST_CHANGED"; // Emitted when a quest the user is subscribed to changed.
|
||||
content: {
|
||||
@@ -1640,15 +1627,6 @@ export interface ChallengeSubscriptionDocument {
|
||||
achievedAt: number | null;
|
||||
}
|
||||
|
||||
export interface ChallengeWallDocument {
|
||||
chartID: string;
|
||||
authorID: integer;
|
||||
type: "lamp" | "score";
|
||||
|
||||
game: Game;
|
||||
playtype: Playtype;
|
||||
}
|
||||
|
||||
interface BaseImportTracker {
|
||||
timeStarted: number;
|
||||
importID: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FormatGame } from "tachi-common";
|
||||
import type { Game, integer, Playtype, PublicUserDocument } from "tachi-common";
|
||||
|
||||
/**
|
||||
* A utility wrapper for sending the SET_RIVAL notification.
|
||||
* A utility wrapper for sending the RIVALED_BY notification.
|
||||
*
|
||||
* @param toUserID - The user to send this notification to.
|
||||
* @param fromUser - The user who rivalled them.
|
||||
|
||||
Reference in New Issue
Block a user