diff --git a/README.md b/README.md index 2c052d7c..436b4b6d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ bun run dev ## Docker +### Development (with hot reload feature) +```bash +docker compose -f docker-compose.dev.yml up +``` + +### Production ```bash docker compose up ``` diff --git a/bun.lock b/bun.lock index 6498f66b..d06382e0 100644 --- a/bun.lock +++ b/bun.lock @@ -45,7 +45,6 @@ "@vitejs/plugin-react": "^4.3.4", "cypress": "^15.2.0", "eslint": "^9.24.0", - "eslint-config-airbnb-typescript": "^18.0.0", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.1.0", @@ -734,8 +733,6 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "confusing-browser-globals": ["confusing-browser-globals@1.0.11", "", {}, "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA=="], - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], @@ -842,10 +839,6 @@ "eslint": ["eslint@9.29.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.1", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ=="], - "eslint-config-airbnb-base": ["eslint-config-airbnb-base@15.0.0", "", { "dependencies": { "confusing-browser-globals": "^1.0.10", "object.assign": "^4.1.2", "object.entries": "^1.1.5", "semver": "^6.3.0" }, "peerDependencies": { "eslint": "^7.32.0 || ^8.2.0", "eslint-plugin-import": "^2.25.2" } }, "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig=="], - - "eslint-config-airbnb-typescript": ["eslint-config-airbnb-typescript@18.0.0", "", { "dependencies": { "eslint-config-airbnb-base": "^15.0.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-oc+Lxzgzsu8FQyFVa4QFaVKiitTYiiW3frB9KYW5OWdPrqFc7FzxgB20hP4cHMlr+MBzGcLl3jnCOVOydL9mIg=="], - "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], @@ -1312,11 +1305,11 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "notistack": ["notistack@3.0.2", "", { "dependencies": { "clsx": "^1.1.0", "goober": "^2.0.33" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA=="], + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], "nwsapi": ["nwsapi@2.2.22", "", {}, "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ=="], - - "notistack": ["notistack@3.0.2", "", { "dependencies": { "clsx": "^1.1.0", "goober": "^2.0.33" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], @@ -1826,8 +1819,6 @@ "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "eslint-config-airbnb-base/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -1870,6 +1861,8 @@ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "notistack/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + "openapi-typescript-codegen/commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], "openapi-typescript-codegen/fs-extra": ["fs-extra@11.3.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew=="], @@ -1881,8 +1874,6 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - - "notistack/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], diff --git a/package.json b/package.json index 70a63dbc..ed6087a9 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "@vitejs/plugin-react": "^4.3.4", "cypress": "^15.2.0", "eslint": "^9.24.0", - "eslint-config-airbnb-typescript": "^18.0.0", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.1.0", @@ -69,4 +68,4 @@ "vite": "^6.2.0" }, "packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538" -} \ No newline at end of file +} diff --git a/src/api/core/OpenAPI.ts b/src/api/core/OpenAPI.ts index 99661fbb..13b731d8 100644 --- a/src/api/core/OpenAPI.ts +++ b/src/api/core/OpenAPI.ts @@ -21,7 +21,7 @@ export type OpenAPIConfig = { }; export const OpenAPI: OpenAPIConfig = { - BASE: import.meta.env.VITE_BACKEND_URL || 'none', + BASE: (import.meta.env.VITE_BACKEND_URL || 'none'), VERSION: '1.0', WITH_CREDENTIALS: true, CREDENTIALS: 'include', diff --git a/src/api/index.ts b/src/api/index.ts index b18970c1..c91188f6 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,12 +14,14 @@ export type { CreateUserDto } from './models/CreateUserDto'; export type { FetchWorkSessionDto } from './models/FetchWorkSessionDto'; export type { KickWorkSessionDto } from './models/KickWorkSessionDto'; export type { LoginDto } from './models/LoginDto'; +export type { NotificationTestDto } from './models/NotificationTestDto'; export type { PaginationMetaDto } from './models/PaginationMetaDto'; export type { ProjectResponseDto } from './models/ProjectResponseDto'; export type { ProjectWithRelationsResponseDto } from './models/ProjectWithRelationsResponseDto'; export type { RemoveCollaboratorDto } from './models/RemoveCollaboratorDto'; export type { UpdateProjectDto } from './models/UpdateProjectDto'; export type { UpdateUserDto } from './models/UpdateUserDto'; +export type { UpdateUserProfileDto } from './models/UpdateUserProfileDto'; export type { UserBasicInfoDto } from './models/UserBasicInfoDto'; export type { UserListResponseDto } from './models/UserListResponseDto'; export type { UserProfileResponseDto } from './models/UserProfileResponseDto'; @@ -28,6 +30,7 @@ export type { UserRoleDto } from './models/UserRoleDto'; export type { UserSingleResponseDto } from './models/UserSingleResponseDto'; export { AuthService } from './services/AuthService'; +export { NotificationsService } from './services/NotificationsService'; export { ProjectsService } from './services/ProjectsService'; export { UsersService } from './services/UsersService'; export { WorkSessionsService } from './services/WorkSessionsService'; diff --git a/src/api/models/AuthResponseDto.ts b/src/api/models/AuthResponseDto.ts index 1b06ce42..294e344a 100644 --- a/src/api/models/AuthResponseDto.ts +++ b/src/api/models/AuthResponseDto.ts @@ -4,6 +4,5 @@ /* eslint-disable */ export type AuthResponseDto = { access_token: string; - refresh_token: string; }; diff --git a/src/api/models/NotificationTestDto.ts b/src/api/models/NotificationTestDto.ts new file mode 100644 index 00000000..b10c03a3 --- /dev/null +++ b/src/api/models/NotificationTestDto.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type NotificationTestDto = { + /** + * The title of the test notification + */ + title: string; + /** + * The message content of the test notification + */ + message: string; + /** + * The type of the notification (friend request, message, etc.) + */ + type: string; +}; + diff --git a/src/api/services/NotificationsService.ts b/src/api/services/NotificationsService.ts new file mode 100644 index 00000000..9496f5c7 --- /dev/null +++ b/src/api/services/NotificationsService.ts @@ -0,0 +1,54 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { NotificationTestDto } from '../models/NotificationTestDto'; +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class NotificationsService { + /** + * Send a test notification to the current user + * @param requestBody + * @returns any Notification created and sent + * @throws ApiError + */ + public static notificationsControllerSendTestNotification( + requestBody: NotificationTestDto, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/notifications/test', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * set one notification as read + * @param id + * @returns any Notification marked as read + * @throws ApiError + */ + public static notificationsControllerMarkAsRead( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/notifications/{id}/read', + path: { + 'id': id, + }, + }); + } + /** + * set notifications as read + * @returns any All notifications as read + * @throws ApiError + */ + public static notificationsControllerMarkAllAsRead(): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/notifications/read-all', + }); + } +} diff --git a/src/assets/inbox.svg b/src/assets/inbox.svg new file mode 100644 index 00000000..fbd253ef --- /dev/null +++ b/src/assets/inbox.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/errors/NotificationError.ts b/src/errors/NotificationError.ts new file mode 100644 index 00000000..888457a4 --- /dev/null +++ b/src/errors/NotificationError.ts @@ -0,0 +1,6 @@ +export class NotificationError extends Error { + constructor(message: string) { + super(message); + this.name = "NotificationError"; + } +} diff --git a/src/shared/authOverlay/AuthOverlay.tsx b/src/shared/authOverlay/AuthOverlay.tsx index b211b0a9..faaefa27 100644 --- a/src/shared/authOverlay/AuthOverlay.tsx +++ b/src/shared/authOverlay/AuthOverlay.tsx @@ -91,8 +91,6 @@ const AuthOverlay: FC = ({ isOpen, setIsOpen, onClose }) => { } // FIXME: put the token to httpOnly cookie using the backend LocalStorageManager.setToken(authResponse.access_token); - LocalStorageManager.setRefreshToken(authResponse.refresh_token); - const userRes = await UsersService.userControllerGetProfile(); LocalStorageManager.setUser({ id: String(userRes.id), diff --git a/src/shared/navbar/NavBar.tsx b/src/shared/navbar/NavBar.tsx index 64fb5c4e..52be5013 100644 --- a/src/shared/navbar/NavBar.tsx +++ b/src/shared/navbar/NavBar.tsx @@ -6,6 +6,7 @@ import React, { useState } from "react"; import { useUser } from "@providers/UserProvider"; import { muiTheme } from "@theme/MUITheme"; import { Login } from "@shared/navbar/login/Login"; +import { NotificationBox } from "@shared/navbar/notifications/NotificationBox"; import * as Urls from "@shared/route"; const Nav = styled("nav")(({ theme }) => ({ display: "grid", @@ -54,6 +55,7 @@ const NavBar: React.FC = () => { Friends + {user ? : } diff --git a/src/shared/navbar/notifications/NotificationBox.tsx b/src/shared/navbar/notifications/NotificationBox.tsx new file mode 100644 index 00000000..beaa305e --- /dev/null +++ b/src/shared/navbar/notifications/NotificationBox.tsx @@ -0,0 +1,139 @@ +import { Badge, IconButton, styled } from "@mui/material"; +import { JSX, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react"; +import InfoBoxIcon from "@assets/infoBox.svg?react"; +import { useUser } from "@providers/UserProvider"; +import { LocalStorageManager } from "@utils/LocalStorageManager"; +import { NotificationMenu } from "./NotificationMenu"; +import { NotificationItem } from "./types"; +import { NotificationsService } from "@api/services/NotificationsService"; +import { OpenAPI } from "@api"; + +const NOTIFICATION_SOCKET_PATH = "/socket/notifications"; +const MAX_NOTIFICATIONS = 50; + +type NotificationWsMessage = + | { type: "notification"; payload: NotificationItem } + | { type: "notifications:init"; payload: NotificationItem[] }; + +const NotificationButton = styled(IconButton)(({ theme }) => ({ + margin: theme.spacing(2), + color: theme.palette.text.primary, +})); + +const InfoIcon = styled(InfoBoxIcon)(({ theme }) => ({ + width: 24, + height: 24, + color: theme.palette.text.primary, +})); + +const mergeNotification = ( + previous: NotificationItem[], + notification: NotificationItem, +): NotificationItem[] => { + const withoutCurrent = previous.filter((item) => item.id !== notification.id); + return [notification, ...withoutCurrent].slice(0, MAX_NOTIFICATIONS); +}; + +export const NotificationBox = (): JSX.Element => { + const { user } = useUser(); + const userId = user?.id; + const token = LocalStorageManager.getToken(); + const [notifications, setNotifications] = useState([]); + const [showMenu, setShowMenu] = useState(false); + const [anchorEl, setAnchorEl] = useState(undefined); + const socketRef = useRef(null); + const backendUrl = OpenAPI.BASE; + + const unreadCount = useMemo( + () => notifications.reduce((count, notification) => count + (notification.read ? 0 : 1), 0), + [notifications], + ); + + useEffect(() => { + if (!userId || !token || !backendUrl) { + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } + setNotifications([]); + return; + } + + const wsBase = backendUrl.replace(/^http/i, "ws").replace(/\/$/, ""); + const socketUrl = `${wsBase}${NOTIFICATION_SOCKET_PATH}`; + const socket = new WebSocket(socketUrl); + + socketRef.current = socket; + + socket.onopen = () => { + try { + socket.send(JSON.stringify({ type: "auth", token })); + } catch { + console.warn("Failed to send auth message over websocket"); + } + }; + + socket.onmessage = (event: MessageEvent) => { + try { + const message = JSON.parse(event.data) as NotificationWsMessage; + + if (message.type === "notifications:init") { + setNotifications(message.payload.slice(0, MAX_NOTIFICATIONS)); + return; + } + + if (message.type === "notification") { + setNotifications((previous) => mergeNotification(previous, message.payload)); + } + } catch (error) { + console.warn("Failed to process notification websocket message:", error); + } + }; + + return () => { + socket.close(); + socketRef.current = null; + }; + }, [userId, token, backendUrl]); + + const handleClick = useCallback((event: MouseEvent) => { + setAnchorEl(event.currentTarget); + setShowMenu((previous) => { + const next = !previous; + + return next; + }); + }, []); + + const handleClose = useCallback(() => setShowMenu(false), []); + + const handleMarkAsRead = useCallback((notificationId: string) => { + setNotifications((current) => + current.map((notification) => + notification.id === notificationId ? { ...notification, read: true } : notification, + ), + ); + NotificationsService.notificationsControllerMarkAsRead(notificationId).catch(() => { + console.error(`Failed to mark notification ${notificationId} as read`); + }); + }, []); + + return ( + <> + + + + + + {showMenu && ( + + )} + + ); +}; diff --git a/src/shared/navbar/notifications/NotificationListItem.tsx b/src/shared/navbar/notifications/NotificationListItem.tsx new file mode 100644 index 00000000..9c400b8a --- /dev/null +++ b/src/shared/navbar/notifications/NotificationListItem.tsx @@ -0,0 +1,68 @@ +import { Box, MenuItem, Typography, styled } from "@mui/material"; +import { JSX } from "react"; +import { NotificationItem } from "./types"; + +const NotificationEntry = styled(MenuItem, { + shouldForwardProp: (prop) => prop !== "read", +})<{ read: boolean }>(({ theme, read }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + padding: theme.spacing(1, 0), + borderBottom: `1px solid ${theme.palette.gray[500]}`, + opacity: read ? 0.5 : 1, + "&:last-of-type": { + borderBottom: "none", + }, +})); + +const NonImportantTypography = styled(Typography)(({ theme }) => ({ + color: theme.palette.gray[300], +})); + +const ActionsRow = styled(Box)(({ theme }) => ({ + display: "flex", + gap: theme.spacing(1), + marginTop: theme.spacing(0.5), +})); + +type NotificationListItemProps = { + notification: NotificationItem; + onMarkAsRead: (notificationId: string) => void; +}; + +export const NotificationListItem = ({ + notification, + onMarkAsRead, +}: NotificationListItemProps): JSX.Element => ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onMarkAsRead(notification.id); + } + }} + > + + {notification.title || notification.type} + + + {notification.message} + + + {new Date(notification.createdAt).toLocaleString()} + + + onMarkAsRead(notification.id)} + > + Mark as read + + + +); diff --git a/src/shared/navbar/notifications/NotificationMenu.tsx b/src/shared/navbar/notifications/NotificationMenu.tsx new file mode 100644 index 00000000..1ea7cd5c --- /dev/null +++ b/src/shared/navbar/notifications/NotificationMenu.tsx @@ -0,0 +1,55 @@ +import { Menu, Typography, styled } from "@mui/material"; +import { JSX } from "react"; +import { NotificationItem } from "./types"; +import { NotificationListItem } from "./NotificationListItem"; + +type NotificationMenuProps = { + anchorEl: HTMLElement | undefined; + open: boolean; + onClose: () => void; + notifications: NotificationItem[]; + onMarkAsRead: (notificationId: string) => void; +}; + +const StyledMenu = styled(Menu)(({ theme }) => ({ + "& .MuiPaper-root": { + boxShadow: "none", + backgroundColor: theme.palette.gray[700], + color: theme.palette.text.primary, + borderRadius: theme.spacing(1), + border: `3px solid ${theme.palette.gray[400]}`, + padding: theme.spacing(1, 2) + }, + + marginTop: theme.spacing(2), +})); + +const EmptyState = styled(Typography)(({ theme }) => ({ + color: theme.palette.gray[300], + padding: theme.spacing(1, 0), +})); + +export const NotificationMenu = ({ + anchorEl, + open, + onClose, + notifications, + onMarkAsRead, +}: NotificationMenuProps): JSX.Element => { + return ( + + Notifications + {notifications.length === 0 ? ( + No notifications yet. + ) : ( + notifications.map((notification) => ( + + )) + )} + + ); +}; diff --git a/src/shared/navbar/notifications/types.ts b/src/shared/navbar/notifications/types.ts new file mode 100644 index 00000000..63d03327 --- /dev/null +++ b/src/shared/navbar/notifications/types.ts @@ -0,0 +1,9 @@ +export type NotificationItem = { + id: string; + userId: number; + title: string | null; + message: string; + type: string; + read: boolean; + createdAt: string; +};