Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions cinetrack-app/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dist-ssr
!.env.example
infra/data
server/benchmarks
server/scripts

# Editor directories and files
.vscode/*
Expand Down
2 changes: 2 additions & 0 deletions cinetrack-app/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { useDemoWelcome } from "./hooks/useDemoWelcome";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { useWatchlistInit } from "./store/useWatchlistStore";
import { useNotificationInit } from "./store/useNotificationStore";

const AuthPage = lazy(() => import("./pages/AuthPage").then((m) => ({ default: m.AuthPage })));

const WatchlistInitializer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
useWatchlistInit();
useNotificationInit();
return <>{children}</>;
};

Expand Down
147 changes: 122 additions & 25 deletions cinetrack-app/client/src/components/common/NotificationsModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import React from "react";
import { FiBell, FiX } from "react-icons/fi";
import { FiBell, FiX, FiCheck, FiTrash2, FiInfo, FiAward, FiTv } from "react-icons/fi";
import { useNotificationStore } from "../../store/useNotificationStore";
import type { Notification } from "../../services/dbService";

interface NotificationsModalProps {
isOpen: boolean;
onClose: () => void;
}

const timeAgo = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);

if (seconds < 60) return "Just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
};

const NotificationIcon = ({ type }: { type: Notification["type"] }) => {
switch (type) {
case "milestone":
return <FiAward className="text-yellow-400 w-5 h-5" />;
case "premiere":
return <FiTv className="text-purple-400 w-5 h-5" />;
default:
return <FiInfo className="text-blue-400 w-5 h-5" />;
}
};

export const NotificationsModal: React.FC<NotificationsModalProps> = ({ isOpen, onClose }) => {
const { notifications, markAsRead, markAllAsRead, removeNotification } = useNotificationStore();

if (!isOpen) return null;

return (
Expand All @@ -17,39 +46,107 @@ export const NotificationsModal: React.FC<NotificationsModalProps> = ({ isOpen,
role="dialog"
>
<div
className="backdrop-blur-xl bg-white/5 border border-white/10 rounded-3xl shadow-2xl w-full max-w-sm p-6 text-brand-text-light"
className="backdrop-blur-xl bg-brand-surface border border-white/10 rounded-3xl shadow-2xl w-full max-w-md h-[80vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-white/10">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<FiBell className="h-5 w-5 text-brand-primary" />
Notifications
</h2>
<button
onClick={onClose}
className="text-brand-text-dim hover:text-white transition-colors p-1 hover:bg-white/10 rounded-lg"
aria-label="Close"
>
<FiX className="h-5 w-5" />
</button>
</div>

<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="w-16 h-16 rounded-full bg-brand-primary/20 flex items-center justify-center mb-4">
<FiBell className="h-8 w-8 text-brand-primary" />
<div className="flex items-center gap-2">
{notifications.some((n) => !n.isRead) && (
<button
onClick={() => markAllAsRead()}
className="text-xs font-medium text-brand-primary hover:text-brand-primary/80 transition-colors px-3 py-1.5 rounded-full bg-brand-primary/10"
>
Mark all read
</button>
)}
<button
onClick={onClose}
className="text-brand-text-dim hover:text-white transition-colors p-1 hover:bg-white/10 rounded-lg"
aria-label="Close"
>
<FiX className="h-5 w-5" />
</button>
</div>
<h3 className="text-lg font-semibold text-white mb-2">Coming Soon</h3>
<p className="text-brand-text-dim text-sm max-w-xs">
Notifications are not available yet. Work in progress!!
</p>
</div>

<button
onClick={onClose}
className="w-full mt-4 py-2.5 px-4 rounded-xl font-medium bg-white/10 hover:bg-white/20 text-white transition-colors"
>
Got it
</button>
{/* List */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{notifications.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-8">
<div className="w-16 h-16 rounded-full bg-brand-surface/50 flex items-center justify-center mb-4 text-brand-text-dim">
<FiBell className="h-8 w-8" />
</div>
<p className="text-brand-text-dim">No notifications yet</p>
</div>
) : (
notifications.map((n) => (
<div
key={n._id}
className={`relative group p-4 rounded-xl border transition-all duration-200 ${
n.isRead
? "bg-transparent border-transparent hover:bg-white/5"
: "bg-white/5 border-brand-primary/20 hover:border-brand-primary/40"
}`}
>
<div className="flex gap-4">
<div
className={`mt-1 p-2 rounded-full h-fit ${n.isRead ? "bg-white/5" : "bg-brand-primary/10"}`}
>
<NotificationIcon type={n.type} />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-start gap-2">
<h4
className={`font-medium truncate pr-6 ${n.isRead ? "text-brand-text-dim" : "text-white"}`}
>
{n.title}
</h4>
<span className="text-xs text-brand-text-dim shrink-0 whitespace-nowrap">
{timeAgo(n.createdAt)}
</span>
</div>
<p
className={`text-sm mt-1 leading-relaxed ${n.isRead ? "text-brand-text-dim/70" : "text-brand-text-light"}`}
>
{n.message}
</p>
</div>
</div>

{/* Actions */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{!n.isRead && (
<button
onClick={(e) => {
e.stopPropagation();
markAsRead(n._id);
}}
className="p-1.5 rounded-lg text-brand-text-dim hover:text-brand-primary hover:bg-brand-primary/10 transition-colors"
title="Mark as read"
>
<FiCheck className="w-4 h-4" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
removeNotification(n._id);
}}
className="p-1.5 rounded-lg text-brand-text-dim hover:text-red-400 hover:bg-red-400/10 transition-colors"
title="Delete"
>
<FiTrash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
);
Expand Down
10 changes: 7 additions & 3 deletions cinetrack-app/client/src/components/layout/SideNavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { useAuthContext } from "../../contexts/AuthContext";
import { useNotificationStore } from "../../store/useNotificationStore";
import { ConfirmModal } from "../common/ConfirmModal";
import {
FiCompass,
Expand Down Expand Up @@ -63,6 +64,7 @@ export const SideNavBar: React.FC<SideNavBarProps> = ({
onOpenNotifications,
}) => {
const { user, logout } = useAuthContext();
const { unreadCount } = useNotificationStore();
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const [isHovered, setIsHovered] = useState(false);

Expand Down Expand Up @@ -156,9 +158,11 @@ export const SideNavBar: React.FC<SideNavBarProps> = ({
>
<FiBell className="w-5 h-5" />
{isExpanded && <span className="ml-3 text-sm font-medium">Notifications</span>}
<span
className={`absolute ${!isExpanded ? "top-2 right-2" : "top-2.5 left-6"} w-2 h-2 bg-brand-primary rounded-full`}
/>
{unreadCount > 0 && (
<span
className={`absolute ${!isExpanded ? "top-2 right-2" : "top-2.5 left-6"} w-2 h-2 bg-brand-primary rounded-full`}
/>
)}
</button>
<a
href="https://github.com/Alameen1433"
Expand Down
15 changes: 13 additions & 2 deletions cinetrack-app/client/src/layouts/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { Suspense, memo, useMemo } from "react";
import { Outlet, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import { FiSettings, FiSearch, FiBell, FiGithub, FiArrowLeft } from "react-icons/fi";
import { useWatchlistStore, getWatchlistIds } from "../store/useWatchlistStore";
import { useNotificationStore } from "../store/useNotificationStore";
import { useUIContext } from "../contexts/UIContext";
import { SearchBar } from "../components/common/SearchBar";
import { SearchPalette } from "../components/common/SearchPalette";
Expand Down Expand Up @@ -68,6 +69,8 @@ const Header: React.FC = memo(() => {
}
};

const unreadCount = useNotificationStore((state) => state.unreadCount);

return (
<header className="sticky top-0 z-20 bg-brand-bg/80 backdrop-blur-lg">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-4">
Expand Down Expand Up @@ -96,7 +99,11 @@ const Header: React.FC = memo(() => {
aria-label="Notifications"
>
<FiBell className="h-6 w-6" />
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-primary rounded-full" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-brand-primary rounded-full border-2 border-brand-bg">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-primary opacity-75"></span>
</span>
)}
</button>
<button
onClick={openSettings}
Expand Down Expand Up @@ -160,7 +167,11 @@ const Header: React.FC = memo(() => {
aria-label="Notifications"
>
<FiBell className="h-6 w-6" />
<span className="absolute top-1 right-1 w-2 h-2 bg-brand-primary rounded-full" />
{unreadCount > 0 && (
<span className="absolute top-1.5 right-1.5 w-2.5 h-2.5 bg-brand-primary rounded-full border-2 border-brand-bg">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-primary opacity-75"></span>
</span>
)}
</button>
<button
onClick={openSettings}
Expand Down
34 changes: 34 additions & 0 deletions cinetrack-app/client/src/services/dbService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,37 @@ export const getRecommendations = async (refresh = false): Promise<Recommendatio
`/watchlist/recommendations${refresh ? "?refresh=true" : ""}`
);
};

// --- Notifications ---

export interface Notification {
_id: string;
userId: string;
type: "system" | "milestone" | "premiere";
title: string;
message: string;
data?: any;
isRead: boolean;
createdAt: string;
}

export interface NotificationsResponse {
notifications: Notification[];
unreadCount: number;
}

export const getNotifications = async (): Promise<NotificationsResponse> => {
return apiFetch<NotificationsResponse>("/notifications");
};

export const markNotificationAsRead = async (id: string): Promise<void> => {
await apiFetch<void>(`/notifications/${id}/read`, { method: "PATCH" });
};

export const markAllNotificationsAsRead = async (): Promise<void> => {
await apiFetch<void>(`/notifications/read-all`, { method: "PATCH" });
};

export const deleteNotification = async (id: string): Promise<void> => {
await apiFetch<void>(`/notifications/${id}`, { method: "DELETE" });
};
14 changes: 14 additions & 0 deletions cinetrack-app/client/src/services/socketService.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { io, Socket } from "socket.io-client";
import type { WatchlistItem } from "../types/types";
import type { Notification } from "./dbService";

type WatchlistUpdateHandler = (item: WatchlistItem) => void;
type WatchlistDeleteHandler = (data: { id: number }) => void;
type WatchlistSyncHandler = (data: { trigger: string }) => void;
type NotificationHandler = (notification: Notification) => void;

class SocketService {
private socket: Socket | null = null;
private updateHandlers: WatchlistUpdateHandler[] = [];
private deleteHandlers: WatchlistDeleteHandler[] = [];
private syncHandlers: WatchlistSyncHandler[] = [];
private notificationHandlers: NotificationHandler[] = [];

connect() {
if (this.socket?.connected) return;
Expand Down Expand Up @@ -47,6 +50,10 @@ class SocketService {
this.socket.on("watchlist:sync", (data: { trigger: string }) => {
this.syncHandlers.forEach((handler) => handler(data));
});

this.socket.on("notification:new", (notification: Notification) => {
this.notificationHandlers.forEach((handler) => handler(notification));
});
}

disconnect() {
Expand Down Expand Up @@ -77,6 +84,13 @@ class SocketService {
};
}

onNotification(handler: NotificationHandler) {
this.notificationHandlers.push(handler);
return () => {
this.notificationHandlers = this.notificationHandlers.filter((h) => h !== handler);
};
}

isConnected(): boolean {
return this.socket?.connected ?? false;
}
Expand Down
Loading