mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||
AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||
import { NotificationIcon, getTypeAccent, resolveStickyStyle } from "@/components/generic/notificationDisplay";
|
||||
|
||||
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
|
||||
// explicit link_url from the "On Open" section — the type-based fallback
|
||||
// resolver in notificationDisplay.jsx points at client-only routes
|
||||
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
|
||||
export default function AdminStickyAnnouncementBar() {
|
||||
const navigate = useNavigate();
|
||||
const { stickyAnnouncement, markSeen } = useAdminNotifications();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
if (!stickyAnnouncement) return;
|
||||
await markSeen(stickyAnnouncement.notification_id);
|
||||
}, [stickyAnnouncement, markSeen]);
|
||||
|
||||
// Opening the dialog must NOT mark it seen — markSeen clears
|
||||
// stickyAnnouncement, which would unmount this component (dialog included)
|
||||
// before it ever shows. Only the X button dismisses/marks seen.
|
||||
const onClickBanner = useCallback(() => {
|
||||
if (!stickyAnnouncement) return;
|
||||
setDetailsOpen(true);
|
||||
}, [stickyAnnouncement]);
|
||||
|
||||
if (!stickyAnnouncement) return null;
|
||||
|
||||
const accentClass = getTypeAccent(stickyAnnouncement.type);
|
||||
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
|
||||
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
|
||||
|
||||
const openLink = () => {
|
||||
if (!linkUrl) return;
|
||||
if (linkUrl.startsWith("/")) navigate(linkUrl);
|
||||
else window.open(linkUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="status"
|
||||
className="w-full border-b shadow-sm px-4 md:px-6"
|
||||
>
|
||||
<div
|
||||
onClick={onClickBanner}
|
||||
className="w-full cursor-pointer bg-card rounded-none py-3 flex items-center justify-between gap-4"
|
||||
style={inlineStyle ?? undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
|
||||
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-snug truncate">
|
||||
{stickyAnnouncement.title || "Announcement"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
|
||||
{stickyAnnouncement.message || ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded shrink-0 ${accentClass}`}>
|
||||
<NotificationIcon type={stickyAnnouncement.type} className="h-4 w-4" />
|
||||
</div>
|
||||
{stickyAnnouncement.title || "Announcement"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<p className="whitespace-pre-wrap text-left pt-1 text-foreground">
|
||||
{stickyAnnouncement.message || ""}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Close</AlertDialogCancel>
|
||||
{linkUrl && (
|
||||
<AlertDialogAction onClick={openLink}>
|
||||
Open Link
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// ─── components/generic/Dialogs/UnsavedChangesDialog.jsx ─────────────────────
|
||||
import { useRef } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
/**
|
||||
* Generic "leave without saving?" prompt. Rendered by useUnsavedChangesGuard —
|
||||
* see that hook for the intended integration (drop its returned `dialog`
|
||||
* anywhere in the page JSX, no direct usage of this component needed).
|
||||
*/
|
||||
export function UnsavedChangesDialog({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title = "Unsaved changes",
|
||||
description = "You have unsaved changes. If you leave this page now, they will be lost.",
|
||||
confirmLabel = "Leave without saving",
|
||||
cancelLabel = "Stay on this page",
|
||||
}) {
|
||||
// Radix's AlertDialogAction/Cancel both auto-dismiss on click (firing
|
||||
// onOpenChange(false)) *in addition to* their own onClick. Without this
|
||||
// flag, clicking Action fires onConfirm() then onOpenChange(false) fires
|
||||
// onCancel() right behind it — the reset immediately undoes the confirm,
|
||||
// so "Leave without saving" silently does nothing.
|
||||
const confirmedRef = useRef(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
confirmedRef.current = true;
|
||||
onConfirm?.();
|
||||
};
|
||||
|
||||
const handleOpenChange = (next) => {
|
||||
if (next) return;
|
||||
if (confirmedRef.current) {
|
||||
confirmedRef.current = false;
|
||||
return;
|
||||
}
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +1,125 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||
AlertDialogContent, AlertDialogDescription,
|
||||
AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
||||
import { NotificationIcon, getTypeAccent, resolveNotificationLink, resolveStickyStyle } from "@/components/generic/notificationDisplay";
|
||||
|
||||
function resolveStickyStyle(data) {
|
||||
// Supports multiple possible shapes without tightly coupling to one admin UI.
|
||||
const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null;
|
||||
if (!style) return null;
|
||||
|
||||
const background = style.background ?? style.bg ?? style.backgroundColor ?? null;
|
||||
const text = style.text ?? style.color ?? style.foreground ?? null;
|
||||
const border = style.border ?? style.borderColor ?? null;
|
||||
|
||||
if (!background && !text && !border) return null;
|
||||
|
||||
return {
|
||||
...(background ? { backgroundColor: background } : null),
|
||||
...(text ? { color: text } : null),
|
||||
...(border ? { borderColor: border } : null),
|
||||
};
|
||||
// Explicit link_url (from the admin "On Open" section) always wins. Falls back
|
||||
// to the type-based resolver for broadcasts sent before that field existed.
|
||||
function resolveClickAction(stickyAnnouncement) {
|
||||
const explicitUrl = stickyAnnouncement.data?.linkUrl || null;
|
||||
if (explicitUrl) {
|
||||
return {
|
||||
label: "Open Link",
|
||||
go: (navigate) => (explicitUrl.startsWith("/")
|
||||
? navigate(explicitUrl)
|
||||
: window.open(explicitUrl, "_blank", "noopener,noreferrer")),
|
||||
};
|
||||
}
|
||||
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
|
||||
}
|
||||
|
||||
export default function StickyAnnouncementBar() {
|
||||
const navigate = useNavigate();
|
||||
const { stickyAnnouncement, markSeen } = useClientNotifications();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
if (!stickyAnnouncement) return;
|
||||
await markSeen(stickyAnnouncement.notification_id);
|
||||
}, [stickyAnnouncement, markSeen]);
|
||||
|
||||
const onClickBanner = useCallback(async () => {
|
||||
// Opening the dialog must NOT mark it seen — markSeen clears
|
||||
// stickyAnnouncement, which would unmount this component (dialog included)
|
||||
// before it ever shows. Only the X button dismisses/marks seen.
|
||||
const onClickBanner = useCallback(() => {
|
||||
if (!stickyAnnouncement) return;
|
||||
|
||||
const id = stickyAnnouncement.notification_id;
|
||||
await markSeen(id);
|
||||
|
||||
const link = resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
|
||||
if (link) await link.go(navigate);
|
||||
}, [stickyAnnouncement, markSeen, navigate]);
|
||||
setDetailsOpen(true);
|
||||
}, [stickyAnnouncement]);
|
||||
|
||||
if (!stickyAnnouncement) return null;
|
||||
|
||||
const accentClass = getTypeAccent(stickyAnnouncement.type);
|
||||
const inlineStyle = resolveStickyStyle(stickyAnnouncement.data);
|
||||
const clickAction = resolveClickAction(stickyAnnouncement);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-0 right-0 z-60 border-b shadow-sm px-4 md:px-6"
|
||||
style={{ top: 0 }}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
onClick={onClickBanner}
|
||||
className="w-full cursor-pointer bg-card border rounded-none px-4 py-3 flex items-start justify-between gap-4"
|
||||
style={inlineStyle ?? undefined}
|
||||
role="status"
|
||||
className="w-full border-b shadow-sm px-4 md:px-6"
|
||||
>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
|
||||
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-snug truncate">
|
||||
{stickyAnnouncement.title || "Announcement"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
|
||||
{stickyAnnouncement.message || ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
<div
|
||||
onClick={onClickBanner}
|
||||
className="w-full cursor-pointer bg-card rounded-none py-3 flex items-center justify-between gap-4"
|
||||
style={inlineStyle ?? undefined}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded ${accentClass}`}>
|
||||
<NotificationIcon type={stickyAnnouncement.type} className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold leading-snug truncate">
|
||||
{stickyAnnouncement.title || "Announcement"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug line-clamp-2">
|
||||
{stickyAnnouncement.message || ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full-content view — plain text info, or with an Open Link action
|
||||
when the announcement was created with a link (see AddNotificationBroadcast's
|
||||
"On Open" section). */}
|
||||
<AlertDialog open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded shrink-0 ${accentClass}`}>
|
||||
<NotificationIcon type={stickyAnnouncement.type} className="h-4 w-4" />
|
||||
</div>
|
||||
{stickyAnnouncement.title || "Announcement"}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<p className="whitespace-pre-wrap text-left pt-1 text-foreground">
|
||||
{stickyAnnouncement.message || ""}
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Close</AlertDialogCancel>
|
||||
{clickAction && (
|
||||
<AlertDialogAction onClick={() => clickAction.go(navigate)}>
|
||||
{clickAction.label}
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,26 @@ const TYPE_ACCENT = {
|
||||
assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
|
||||
};
|
||||
|
||||
// Shared by StickyAnnouncementBar (client) and AdminStickyAnnouncementBar —
|
||||
// supports multiple possible admin-authored shapes without tightly coupling
|
||||
// to one admin UI.
|
||||
export function resolveStickyStyle(data) {
|
||||
const style = data?.sticky_style ?? data?.stickyStyle ?? data?.stickyColors ?? data?.colors ?? null;
|
||||
if (!style) return null;
|
||||
|
||||
const background = style.background ?? style.bg ?? style.backgroundColor ?? null;
|
||||
const text = style.text ?? style.color ?? style.foreground ?? null;
|
||||
const border = style.border ?? style.borderColor ?? null;
|
||||
|
||||
if (!background && !text && !border) return null;
|
||||
|
||||
return {
|
||||
...(background ? { backgroundColor: background } : null),
|
||||
...(text ? { color: text } : null),
|
||||
...(border ? { borderColor: border } : null),
|
||||
};
|
||||
}
|
||||
|
||||
export function NotificationIcon({ type, className }) {
|
||||
const Icon = TYPE_ICON[type] ?? Bell;
|
||||
return <Icon className={cn("shrink-0", className)} />;
|
||||
|
||||
Reference in New Issue
Block a user