Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43906a0
[MISC][CLEANUP] remove useless line
Notgoyome Feb 15, 2026
3850d31
[NOTIFICATIONS][FEATURE]: add notifications inbox
Notgoyome Feb 15, 2026
d3d7ed1
[MISC][FEATURE]: add dev docker compose to get hot reload
Notgoyome Feb 15, 2026
2091eb5
[CONFIG][FIX]: update BASE URL fallback to use localhost for OpenAPI
Notgoyome Feb 22, 2026
90a2826
[NOTIFICATIONS][FEATURE]: add NotificationsService and NotificationTe…
Notgoyome Feb 22, 2026
2276177
[NOTIFICATIONS][FEATURE]: better notifications and reading/init notif…
Notgoyome Feb 22, 2026
483d4e5
[NOTIFICATIONS][REFACTO]: refactor notification read handling and imp…
Notgoyome Feb 28, 2026
a4c93d2
[NOTIFICATIONS][FEAT]: api generate
Notgoyome Feb 28, 2026
c6cf3cd
[NOTIFICATIONS][CLEANUP]: remove comments
Notgoyome Feb 28, 2026
14b2266
[NOTIFICATIONS][item]:notification item as a component for menu lisib…
Notgoyome Feb 28, 2026
b179283
[DOCS][UPDATE]: clean readme
Notgoyome Feb 28, 2026
9019972
[NOTIFICATIONS][FEAT]: error handling
Notgoyome Feb 28, 2026
d25454d
[NOTIFICATIONS][FEAT]: replace Typography with MenuItem for better in…
Notgoyome Feb 28, 2026
df060f3
[CONFIG][UPDATE]: change default BASE URL to "none"
Notgoyome Feb 28, 2026
58d34b3
[NOTIFICATIONS][UPDATE]: remove cursor pointer style from Notificatio…
Notgoyome Feb 28, 2026
465c254
[NOTIFICATIONS][UPDATE]: add error handling for marking notifications…
Notgoyome Feb 28, 2026
6f7ade7
[NOTIFICATIONS][UPDATE]: refactor NotificationEntry to use MenuItem a…
Notgoyome Feb 28, 2026
b9328ea
[API][FIX] revert
Notgoyome Mar 1, 2026
31b971b
[NOTIFICATIONS][UPDATE]: refactor backend URL retrieval to use OpenAP…
Notgoyome Mar 1, 2026
5c88326
[NOTIFICATIONS][UPDATE]: removed eslint disable next line
Notgoyome Mar 1, 2026
a4ff490
[DEV][REMOVE]: delete docker-compose.dev.yml file
Notgoyome Mar 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
17 changes: 4 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -69,4 +68,4 @@
"vite": "^6.2.0"
},
"packageManager": "yarn@4.9.1+sha512.f95ce356460e05be48d66401c1ae64ef84d163dd689964962c6888a9810865e39097a5e9de748876c2e0bf89b232d583c33982773e9903ae7a76257270986538"
}
}
2 changes: 1 addition & 1 deletion src/api/core/OpenAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
1 change: 0 additions & 1 deletion src/api/models/AuthResponseDto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@
/* eslint-disable */
export type AuthResponseDto = {
access_token: string;
refresh_token: string;
};

19 changes: 19 additions & 0 deletions src/api/models/NotificationTestDto.ts
Original file line number Diff line number Diff line change
@@ -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;
};

54 changes: 54 additions & 0 deletions src/api/services/NotificationsService.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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<any> {
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<any> {
return __request(OpenAPI, {
method: 'PATCH',
url: '/notifications/read-all',
});
}
}
3 changes: 3 additions & 0 deletions src/assets/inbox.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions src/errors/NotificationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class NotificationError extends Error {
constructor(message: string) {
super(message);
this.name = "NotificationError";
}
}
2 changes: 0 additions & 2 deletions src/shared/authOverlay/AuthOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,6 @@ const AuthOverlay: FC<AuthOverlayProps> = ({ 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),
Expand Down
2 changes: 2 additions & 0 deletions src/shared/navbar/NavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -54,6 +55,7 @@ const NavBar: React.FC = () => {

<Right>
<NavElem to="/friends">Friends</NavElem>
<NotificationBox />
{user ? <NavProfile /> : <Login forceShowAuthOverlay={forceShowAuthOverlay} setForceShowAuthOverlay={setForceShowAuthOverlay} />}
</Right>
</Nav >
Expand Down
139 changes: 139 additions & 0 deletions src/shared/navbar/notifications/NotificationBox.tsx
Original file line number Diff line number Diff line change
@@ -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<NotificationItem[]>([]);
const [showMenu, setShowMenu] = useState(false);
const [anchorEl, setAnchorEl] = useState<HTMLElement | undefined>(undefined);
const socketRef = useRef<WebSocket | null>(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<string>) => {
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<HTMLElement>) => {
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 (
<>
<NotificationButton onClick={handleClick} disabled={!userId}>
<Badge badgeContent={unreadCount} color="error">
<InfoIcon />
</Badge>
</NotificationButton>
{showMenu && (
<NotificationMenu
anchorEl={anchorEl}
open={showMenu}
onClose={handleClose}
notifications={notifications}
onMarkAsRead={handleMarkAsRead}
/>
)}
</>
);
};
Loading