feat(notifications): add dedicated page and localized rendering
This commit is contained in:
@@ -22,6 +22,7 @@ import Tags from "./pages/Tags"
|
|||||||
import Reports from "./pages/Reports"
|
import Reports from "./pages/Reports"
|
||||||
import Timesheet from "./pages/Timesheet"
|
import Timesheet from "./pages/Timesheet"
|
||||||
import Logs from "./pages/Logs"
|
import Logs from "./pages/Logs"
|
||||||
|
import NotificationsPage from "./pages/Notifications"
|
||||||
|
|
||||||
const MainLayout = () => {
|
const MainLayout = () => {
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||||
@@ -66,6 +67,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: "/profile", element: <Profile /> },
|
{ path: "/profile", element: <Profile /> },
|
||||||
{ path: "/timesheet", element: <Timesheet /> },
|
{ path: "/timesheet", element: <Timesheet /> },
|
||||||
{ path: "/reports", element: <Reports /> },
|
{ path: "/reports", element: <Reports /> },
|
||||||
|
{ path: "/notifications", element: <NotificationsPage /> },
|
||||||
{ path: "/logs", element: <Logs /> },
|
{ path: "/logs", element: <Logs /> },
|
||||||
{ path: "/tags", element: <Tags /> },
|
{ path: "/tags", element: <Tags /> },
|
||||||
{ path: "/workspaces", element: <Workspaces /> },
|
{ path: "/workspaces", element: <Workspaces /> },
|
||||||
|
|||||||
@@ -1,105 +1,30 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react"
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "../../hooks/useTranslation"
|
import { Bell, CheckCheck, Loader2 } from "lucide-react";
|
||||||
import { cn } from "../../lib/utils"
|
|
||||||
import { useNotifications } from "../../context/NotificationsContext"
|
|
||||||
import type { NotificationItem } from "../../api/notifications"
|
|
||||||
import { Button } from "../ui/button"
|
|
||||||
|
|
||||||
const formatNotificationTimestamp = (value: string, locale: string) => {
|
import { NotificationList } from "./NotificationList";
|
||||||
const date = new Date(value)
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
if (Number.isNaN(date.getTime())) {
|
import { useNotifications } from "../../context/NotificationsContext";
|
||||||
return value
|
import { Button } from "../ui/button";
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
}).format(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotificationRow({
|
|
||||||
notification,
|
|
||||||
locale,
|
|
||||||
onClick,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
notification: NotificationItem
|
|
||||||
locale: string
|
|
||||||
onClick: (notification: NotificationItem) => void
|
|
||||||
onDelete: (notification: NotificationItem) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
|
|
||||||
notification.is_seen
|
|
||||||
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
|
||||||
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onClick(notification)}
|
|
||||||
className="flex min-w-0 flex-1 items-start gap-3 text-start"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
|
|
||||||
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
||||||
{notification.title || notification.type}
|
|
||||||
</p>
|
|
||||||
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
|
|
||||||
{formatNotificationTimestamp(notification.created_at, locale)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{notification.message ? (
|
|
||||||
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
void onDelete(notification)
|
|
||||||
}}
|
|
||||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
|
|
||||||
aria-label="Delete notification"
|
|
||||||
title="Delete notification"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const { t, lang } = useTranslation()
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const {
|
const {
|
||||||
notifications,
|
notifications,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
totalCount,
|
|
||||||
hasMore,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isLoadingMore,
|
|
||||||
loadMore,
|
|
||||||
markAllAsSeen,
|
markAllAsSeen,
|
||||||
deleteOne,
|
deleteOne,
|
||||||
handleNotificationClick,
|
handleNotificationClick,
|
||||||
} = useNotifications()
|
} = useNotifications();
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const unreadNotifications = useMemo(
|
||||||
|
() => notifications.filter((notification) => !notification.is_seen),
|
||||||
|
[notifications],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@@ -107,12 +32,12 @@ export function NotificationBell() {
|
|||||||
containerRef.current &&
|
containerRef.current &&
|
||||||
!containerRef.current.contains(event.target as Node)
|
!containerRef.current.contains(event.target as Node)
|
||||||
) {
|
) {
|
||||||
setIsOpen(false)
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside)
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={containerRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
@@ -136,8 +61,8 @@ export function NotificationBell() {
|
|||||||
{t.notifications?.title || "Notifications"}
|
{t.notifications?.title || "Notifications"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{t.notifications?.summary?.(totalCount, unreadCount) ||
|
{t.notifications?.summary?.(unreadNotifications.length, unreadCount) ||
|
||||||
`${totalCount} total, ${unreadCount} unread`}
|
`${unreadNotifications.length} total, ${unreadCount} unread`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -159,45 +84,31 @@ export function NotificationBell() {
|
|||||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||||
{t.notifications?.loading || "Loading notifications..."}
|
{t.notifications?.loading || "Loading notifications..."}
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className="px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400">
|
|
||||||
{t.notifications?.empty || "No notifications yet."}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notification) => (
|
<NotificationList
|
||||||
<NotificationRow
|
notifications={unreadNotifications}
|
||||||
key={notification.id}
|
emptyLabel={t.notifications?.emptyUnread || "No unread notifications."}
|
||||||
notification={notification}
|
onClick={(item) => void handleNotificationClick(item)}
|
||||||
locale={lang}
|
onDelete={(item) => void deleteOne(item)}
|
||||||
onClick={(item) => void handleNotificationClick(item)}
|
/>
|
||||||
onDelete={(item) => void deleteOne(item)}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasMore ? (
|
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
|
||||||
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="outline"
|
||||||
variant="outline"
|
className="w-full"
|
||||||
className="w-full"
|
onClick={() => {
|
||||||
onClick={() => void loadMore()}
|
setIsOpen(false);
|
||||||
disabled={isLoadingMore}
|
navigate("/notifications");
|
||||||
>
|
}}
|
||||||
{isLoadingMore ? (
|
>
|
||||||
<>
|
{t.notifications?.viewAll || "View all notifications"}
|
||||||
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
</Button>
|
||||||
{t.notifications?.loadingMore || "Loading more..."}
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t.notifications?.loadMore || "Load more"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/components/notifications/NotificationList.tsx
Normal file
105
src/components/notifications/NotificationList.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import type { NotificationItem } from "../../api/notifications";
|
||||||
|
import { useTranslation } from "../../hooks/useTranslation";
|
||||||
|
import { presentNotification } from "../../lib/notificationPresenter";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const formatNotificationTimestamp = (value: string, locale: string) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NotificationList({
|
||||||
|
notifications,
|
||||||
|
emptyLabel,
|
||||||
|
onClick,
|
||||||
|
onDelete,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
notifications: NotificationItem[];
|
||||||
|
emptyLabel: string;
|
||||||
|
onClick: (notification: NotificationItem) => void;
|
||||||
|
onDelete: (notification: NotificationItem) => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { t, lang } = useTranslation();
|
||||||
|
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn("px-4 py-12 text-center text-sm text-slate-500 dark:text-slate-400", className)}>
|
||||||
|
{emptyLabel}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{notifications.map((notification) => {
|
||||||
|
const presented = presentNotification(notification, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className={cn(
|
||||||
|
"border-b border-slate-100 px-4 py-3 transition-colors dark:border-slate-800",
|
||||||
|
notification.is_seen
|
||||||
|
? "bg-white hover:bg-slate-50 dark:bg-slate-900 dark:hover:bg-slate-800/80"
|
||||||
|
: "bg-sky-50/70 hover:bg-sky-100/70 dark:bg-sky-500/10 dark:hover:bg-sky-500/15",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClick(notification)}
|
||||||
|
className="flex min-w-0 flex-1 items-start gap-3 text-start"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full",
|
||||||
|
notification.is_seen ? "bg-slate-300 dark:bg-slate-700" : "bg-sky-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
{presented.title}
|
||||||
|
</p>
|
||||||
|
<span className="shrink-0 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{formatNotificationTimestamp(notification.created_at, lang)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{presented.message ? (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{presented.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
void onDelete(notification);
|
||||||
|
}}
|
||||||
|
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:text-slate-500 dark:hover:bg-red-950/40 dark:hover:text-red-400"
|
||||||
|
aria-label={t.notifications?.deleteLabel || "Delete notification"}
|
||||||
|
title={t.notifications?.deleteLabel || "Delete notification"}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type NotificationLevel,
|
type NotificationLevel,
|
||||||
} from "../api/notifications"
|
} from "../api/notifications"
|
||||||
import { useTranslation } from "../hooks/useTranslation"
|
import { useTranslation } from "../hooks/useTranslation"
|
||||||
|
import { presentNotification } from "../lib/notificationPresenter"
|
||||||
import {
|
import {
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
SESSION_CHANGED_EVENT,
|
SESSION_CHANGED_EVENT,
|
||||||
@@ -153,8 +154,9 @@ export function NotificationsProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
toastedNotificationIdsRef.current.add(notification.id)
|
toastedNotificationIdsRef.current.add(notification.id)
|
||||||
const notify = getToastMethod(notification.level)
|
const notify = getToastMethod(notification.level)
|
||||||
notify(notification.title || (t.notifications?.newTitle || "New notification"), {
|
const presented = presentNotification(notification, t)
|
||||||
description: notification.message || undefined,
|
notify(presented.title || (t.notifications?.newTitle || "New notification"), {
|
||||||
|
description: presented.message || undefined,
|
||||||
action: notification.action_url
|
action: notification.action_url
|
||||||
? {
|
? {
|
||||||
label: t.notifications?.openAction || "Open",
|
label: t.notifications?.openAction || "Open",
|
||||||
|
|||||||
78
src/lib/notificationPresenter.ts
Normal file
78
src/lib/notificationPresenter.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { NotificationItem } from "../api/notifications";
|
||||||
|
|
||||||
|
const roleLabel = (role: unknown, dictionary: any) => {
|
||||||
|
if (typeof role !== "string" || !role) return "";
|
||||||
|
return dictionary?.workspace?.roles?.[role] || role;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const presentNotification = (notification: NotificationItem, dictionary: any) => {
|
||||||
|
const notifications = dictionary?.notifications;
|
||||||
|
const meta = notification.meta || {};
|
||||||
|
const workspaceName = typeof meta.workspace_name === "string" ? meta.workspace_name : "";
|
||||||
|
const actorName = typeof meta.actor_name === "string" ? meta.actor_name : "";
|
||||||
|
const exportType = typeof meta.export_type === "string" ? meta.export_type : "report";
|
||||||
|
const fileName = typeof meta.file_name === "string" ? meta.file_name : null;
|
||||||
|
const previousRole = roleLabel(meta.previous_role, dictionary);
|
||||||
|
const newRole = roleLabel(meta.new_role, dictionary);
|
||||||
|
|
||||||
|
switch (notification.type) {
|
||||||
|
case "workspace_membership_added":
|
||||||
|
return {
|
||||||
|
title: notifications?.workspaceMembershipAddedTitle || notification.title || notification.type,
|
||||||
|
message:
|
||||||
|
notifications?.workspaceMembershipAddedMessage?.(actorName, workspaceName, newRole) ||
|
||||||
|
notification.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
case "workspace_membership_role_changed":
|
||||||
|
return {
|
||||||
|
title: notifications?.workspaceMembershipRoleChangedTitle || notification.title || notification.type,
|
||||||
|
message:
|
||||||
|
notifications?.workspaceMembershipRoleChangedMessage?.(
|
||||||
|
actorName,
|
||||||
|
workspaceName,
|
||||||
|
previousRole,
|
||||||
|
newRole,
|
||||||
|
) ||
|
||||||
|
notification.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
case "workspace_membership_deactivated":
|
||||||
|
return {
|
||||||
|
title: notifications?.workspaceMembershipDeactivatedTitle || notification.title || notification.type,
|
||||||
|
message:
|
||||||
|
notifications?.workspaceMembershipDeactivatedMessage?.(actorName, workspaceName) ||
|
||||||
|
notification.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
case "workspace_membership_removed":
|
||||||
|
return {
|
||||||
|
title: notifications?.workspaceMembershipRemovedTitle || notification.title || notification.type,
|
||||||
|
message:
|
||||||
|
notifications?.workspaceMembershipRemovedMessage?.(actorName, workspaceName) ||
|
||||||
|
notification.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
case "report_export_ready":
|
||||||
|
return {
|
||||||
|
title: notifications?.reportExportReadyTitle || notification.title || notification.type,
|
||||||
|
message:
|
||||||
|
notifications?.reportExportReadyMessage?.(exportType, workspaceName, fileName) ||
|
||||||
|
notification.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
case "report_export_failed":
|
||||||
|
return {
|
||||||
|
title: notifications?.reportExportFailedTitle || notification.title || notification.type,
|
||||||
|
message:
|
||||||
|
notifications?.reportExportFailedMessage?.(exportType, workspaceName) ||
|
||||||
|
notification.message ||
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: notification.title || notification.type,
|
||||||
|
message: notification.message || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -581,12 +581,18 @@ export const en = {
|
|||||||
|
|
||||||
notifications: {
|
notifications: {
|
||||||
title: "Notifications",
|
title: "Notifications",
|
||||||
|
pageDescription: "Review all notifications and export updates.",
|
||||||
open: "Open notifications",
|
open: "Open notifications",
|
||||||
empty: "No notifications yet.",
|
empty: "No notifications yet.",
|
||||||
|
emptyUnread: "No unread notifications.",
|
||||||
loading: "Loading notifications...",
|
loading: "Loading notifications...",
|
||||||
loadingMore: "Loading more...",
|
loadingMore: "Loading more...",
|
||||||
loadMore: "Load more",
|
loadMore: "Load more",
|
||||||
markAllRead: "Mark all as read",
|
markAllRead: "Mark all as read",
|
||||||
|
viewAll: "View all notifications",
|
||||||
|
totalLabel: "Total notifications",
|
||||||
|
unreadLabel: "Unread notifications",
|
||||||
|
deleteLabel: "Delete notification",
|
||||||
markSeenError: "Failed to update notification",
|
markSeenError: "Failed to update notification",
|
||||||
markAllError: "Failed to update notifications",
|
markAllError: "Failed to update notifications",
|
||||||
deleteError: "Failed to delete notification",
|
deleteError: "Failed to delete notification",
|
||||||
@@ -595,5 +601,23 @@ export const en = {
|
|||||||
newTitle: "New notification",
|
newTitle: "New notification",
|
||||||
openAction: "Open",
|
openAction: "Open",
|
||||||
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
|
summary: (total: number, unread: number) => `${total} total, ${unread} unread`,
|
||||||
|
workspaceMembershipAddedTitle: "Added to workspace",
|
||||||
|
workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) =>
|
||||||
|
`${actor} added you to ${workspace} as ${role}.`,
|
||||||
|
workspaceMembershipRoleChangedTitle: "Workspace role changed",
|
||||||
|
workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) =>
|
||||||
|
`${actor} changed your role in ${workspace} from ${previousRole} to ${newRole}.`,
|
||||||
|
workspaceMembershipDeactivatedTitle: "Workspace access deactivated",
|
||||||
|
workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) =>
|
||||||
|
`${actor} deactivated your access to ${workspace}.`,
|
||||||
|
workspaceMembershipRemovedTitle: "Removed from workspace",
|
||||||
|
workspaceMembershipRemovedMessage: (actor: string, workspace: string) =>
|
||||||
|
`${actor} removed you from ${workspace}.`,
|
||||||
|
reportExportReadyTitle: "Report export is ready",
|
||||||
|
reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) =>
|
||||||
|
`Your ${exportType.toUpperCase()} report for ${workspace} is ready${fileName ? `: ${fileName}` : ""}.`,
|
||||||
|
reportExportFailedTitle: "Report export failed",
|
||||||
|
reportExportFailedMessage: (exportType: string, workspace: string) =>
|
||||||
|
`Your ${exportType.toUpperCase()} report for ${workspace} could not be generated.`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,12 +575,18 @@ export const fa = {
|
|||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
title: "اعلانها",
|
title: "اعلانها",
|
||||||
|
pageDescription: "مرور همه اعلانها و وضعیت خروجیهای گزارش.",
|
||||||
open: "باز کردن اعلانها",
|
open: "باز کردن اعلانها",
|
||||||
empty: "هنوز اعلانی وجود ندارد.",
|
empty: "هنوز اعلانی وجود ندارد.",
|
||||||
|
emptyUnread: "اعلان خواندهنشدهای وجود ندارد.",
|
||||||
loading: "در حال بارگذاری اعلانها...",
|
loading: "در حال بارگذاری اعلانها...",
|
||||||
loadingMore: "در حال بارگذاری بیشتر...",
|
loadingMore: "در حال بارگذاری بیشتر...",
|
||||||
loadMore: "بارگذاری بیشتر",
|
loadMore: "بارگذاری بیشتر",
|
||||||
markAllRead: "خواندن همه",
|
markAllRead: "خواندن همه",
|
||||||
|
viewAll: "نمایش همه اعلانها",
|
||||||
|
totalLabel: "مجموع اعلانها",
|
||||||
|
unreadLabel: "اعلانهای خواندهنشده",
|
||||||
|
deleteLabel: "حذف اعلان",
|
||||||
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
markSeenError: "بهروزرسانی اعلان با خطا مواجه شد.",
|
||||||
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
markAllError: "بهروزرسانی اعلانها با خطا مواجه شد.",
|
||||||
deleteError: "حذف اعلان با خطا مواجه شد.",
|
deleteError: "حذف اعلان با خطا مواجه شد.",
|
||||||
@@ -589,5 +595,23 @@ export const fa = {
|
|||||||
newTitle: "اعلان جدید",
|
newTitle: "اعلان جدید",
|
||||||
openAction: "باز کردن",
|
openAction: "باز کردن",
|
||||||
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
summary: (total: number, unread: number) => `${total} کل، ${unread} خواندهنشده`,
|
||||||
|
workspaceMembershipAddedTitle: "به ورکاسپیس اضافه شدید",
|
||||||
|
workspaceMembershipAddedMessage: (actor: string, workspace: string, role: string) =>
|
||||||
|
`${actor} شما را با نقش ${role} به ${workspace} اضافه کرد.`,
|
||||||
|
workspaceMembershipRoleChangedTitle: "نقش شما در ورکاسپیس تغییر کرد",
|
||||||
|
workspaceMembershipRoleChangedMessage: (actor: string, workspace: string, previousRole: string, newRole: string) =>
|
||||||
|
`${actor} نقش شما را در ${workspace} از ${previousRole} به ${newRole} تغییر داد.`,
|
||||||
|
workspaceMembershipDeactivatedTitle: "دسترسی ورکاسپیس غیرفعال شد",
|
||||||
|
workspaceMembershipDeactivatedMessage: (actor: string, workspace: string) =>
|
||||||
|
`${actor} دسترسی شما به ${workspace} را غیرفعال کرد.`,
|
||||||
|
workspaceMembershipRemovedTitle: "از ورکاسپیس حذف شدید",
|
||||||
|
workspaceMembershipRemovedMessage: (actor: string, workspace: string) =>
|
||||||
|
`${actor} شما را از ${workspace} حذف کرد.`,
|
||||||
|
reportExportReadyTitle: "خروجی گزارش آماده است",
|
||||||
|
reportExportReadyMessage: (exportType: string, workspace: string, fileName?: string | null) =>
|
||||||
|
`خروجی ${exportType.toUpperCase()} گزارش ${workspace}${fileName ? ` با نام ${fileName}` : ""} آماده دانلود است.`,
|
||||||
|
reportExportFailedTitle: "خروجی گزارش ناموفق بود",
|
||||||
|
reportExportFailedMessage: (exportType: string, workspace: string) =>
|
||||||
|
`تولید خروجی ${exportType.toUpperCase()} گزارش ${workspace} با خطا مواجه شد.`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
101
src/pages/Notifications.tsx
Normal file
101
src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { CheckCheck, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { NotificationList } from "../components/notifications/NotificationList";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { useNotifications } from "../context/NotificationsContext";
|
||||||
|
import { useTranslation } from "../hooks/useTranslation";
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
notifications,
|
||||||
|
unreadCount,
|
||||||
|
totalCount,
|
||||||
|
hasMore,
|
||||||
|
isLoading,
|
||||||
|
isLoadingMore,
|
||||||
|
loadMore,
|
||||||
|
markAllAsSeen,
|
||||||
|
deleteOne,
|
||||||
|
handleNotificationClick,
|
||||||
|
} = useNotifications();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-5 p-4 md:p-6">
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||||
|
{t.notifications?.title || "Notifications"}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{t.notifications?.pageDescription || "Review all notifications and export updates."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void markAllAsSeen()}
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-4 w-4" />
|
||||||
|
{t.notifications?.markAllRead || "Mark all as read"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||||
|
{t.notifications?.totalLabel || "Total notifications"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{totalCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
|
||||||
|
{t.notifications?.unreadLabel || "Unread notifications"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-3xl font-bold text-slate-900 dark:text-white">{unreadCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center px-4 py-12 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||||
|
{t.notifications?.loading || "Loading notifications..."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<NotificationList
|
||||||
|
notifications={notifications}
|
||||||
|
emptyLabel={t.notifications?.empty || "No notifications yet."}
|
||||||
|
onClick={(item) => void handleNotificationClick(item)}
|
||||||
|
onDelete={(item) => void deleteOne(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore ? (
|
||||||
|
<div className="border-t border-slate-100 p-3 dark:border-slate-800">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="me-2 h-4 w-4 animate-spin" />
|
||||||
|
{t.notifications?.loadingMore || "Loading more..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t.notifications?.loadMore || "Load more"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user