mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
updated some of things
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
// components/generic/CMS/AddBlockMenu.jsx
|
// components/generic/CMS/AddBlockMenu.jsx
|
||||||
|
|
||||||
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText, FileUp } from "lucide-react";
|
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -60,12 +60,6 @@ const BLOCK_TYPES = [
|
|||||||
description: "Rich text written in Markdown",
|
description: "Rich text written in Markdown",
|
||||||
icon: <FileText className="h-4 w-4" />,
|
icon: <FileText className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "document",
|
|
||||||
label: "Document",
|
|
||||||
description: "Upload a PDF/PPTX only, auto-convert to Markdown",
|
|
||||||
icon: <FileUp className="h-4 w-4" />,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AddBlockMenu({ onAdd }) {
|
export function AddBlockMenu({ onAdd }) {
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { goToLink } from "@/components/generic/notificationDisplay";
|
import { goToLink } from "@/components/generic/notificationDisplay";
|
||||||
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
|
||||||
|
|
||||||
const ROTATE_INTERVAL_MS = 6000;
|
|
||||||
|
|
||||||
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
|
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
|
||||||
// explicit link_url from the "On Open" section — the type-based fallback
|
// explicit link_url from the "On Open" section — the type-based fallback
|
||||||
@@ -24,47 +22,23 @@ function resolveClickAction(stickyAnnouncement) {
|
|||||||
|
|
||||||
export default function AdminStickyAnnouncementBar() {
|
export default function AdminStickyAnnouncementBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { stickyAnnouncements, bannerImage, markSeen } = useAdminNotifications();
|
const { stickyAnnouncements, markSeen } = useAdminNotifications();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
const count = stickyAnnouncements.length;
|
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
|
||||||
// Derived rather than clamped via effect — safe the instant the array
|
// it (X on the bar) reveals whichever is next in the queue.
|
||||||
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
|
const current = stickyAnnouncements[0];
|
||||||
const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (count <= 1) return;
|
|
||||||
const id = setInterval(() => {
|
|
||||||
setActiveIndex((i) => (i + 1) % count);
|
|
||||||
}, ROTATE_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
const current = stickyAnnouncements[safeIndex];
|
|
||||||
|
|
||||||
const onDismiss = useCallback(async () => {
|
const onDismiss = useCallback(async () => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
await markSeen(current.notification_id);
|
await markSeen(current.notification_id);
|
||||||
}, [current, markSeen]);
|
}, [current, markSeen]);
|
||||||
|
|
||||||
// Opening the dialog must NOT mark it seen — markSeen removes the row from
|
|
||||||
// stickyAnnouncements, which would unmount this component (dialog included)
|
|
||||||
// before it ever shows.
|
|
||||||
const onClickBanner = useCallback(() => {
|
const onClickBanner = useCallback(() => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
}, [current]);
|
}, [current]);
|
||||||
|
|
||||||
// Closing the details dialog (X, Escape, overlay click — any reason)
|
|
||||||
// dismisses whichever announcement was being viewed at the time. This is
|
|
||||||
// the only dismiss path when multiple are active (no per-item X on the bar
|
|
||||||
// itself — see the count > 1 branch below).
|
|
||||||
const onDialogOpenChange = useCallback((open) => {
|
|
||||||
setDetailsOpen(open);
|
|
||||||
if (!open) void onDismiss();
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
const swatch = getTierColor(current.color || "indigo").swatch;
|
const swatch = getTierColor(current.color || "indigo").swatch;
|
||||||
@@ -99,63 +73,38 @@ export default function AdminStickyAnnouncementBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{count > 1 && (
|
{/* Only way to dismiss a sticky alert — closing the details dialog
|
||||||
<div className="absolute right-2 flex items-center gap-1.5">
|
no longer dismisses it. */}
|
||||||
{stickyAnnouncements.map((a, i) => (
|
<div
|
||||||
<button
|
role="button"
|
||||||
key={a.notification_id}
|
tabIndex={0}
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
aria-label={`Show announcement ${i + 1}`}
|
e.preventDefault();
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
void onDismiss();
|
||||||
e.stopPropagation();
|
}}
|
||||||
setActiveIndex(i);
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
className="size-1.5 rounded-full transition-opacity"
|
e.preventDefault();
|
||||||
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
|
e.stopPropagation();
|
||||||
/>
|
void onDismiss();
|
||||||
))}
|
}}
|
||||||
</div>
|
aria-label="Dismiss sticky announcement"
|
||||||
)}
|
title="Dismiss"
|
||||||
|
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
{/* Dismiss-X only makes sense for a single active announcement —
|
style={{ color: textColor }}
|
||||||
with multiple, the dialog's own close button (shadcn Dialog)
|
>
|
||||||
is the way to close/step away, no per-item dismiss from the bar. */}
|
<X className="size-4" />
|
||||||
{count <= 1 && (
|
</div>
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void onDismiss();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Enter" && e.key !== " ") return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void onDismiss();
|
|
||||||
}}
|
|
||||||
aria-label="Dismiss sticky announcement"
|
|
||||||
title="Dismiss"
|
|
||||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
|
||||||
style={{ color: textColor }}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnnouncementCarouselDialog
|
<AnnouncementDetailsDialog
|
||||||
open={detailsOpen}
|
open={detailsOpen}
|
||||||
onOpenChange={onDialogOpenChange}
|
onOpenChange={setDetailsOpen}
|
||||||
announcements={stickyAnnouncements}
|
announcement={current}
|
||||||
activeIndex={safeIndex}
|
|
||||||
onIndexChange={setActiveIndex}
|
|
||||||
resolveClickAction={resolveClickAction}
|
resolveClickAction={resolveClickAction}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
bannerImage={bannerImage}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
|
||||||
|
|
||||||
// Shared details view for the sticky announcement bar (client + admin
|
|
||||||
// variants) — pages through every currently-active sticky announcement (max
|
|
||||||
// 3, see notificationBroadcasts.controller.js's countActiveSticky) with a
|
|
||||||
// segmented progress bar. bannerImage is ONE shared image for the whole set
|
|
||||||
// (not per-announcement) — see NotificationBroadcastList.jsx's banner picker
|
|
||||||
// and the sticky_banner_settings singleton.
|
|
||||||
export default function AnnouncementCarouselDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
announcements,
|
|
||||||
activeIndex,
|
|
||||||
onIndexChange,
|
|
||||||
resolveClickAction,
|
|
||||||
navigate,
|
|
||||||
bannerImage,
|
|
||||||
}) {
|
|
||||||
const current = announcements[activeIndex];
|
|
||||||
if (!current) return null;
|
|
||||||
|
|
||||||
const clickAction = resolveClickAction(current);
|
|
||||||
const imageSrc = resolveAssetSrc(bannerImage);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden gap-0">
|
|
||||||
<div className="grid sm:grid-cols-2">
|
|
||||||
<div className="p-6 flex flex-col gap-3 min-w-0">
|
|
||||||
<DialogHeader className="text-left">
|
|
||||||
<DialogTitle className="text-lg">
|
|
||||||
{current.title || "Announcement"}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription asChild>
|
|
||||||
<p className="whitespace-pre-wrap text-left text-foreground">
|
|
||||||
{current.message || ""}
|
|
||||||
</p>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2 pt-4">
|
|
||||||
{clickAction && (
|
|
||||||
<Button
|
|
||||||
className="self-start"
|
|
||||||
onClick={() => clickAction.go(navigate)}
|
|
||||||
>
|
|
||||||
{clickAction.label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{announcements.length > 1 && (
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{announcements.map((a, i) => (
|
|
||||||
<button
|
|
||||||
key={a.notification_id}
|
|
||||||
type="button"
|
|
||||||
aria-label={`Show announcement ${i + 1}`}
|
|
||||||
onClick={() => onIndexChange(i)}
|
|
||||||
className={[
|
|
||||||
"h-1.5 flex-1 rounded-full transition-colors",
|
|
||||||
i === activeIndex ? "bg-foreground" : "bg-muted-foreground/25 hover:bg-muted-foreground/40",
|
|
||||||
].join(" ")}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
|
||||||
{imageSrc ? (
|
|
||||||
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">No image</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
|
// Shared details view for the sticky announcement bar (client + admin
|
||||||
|
// variants). Only one sticky alert is ever shown at a time — no carousel,
|
||||||
|
// no autoplay. Optional per-alert layout image renders alongside the text
|
||||||
|
// when present, single column when not.
|
||||||
|
export default function AnnouncementDetailsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
announcement,
|
||||||
|
resolveClickAction,
|
||||||
|
navigate,
|
||||||
|
}) {
|
||||||
|
if (!announcement) return null;
|
||||||
|
|
||||||
|
const clickAction = resolveClickAction(announcement);
|
||||||
|
const imageSrc = announcement.image ? resolveAssetSrc(announcement.image) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className={["p-0 overflow-hidden gap-0", imageSrc ? "sm:max-w-2xl" : "sm:max-w-md"].join(" ")}>
|
||||||
|
<div className={imageSrc ? "grid sm:grid-cols-2" : ""}>
|
||||||
|
<div className="p-6 flex flex-col gap-3 min-w-0">
|
||||||
|
<DialogHeader className="text-left">
|
||||||
|
<DialogTitle className="text-lg">
|
||||||
|
{announcement.title || "Alert"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<p className="whitespace-pre-wrap text-left text-foreground">
|
||||||
|
{announcement.message || ""}
|
||||||
|
</p>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{clickAction && (
|
||||||
|
<div className="mt-auto pt-4">
|
||||||
|
<Button
|
||||||
|
className="self-start"
|
||||||
|
onClick={() => clickAction.go(navigate)}
|
||||||
|
>
|
||||||
|
{clickAction.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageSrc && (
|
||||||
|
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { formatPlayerTime } from "@/utils/format.util";
|
||||||
|
|
||||||
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
||||||
const DEBOUNCE_MS = 400;
|
const DEBOUNCE_MS = 400;
|
||||||
@@ -26,6 +27,10 @@ const EXT_OPTIONS = {
|
|||||||
function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
||||||
const directThumb = asset.thumbnail_url ?? asset.file_url;
|
const directThumb = asset.thumbnail_url ?? asset.file_url;
|
||||||
const thumb = streamSrc ?? directThumb;
|
const thumb = streamSrc ?? directThumb;
|
||||||
|
// duration comes straight off the asset row (ffprobe-derived at upload) —
|
||||||
|
// shows regardless of whether a thumbnail image loaded, since a missing
|
||||||
|
// preview shouldn't also mean losing the one other useful signal at a glance.
|
||||||
|
const showDuration = (asset.file_type === "video" || asset.file_type === "audio") && !!asset.duration;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -37,7 +42,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
|||||||
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
|
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<div className="aspect-video bg-muted w-full overflow-hidden">
|
<div className="relative aspect-video bg-muted w-full overflow-hidden">
|
||||||
{thumb ? (
|
{thumb ? (
|
||||||
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
|
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -45,6 +50,11 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
|||||||
<span className="text-xs text-muted-foreground">No preview</span>
|
<span className="text-xs text-muted-foreground">No preview</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showDuration && (
|
||||||
|
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white tabular-nums">
|
||||||
|
{formatPlayerTime(asset.duration)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<p className="text-xs font-medium truncate">{asset.display_name}</p>
|
<p className="text-xs font-medium truncate">{asset.display_name}</p>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
|
|||||||
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
|
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
|
||||||
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
|
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
|
||||||
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
|
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
|
||||||
import { DocumentBlock } from "./Blocks/Admin/DocumentBlock";
|
|
||||||
|
|
||||||
// ─── Block renderer ───────────────────────────────────────────────────────────
|
// ─── Block renderer ───────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -42,8 +41,6 @@ function BlockContent({ block, onUpdate }) {
|
|||||||
return <CodeBlock content={content} onUpdate={onUpdate} />;
|
return <CodeBlock content={content} onUpdate={onUpdate} />;
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
|
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
|
||||||
case "document":
|
|
||||||
return <DocumentBlock content={content} onUpdate={onUpdate} />;
|
|
||||||
default:
|
default:
|
||||||
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
|
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
|
||||||
}
|
}
|
||||||
@@ -60,7 +57,6 @@ export const DEFAULT_CONTENT = {
|
|||||||
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
|
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
|
||||||
"code": { language: "javascript", code: "" },
|
"code": { language: "javascript", code: "" },
|
||||||
"markdown": { body: "" },
|
"markdown": { body: "" },
|
||||||
"document": { source_asset_id: null, source_filename: null, source_ext: null, body: "" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { FileUp, FileText, RotateCcw, Check, X, Eye, Pencil } from "lucide-react";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
|
||||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
|
||||||
import { MarkdownBlock } from "./MarkdownBlock";
|
|
||||||
|
|
||||||
// Only these two — docx/xlsx are visible in the generic document library but
|
|
||||||
// out of scope for this block (see documentConversion.service.js on the backend).
|
|
||||||
const ALLOWED_EXTENSIONS = ["pdf", "pptx"];
|
|
||||||
|
|
||||||
const STAGES = [
|
|
||||||
{ phase: "compiling", label: "Compiling document" },
|
|
||||||
{ phase: "validating", label: "Validating content" },
|
|
||||||
{ phase: "generating", label: "Generating Markdown" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ─── Stage progress ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StageProgress({ phase }) {
|
|
||||||
const activeIndex = STAGES.findIndex((s) => s.phase === phase);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 py-2">
|
|
||||||
{STAGES.map((s, i) => {
|
|
||||||
const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending";
|
|
||||||
return (
|
|
||||||
<div key={s.phase} className="flex items-center gap-2 text-sm">
|
|
||||||
{state === "done" ? (
|
|
||||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
|
||||||
) : state === "active" ? (
|
|
||||||
<Spinner className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="h-3.5 w-3.5 rounded-full border shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className={cn(state === "pending" && "text-muted-foreground")}>{s.label}…</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── DocumentBlock (Admin) ─────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Pick (pdf/pptx only) -> Convert (compile/validate/automate, live stage
|
|
||||||
// progress over SSE) -> Draft review (rendered Markdown + warnings, explicit
|
|
||||||
// Insert/Discard). Nothing lands in `content` until "Insert into Lesson" is
|
|
||||||
// clicked — the conversion result is a draft, never written automatically.
|
|
||||||
// Once inserted, this hands off to MarkdownBlock's own editor for `body` so
|
|
||||||
// admins can hand-edit the generated Markdown before saving the lesson.
|
|
||||||
//
|
|
||||||
export function DocumentBlock({ content, onUpdate, readOnly = false }) {
|
|
||||||
const { convertAssetToMarkdown } = useAssets();
|
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
|
||||||
const [phase, setPhase] = useState(null); // null | 'compiling' | 'validating' | 'generating'
|
|
||||||
const [pendingAsset, setPendingAsset] = useState(null); // { asset_id, display_name, extension } — while converting
|
|
||||||
const [draft, setDraft] = useState(null); // { markdown, warnings, stats } — awaiting Insert/Discard
|
|
||||||
const [preview, setPreview] = useState(true);
|
|
||||||
|
|
||||||
const hasContent = !!content?.body?.trim();
|
|
||||||
|
|
||||||
const runConversion = async (asset) => {
|
|
||||||
setPendingAsset(asset);
|
|
||||||
setDraft(null);
|
|
||||||
setPhase("compiling");
|
|
||||||
|
|
||||||
const result = await convertAssetToMarkdown(asset.asset_id, {
|
|
||||||
onProgress: (data) => { if (data.phase && data.phase !== "done" && data.phase !== "error") setPhase(data.phase); },
|
|
||||||
});
|
|
||||||
|
|
||||||
setPhase(null);
|
|
||||||
if (result) {
|
|
||||||
setDraft({
|
|
||||||
markdown: result.markdown ?? "",
|
|
||||||
warnings: result.warnings ?? [],
|
|
||||||
stats: result.stats ?? null,
|
|
||||||
asset,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// convertAssetToMarkdown already toasted the backend's specific
|
|
||||||
// error (e.g. "No readable text found…") — just reset to picking.
|
|
||||||
setPendingAsset(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePick = (asset) => {
|
|
||||||
runConversion({ asset_id: asset.asset_id, display_name: asset.display_name, extension: asset.extension });
|
|
||||||
};
|
|
||||||
|
|
||||||
const insertDraft = () => {
|
|
||||||
if (!draft) return;
|
|
||||||
onUpdate({
|
|
||||||
source_asset_id: draft.asset.asset_id,
|
|
||||||
source_filename: draft.asset.display_name,
|
|
||||||
source_ext: draft.asset.extension,
|
|
||||||
body: draft.markdown,
|
|
||||||
});
|
|
||||||
setDraft(null);
|
|
||||||
setPendingAsset(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const discardDraft = () => {
|
|
||||||
setDraft(null);
|
|
||||||
setPendingAsset(null);
|
|
||||||
setPickerOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Already inserted: behave like a normal editable Markdown block ────────
|
|
||||||
if (hasContent && !draft) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{!readOnly && (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label>Document Import</Label>
|
|
||||||
{content.source_filename && (
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<FileText className="h-3 w-3" />
|
|
||||||
<span className="truncate max-w-[180px]">{content.source_filename}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPickerOpen(true)}
|
|
||||||
className="flex items-center gap-1 text-primary hover:underline"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3 w-3" />
|
|
||||||
Re-convert
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<MarkdownBlock content={content} onUpdate={onUpdate} readOnly={readOnly} />
|
|
||||||
{!readOnly && (
|
|
||||||
<AssetPickerSheet
|
|
||||||
open={pickerOpen}
|
|
||||||
onOpenChange={setPickerOpen}
|
|
||||||
fileType="document"
|
|
||||||
allowedExtensions={ALLOWED_EXTENSIONS}
|
|
||||||
onSelect={handlePick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Converting ──────────────────────────────────────────────────────────
|
|
||||||
if (phase) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Document Import</Label>
|
|
||||||
<div className="rounded-lg border p-4 space-y-1">
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{pendingAsset?.display_name}</p>
|
|
||||||
<StageProgress phase={phase} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Draft review ────────────────────────────────────────────────────────
|
|
||||||
if (draft) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Document Import — Draft</Label>
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b bg-muted/40">
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
|
||||||
Converted from <span className="font-medium">{draft.asset.display_name}</span> — review before inserting
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={preview ? "View raw Markdown" : "Preview"}
|
|
||||||
onClick={() => setPreview((p) => !p)}
|
|
||||||
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground shrink-0"
|
|
||||||
>
|
|
||||||
{preview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{draft.warnings.length > 0 && (
|
|
||||||
<div className="px-3 pt-2 space-y-1">
|
|
||||||
{draft.warnings.map((w, i) => (
|
|
||||||
<Alert key={i} className="py-1.5">
|
|
||||||
<AlertDescription className="text-xs">{w}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="min-h-[180px] max-h-[400px] overflow-y-auto px-3 py-3">
|
|
||||||
{preview ? (
|
|
||||||
<div className="typeset text-sm">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{draft.markdown}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<pre className="text-xs font-mono whitespace-pre-wrap">{draft.markdown}</pre>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-3 py-2 border-t bg-muted/40">
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={discardDraft} className="gap-1.5">
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Discard / Pick another
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" onClick={insertDraft} className="gap-1.5">
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
Insert into Lesson
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Empty: pick a file ─────────────────────────────────────────────────
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{!readOnly && <Label>Document Import</Label>}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPickerOpen(true)}
|
|
||||||
disabled={readOnly}
|
|
||||||
className="w-full py-8 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<FileUp className="h-8 w-8 text-muted-foreground/50" />
|
|
||||||
<p className="text-sm text-muted-foreground">Click to select a PDF or PPTX</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70">Text is extracted and converted to Markdown automatically — no images/OCR.</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{!readOnly && (
|
|
||||||
<AssetPickerSheet
|
|
||||||
open={pickerOpen}
|
|
||||||
onOpenChange={setPickerOpen}
|
|
||||||
fileType="document"
|
|
||||||
allowedExtensions={ALLOWED_EXTENSIONS}
|
|
||||||
onSelect={handlePick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { MarkdownBlock } from "./MarkdownBlock";
|
|
||||||
|
|
||||||
// The converted output is just Markdown by the time it reaches the client —
|
|
||||||
// no document-specific rendering needed, just delegate straight through.
|
|
||||||
export function DocumentBlock({ content }) {
|
|
||||||
return <MarkdownBlock content={{ body: content?.body ?? "" }} />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// ─── components/DemoteAdminDialog.jsx ────────────────────────────────────────
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms removing Administrator access from a single user.
|
||||||
|
*
|
||||||
|
* @param {Function} onDemoteAdmin (entity) => Promise
|
||||||
|
*/
|
||||||
|
export function DemoteAdminDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
entity,
|
||||||
|
onDemoteAdmin,
|
||||||
|
loading,
|
||||||
|
onSuccess,
|
||||||
|
}) {
|
||||||
|
const displayName = entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user";
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
const res = await onDemoteAdmin(entity);
|
||||||
|
if (res) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Demote Administrator</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to remove Administrator access from{" "}
|
||||||
|
<span className="font-medium text-foreground">{displayName}</span>?
|
||||||
|
Their account will be returned to a standard User role, and they will
|
||||||
|
be notified by email.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
|
||||||
|
{loading && <Spinner className="size-4 mr-2" />}
|
||||||
|
Demote
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// ─── components/MakeAdminDialog.jsx ──────────────────────────────────────────
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirms promoting a single user to Administrator.
|
||||||
|
*
|
||||||
|
* @param {Function} onMakeAdmin (entity) => Promise
|
||||||
|
*/
|
||||||
|
export function MakeAdminDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
entity,
|
||||||
|
onMakeAdmin,
|
||||||
|
loading,
|
||||||
|
onSuccess,
|
||||||
|
}) {
|
||||||
|
const displayName = entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user";
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
const res = await onMakeAdmin(entity);
|
||||||
|
if (res) {
|
||||||
|
onOpenChange(false);
|
||||||
|
onSuccess?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Make Administrator</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to grant{" "}
|
||||||
|
<span className="font-medium text-foreground">{displayName}</span>{" "}
|
||||||
|
Administrator access? They will gain full access to all administrative
|
||||||
|
features, and will be notified by email.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
|
||||||
|
{loading && <Spinner className="size-4 mr-2" />}
|
||||||
|
Make Admin
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
{item.type === 'visit_link' && (
|
{item.type === 'visit_link' && (
|
||||||
<div className="grid grid-cols-2 gap-3 pl-7">
|
<div className="grid grid-cols-2 gap-3 pl-7">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">URL *</Label>
|
<Label className="text-xs">URL <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
value={item.link_url}
|
value={item.link_url}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||||
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
||||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
|
||||||
|
|
||||||
const ROTATE_INTERVAL_MS = 6000;
|
|
||||||
|
|
||||||
// resolveNotificationLink already gives explicit link_url (the admin "On
|
// resolveNotificationLink already gives explicit link_url (the admin "On
|
||||||
// Open" section) precedence over the type-based fallbacks, for broadcasts
|
// Open" section) precedence over the type-based fallbacks, for broadcasts
|
||||||
@@ -18,48 +16,23 @@ function resolveClickAction(stickyAnnouncement) {
|
|||||||
|
|
||||||
export default function StickyAnnouncementBar() {
|
export default function StickyAnnouncementBar() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications();
|
const { stickyAnnouncements, markSeen } = useClientNotifications();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
const count = stickyAnnouncements.length;
|
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
|
||||||
// Derived rather than clamped via effect — safe the instant the array
|
// it (X on the bar) reveals whichever is next in the queue.
|
||||||
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
|
const current = stickyAnnouncements[0];
|
||||||
const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
|
|
||||||
|
|
||||||
// Auto-rotate through active announcements while more than one is live.
|
|
||||||
useEffect(() => {
|
|
||||||
if (count <= 1) return;
|
|
||||||
const id = setInterval(() => {
|
|
||||||
setActiveIndex((i) => (i + 1) % count);
|
|
||||||
}, ROTATE_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [count]);
|
|
||||||
|
|
||||||
const current = stickyAnnouncements[safeIndex];
|
|
||||||
|
|
||||||
const onDismiss = useCallback(async () => {
|
const onDismiss = useCallback(async () => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
await markSeen(current.notification_id);
|
await markSeen(current.notification_id);
|
||||||
}, [current, markSeen]);
|
}, [current, markSeen]);
|
||||||
|
|
||||||
// Opening the dialog must NOT mark it seen — markSeen removes the row from
|
|
||||||
// stickyAnnouncements, which would unmount this component (dialog included)
|
|
||||||
// before it ever shows.
|
|
||||||
const onClickBanner = useCallback(() => {
|
const onClickBanner = useCallback(() => {
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
setDetailsOpen(true);
|
setDetailsOpen(true);
|
||||||
}, [current]);
|
}, [current]);
|
||||||
|
|
||||||
// Closing the details dialog (X, Escape, overlay click — any reason)
|
|
||||||
// dismisses whichever announcement was being viewed at the time. This is
|
|
||||||
// the only dismiss path when multiple are active (no per-item X on the bar
|
|
||||||
// itself — see the count > 1 branch below).
|
|
||||||
const onDialogOpenChange = useCallback((open) => {
|
|
||||||
setDetailsOpen(open);
|
|
||||||
if (!open) void onDismiss();
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
const swatch = getTierColor(current.color || "indigo").swatch;
|
const swatch = getTierColor(current.color || "indigo").swatch;
|
||||||
@@ -94,63 +67,38 @@ export default function StickyAnnouncementBar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{count > 1 && (
|
{/* Only way to dismiss a sticky alert — closing the details dialog
|
||||||
<div className="absolute right-2 flex items-center gap-1.5">
|
no longer dismisses it. */}
|
||||||
{stickyAnnouncements.map((a, i) => (
|
<div
|
||||||
<button
|
role="button"
|
||||||
key={a.notification_id}
|
tabIndex={0}
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
aria-label={`Show announcement ${i + 1}`}
|
e.preventDefault();
|
||||||
onClick={(e) => {
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
void onDismiss();
|
||||||
e.stopPropagation();
|
}}
|
||||||
setActiveIndex(i);
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
className="size-1.5 rounded-full transition-opacity"
|
e.preventDefault();
|
||||||
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
|
e.stopPropagation();
|
||||||
/>
|
void onDismiss();
|
||||||
))}
|
}}
|
||||||
</div>
|
aria-label="Dismiss sticky announcement"
|
||||||
)}
|
title="Dismiss"
|
||||||
|
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||||
{/* Dismiss-X only makes sense for a single active announcement —
|
style={{ color: textColor }}
|
||||||
with multiple, the dialog's own close button (shadcn Dialog)
|
>
|
||||||
is the way to close/step away, no per-item dismiss from the bar. */}
|
<X className="size-4" />
|
||||||
{count <= 1 && (
|
</div>
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void onDismiss();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key !== "Enter" && e.key !== " ") return;
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
void onDismiss();
|
|
||||||
}}
|
|
||||||
aria-label="Dismiss sticky announcement"
|
|
||||||
title="Dismiss"
|
|
||||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
|
||||||
style={{ color: textColor }}
|
|
||||||
>
|
|
||||||
<X className="size-4" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnnouncementCarouselDialog
|
<AnnouncementDetailsDialog
|
||||||
open={detailsOpen}
|
open={detailsOpen}
|
||||||
onOpenChange={onDialogOpenChange}
|
onOpenChange={setDetailsOpen}
|
||||||
announcements={stickyAnnouncements}
|
announcement={current}
|
||||||
activeIndex={safeIndex}
|
|
||||||
onIndexChange={setActiveIndex}
|
|
||||||
resolveClickAction={resolveClickAction}
|
resolveClickAction={resolveClickAction}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
bannerImage={bannerImage}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
const advertisement = res.data?.data?.data ?? null;
|
const advertisement = res.data?.data?.data ?? null;
|
||||||
if (advertisement) {
|
if (advertisement) {
|
||||||
setAdvertisements((prev) => [advertisement, ...prev]);
|
setAdvertisements((prev) => [advertisement, ...prev]);
|
||||||
toast("Advertisement created successfully.");
|
toast("Ad created successfully.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -116,7 +116,24 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
if (advertisement) {
|
if (advertisement) {
|
||||||
setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a)));
|
setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a)));
|
||||||
setSelectedAdvertisement(advertisement);
|
setSelectedAdvertisement(advertisement);
|
||||||
toast("Advertisement updated successfully.");
|
toast("Ad updated successfully.");
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /api/admin/advertisements/:advertisementId/reorder ─────────────
|
||||||
|
const reorderAdvertisement = useCallback(
|
||||||
|
(advertisementId, direction) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.patch(`/admin/advertisements/${advertisementId}/reorder`, { direction });
|
||||||
|
const updates = res.data?.data?.updates ?? [];
|
||||||
|
if (updates.length) {
|
||||||
|
const orderById = new Map(updates.map((u) => [u.advertisement_id, u.order]));
|
||||||
|
setAdvertisements((prev) => prev.map((a) =>
|
||||||
|
orderById.has(a.advertisement_id) ? { ...a, order: orderById.get(a.advertisement_id) } : a
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -132,7 +149,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||||
setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev));
|
setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev));
|
||||||
toast("Advertisement archived.");
|
toast("Ad archived.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -146,7 +163,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||||
toast(`${ids.length} advertisement(s) archived.`);
|
toast(`${ids.length} ad(s) archived.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -160,7 +177,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
const advertisement = res.data?.data?.data ?? null;
|
const advertisement = res.data?.data?.data ?? null;
|
||||||
if (advertisement) {
|
if (advertisement) {
|
||||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||||
toast("Advertisement restored.");
|
toast("Ad restored.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -173,7 +190,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch("/admin/advertisements/bulk-restore", { ids });
|
const res = await api.patch("/admin/advertisements/bulk-restore", { ids });
|
||||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||||
toast(`${ids.length} advertisement(s) restored.`);
|
toast(`${ids.length} ad(s) restored.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -185,7 +202,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`);
|
const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`);
|
||||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||||
toast("Advertisement permanently deleted.");
|
toast("Ad permanently deleted.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -197,7 +214,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } });
|
const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } });
|
||||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||||
toast(`${ids.length} advertisement(s) permanently deleted.`);
|
toast(`${ids.length} ad(s) permanently deleted.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -227,6 +244,7 @@ export function AdvertisementsProvider({ children }) {
|
|||||||
fetchArchivedAdvertisements,
|
fetchArchivedAdvertisements,
|
||||||
createAdvertisement,
|
createAdvertisement,
|
||||||
updateAdvertisement,
|
updateAdvertisement,
|
||||||
|
reorderAdvertisement,
|
||||||
archiveAdvertisement,
|
archiveAdvertisement,
|
||||||
archiveAdvertisements,
|
archiveAdvertisements,
|
||||||
restoreAdvertisement,
|
restoreAdvertisement,
|
||||||
|
|||||||
@@ -1,60 +1,8 @@
|
|||||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
|
||||||
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// ─── Generic authenticated SSE reader ──────────────────────────────────────
|
|
||||||
//
|
|
||||||
// Native EventSource can't set the Authorization header this app authenticates
|
|
||||||
// with, so SSE endpoints are consumed via a manually-parsed, authenticated
|
|
||||||
// fetch() stream instead of EventSource. Returns a stop() function. Failures
|
|
||||||
// here are swallowed on purpose — this is a best-effort progress signal on
|
|
||||||
// top of a request that already carries its own real result, never
|
|
||||||
// load-bearing on its own.
|
|
||||||
function streamSSE(url, token, onEvent) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
const reader = res.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
|
|
||||||
const chunks = buffer.split("\n\n");
|
|
||||||
buffer = chunks.pop(); // keep the last, possibly-incomplete chunk for next read
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
|
||||||
if (!line) continue;
|
|
||||||
const data = JSON.parse(line.slice(6));
|
|
||||||
onEvent(data);
|
|
||||||
if (data.done) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.name !== "AbortError") console.warn("[SSE STREAM]", url, err.message);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Document-conversion stage progress (compiling/validating/generating) — see
|
|
||||||
// convertAssetToMarkdown() below. Backend broadcaster is
|
|
||||||
// services/uploadProgress.service.js, keyed by a client-generated job id.
|
|
||||||
const streamConvertProgress = (jobId, token, onProgress) =>
|
|
||||||
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/convert-progress/${jobId}`, token, onProgress);
|
|
||||||
|
|
||||||
const AssetsContext = createContext(null);
|
const AssetsContext = createContext(null);
|
||||||
|
|
||||||
export function useAssets() {
|
export function useAssets() {
|
||||||
@@ -84,7 +32,6 @@ const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
|
|||||||
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
|
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
|
||||||
|
|
||||||
export function AssetsProvider({ children }) {
|
export function AssetsProvider({ children }) {
|
||||||
const { accessTokenRef } = useAuth();
|
|
||||||
const [assets, setAssets] = useState([]);
|
const [assets, setAssets] = useState([]);
|
||||||
const [attributes, setAttributes] = useState([]);
|
const [attributes, setAttributes] = useState([]);
|
||||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||||
@@ -286,34 +233,6 @@ export function AssetsProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/assets/:assetId/convert-to-markdown ─────────────────
|
|
||||||
//
|
|
||||||
// PDF/PPTX -> Markdown, text only (see documentConversion.service.js on
|
|
||||||
// the backend for why OCR/images are out of scope). Nothing is persisted
|
|
||||||
// by this call — the result is a draft the caller (Document Import block)
|
|
||||||
// only keeps if the admin explicitly inserts it. On failure this resolves
|
|
||||||
// to null (the shared `request()` wrapper already toasts the backend's
|
|
||||||
// specific error message, e.g. "No readable text found...").
|
|
||||||
//
|
|
||||||
// onProgress?: ({ phase: 'compiling'|'validating'|'generating'|'done'|'error' }) => void
|
|
||||||
const convertAssetToMarkdown = useCallback(
|
|
||||||
(assetId, { onProgress } = {}) =>
|
|
||||||
request(async () => {
|
|
||||||
const jobId = nanoid();
|
|
||||||
const stopStream = onProgress
|
|
||||||
? streamConvertProgress(jobId, accessTokenRef.current, onProgress)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await api.post(`/admin/assets/${assetId}/convert-to-markdown`, { jobId });
|
|
||||||
return res.data?.data ?? null;
|
|
||||||
} finally {
|
|
||||||
stopStream?.();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[request, accessTokenRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A replacement file (video thumbnail, or an image/audio asset's main
|
// A replacement file (video thumbnail, or an image/audio asset's main
|
||||||
@@ -462,7 +381,6 @@ export function AssetsProvider({ children }) {
|
|||||||
fetchAsset,
|
fetchAsset,
|
||||||
fetchArchivedAssets,
|
fetchArchivedAssets,
|
||||||
uploadAsset,
|
uploadAsset,
|
||||||
convertAssetToMarkdown,
|
|
||||||
updateAsset,
|
updateAsset,
|
||||||
archiveAsset,
|
archiveAsset,
|
||||||
archiveAssets,
|
archiveAssets,
|
||||||
|
|||||||
@@ -7,62 +7,154 @@ const AdminCategoriesContext = createContext(null);
|
|||||||
export function AdminCategoriesProvider({ children }) {
|
export function AdminCategoriesProvider({ children }) {
|
||||||
const [categories, setCategories] = useState([]);
|
const [categories, setCategories] = useState([]);
|
||||||
const [category, setCategory] = useState(null);
|
const [category, setCategory] = useState(null);
|
||||||
|
const [categoryAttributes, setCategoryAttributes] = useState([]);
|
||||||
|
const [categoryPagination, setCategoryPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const wrap = useCallback(async (fn) => {
|
const fetchCategories = useCallback(async ({ page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try { return await fn(); }
|
try {
|
||||||
catch (err) {
|
const { data } = await api.get("/admin/categories", {
|
||||||
toast(err?.response?.data?.message ?? "Something went wrong.");
|
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort), archived },
|
||||||
|
});
|
||||||
|
setCategories(data.data?.data ?? []);
|
||||||
|
setCategoryAttributes(data.data?.attributes ?? []);
|
||||||
|
setCategoryPagination({
|
||||||
|
page: data.data?.pagination?.page ?? page,
|
||||||
|
limit: data.data?.pagination?.limit ?? limit,
|
||||||
|
totalPages: data.data?.pagination?.totalPages ?? 1,
|
||||||
|
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
||||||
|
});
|
||||||
|
} catch { toast("Could not load categories."); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCategory = useCallback(async (id) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/categories/${id}`);
|
||||||
|
setCategory(data.data ?? null);
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not load category.");
|
||||||
return null;
|
return null;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchCategories = useCallback((archived = false) => wrap(async () => {
|
const createCategory = useCallback(async (payload) => {
|
||||||
const { data } = await api.get("/admin/categories", { params: { archived } });
|
setLoading(true);
|
||||||
setCategories(data.data ?? []);
|
try {
|
||||||
return data.data;
|
const { data } = await api.post("/admin/categories", payload);
|
||||||
}), [wrap]);
|
toast("Category created.");
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not create category.");
|
||||||
|
return null;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fetchCategory = useCallback((id) => wrap(async () => {
|
const updateCategory = useCallback(async (id, payload) => {
|
||||||
const { data } = await api.get(`/admin/categories/${id}`);
|
setLoading(true);
|
||||||
setCategory(data.data ?? null);
|
try {
|
||||||
return data.data;
|
const { data } = await api.put(`/admin/categories/${id}`, payload);
|
||||||
}), [wrap]);
|
setCategory(data.data ?? null);
|
||||||
|
toast("Category updated.");
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not update category.");
|
||||||
|
return null;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const createCategory = useCallback((payload) => wrap(async () => {
|
const archiveCategory = useCallback(async (id) => {
|
||||||
const { data } = await api.post("/admin/categories", payload);
|
setLoading(true);
|
||||||
toast("Category created.");
|
try {
|
||||||
return data.data;
|
await api.delete(`/admin/categories/${id}`);
|
||||||
}), [wrap]);
|
toast("Category archived.");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not archive category.");
|
||||||
|
return false;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateCategory = useCallback((id, payload) => wrap(async () => {
|
const restoreCategory = useCallback(async (id) => {
|
||||||
const { data } = await api.put(`/admin/categories/${id}`, payload);
|
setLoading(true);
|
||||||
setCategory(data.data ?? null);
|
try {
|
||||||
toast("Category updated.");
|
await api.post(`/admin/categories/${id}/restore`);
|
||||||
return data.data;
|
toast("Category restored.");
|
||||||
}), [wrap]);
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not restore category.");
|
||||||
|
return false;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const archiveCategory = useCallback((id) => wrap(async () => {
|
const bulkArchiveCategories = useCallback(async (ids) => {
|
||||||
await api.delete(`/admin/categories/${id}`);
|
setLoading(true);
|
||||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
try {
|
||||||
toast("Category archived.");
|
await api.delete("/admin/categories/bulk", { data: { ids } });
|
||||||
return true;
|
toast("Categories archived.");
|
||||||
}), [wrap]);
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not archive categories.");
|
||||||
|
return false;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const restoreCategory = useCallback((id) => wrap(async () => {
|
const bulkRestoreCategories = useCallback(async (ids) => {
|
||||||
await api.post(`/admin/categories/${id}/restore`);
|
setLoading(true);
|
||||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
try {
|
||||||
toast("Category restored.");
|
await api.post("/admin/categories/bulk-restore", { ids });
|
||||||
return true;
|
toast("Categories restored.");
|
||||||
}), [wrap]);
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not restore categories.");
|
||||||
|
return false;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCategoryPermanentDeleteImpact = useCallback(async (id) => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/admin/categories/${id}/permanent-delete-impact`);
|
||||||
|
return [{ label: "course(s) linked to this category", count: data.data?.course_count ?? 0 }];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const permanentlyDeleteCategory = useCallback(async (id) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/categories/${id}/permanent`);
|
||||||
|
toast("Category permanently deleted.");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not permanently delete category.");
|
||||||
|
return false;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bulkPermanentlyDeleteCategories = useCallback(async (ids) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await api.delete("/admin/categories/bulk/permanent", { data: { ids } });
|
||||||
|
toast("Categories permanently deleted.");
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
toast(err?.response?.data?.message ?? "Could not permanently delete categories.");
|
||||||
|
return false;
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminCategoriesContext.Provider value={{
|
<AdminCategoriesContext.Provider value={{
|
||||||
categories, category, loading,
|
categories, category, categoryAttributes, categoryPagination, setCategoryPagination, loading,
|
||||||
fetchCategories, fetchCategory,
|
fetchCategories, fetchCategory,
|
||||||
createCategory, updateCategory,
|
createCategory, updateCategory,
|
||||||
archiveCategory, restoreCategory,
|
archiveCategory, restoreCategory,
|
||||||
|
bulkArchiveCategories, bulkRestoreCategories,
|
||||||
|
fetchCategoryPermanentDeleteImpact, permanentlyDeleteCategory, bulkPermanentlyDeleteCategories,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AdminCategoriesContext.Provider>
|
</AdminCategoriesContext.Provider>
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
const [attributes, setAttributes] = useState([]);
|
const [attributes, setAttributes] = useState([]);
|
||||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||||
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
|
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
|
||||||
const [stickyBannerSetting, setStickyBannerSetting] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const request = useCallback(async (fn) => {
|
const request = useCallback(async (fn) => {
|
||||||
@@ -101,7 +100,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
const broadcast = res.data?.data?.data ?? null;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => [broadcast, ...prev]);
|
setBroadcasts((prev) => [broadcast, ...prev]);
|
||||||
toast("Notification broadcast created.");
|
toast("Alert created.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -117,7 +116,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
||||||
setSelectedBroadcast(broadcast);
|
setSelectedBroadcast(broadcast);
|
||||||
toast("Notification broadcast updated.");
|
toast("Alert updated.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -133,7 +132,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
|
||||||
setSelectedBroadcast(broadcast);
|
setSelectedBroadcast(broadcast);
|
||||||
toast("Notification broadcast sent.");
|
toast("Alert sent.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -149,7 +148,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
|
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
|
||||||
toast("Notification broadcast archived.");
|
toast("Alert archived.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -163,7 +162,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast(`${ids.length} notification broadcast(s) archived.`);
|
toast(`${ids.length} alert(s) archived.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -177,7 +176,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
const broadcast = res.data?.data?.data ?? null;
|
const broadcast = res.data?.data?.data ?? null;
|
||||||
if (broadcast) {
|
if (broadcast) {
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
toast("Notification broadcast restored.");
|
toast("Alert restored.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -190,7 +189,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch("/admin/announcements/bulk-restore", { ids });
|
const res = await api.patch("/admin/announcements/bulk-restore", { ids });
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast(`${ids.length} notification broadcast(s) restored.`);
|
toast(`${ids.length} alert(s) restored.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -202,36 +201,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`/admin/announcements/${broadcastId}/permanent`);
|
const res = await api.delete(`/admin/announcements/${broadcastId}/permanent`);
|
||||||
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
|
||||||
toast("Notification broadcast permanently deleted.");
|
toast("Alert permanently deleted.");
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── GET /api/admin/announcements/sticky-banner ───────────────────────────
|
|
||||||
// Shared banner image for the whole rotating sticky bar — one image for
|
|
||||||
// all (up to 3) concurrently-active announcements, not one per announcement.
|
|
||||||
const fetchStickyBannerSetting = useCallback(
|
|
||||||
() =>
|
|
||||||
request(async () => {
|
|
||||||
const res = await api.get("/admin/announcements/sticky-banner");
|
|
||||||
const setting = res.data?.data?.data ?? null;
|
|
||||||
setStickyBannerSetting(setting);
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── PATCH /api/admin/announcements/sticky-banner ────────────────────────
|
|
||||||
const updateStickyBannerSetting = useCallback(
|
|
||||||
(fields) =>
|
|
||||||
request(async () => {
|
|
||||||
const res = await api.patch("/admin/announcements/sticky-banner", fields);
|
|
||||||
const setting = res.data?.data?.data ?? null;
|
|
||||||
if (setting) {
|
|
||||||
setStickyBannerSetting((prev) => ({ ...prev, ...setting }));
|
|
||||||
toast("Sticky banner image updated.");
|
|
||||||
}
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -243,7 +213,7 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete("/admin/announcements/bulk/permanent", { data: { ids } });
|
const res = await api.delete("/admin/announcements/bulk/permanent", { data: { ids } });
|
||||||
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
setBroadcasts((prev) => prev.filter((b) => !ids.includes(b.broadcast_id)));
|
||||||
toast(`${ids.length} notification broadcast(s) permanently deleted.`);
|
toast(`${ids.length} alert(s) permanently deleted.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -255,7 +225,6 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
attributes,
|
attributes,
|
||||||
pagination,
|
pagination,
|
||||||
selectedBroadcast,
|
selectedBroadcast,
|
||||||
stickyBannerSetting,
|
|
||||||
loading,
|
loading,
|
||||||
setPagination,
|
setPagination,
|
||||||
setSelectedBroadcast,
|
setSelectedBroadcast,
|
||||||
@@ -271,8 +240,6 @@ export function NotificationBroadcastsProvider({ children }) {
|
|||||||
restoreBroadcasts,
|
restoreBroadcasts,
|
||||||
permanentlyDeleteBroadcast,
|
permanentlyDeleteBroadcast,
|
||||||
permanentlyDeleteBroadcasts,
|
permanentlyDeleteBroadcasts,
|
||||||
fetchStickyBannerSetting,
|
|
||||||
updateStickyBannerSetting,
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NotificationBroadcastsContext.Provider>
|
</NotificationBroadcastsContext.Provider>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unseenCount, setUnseenCount] = useState(0);
|
const [unseenCount, setUnseenCount] = useState(0);
|
||||||
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
|
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
|
||||||
const [bannerImage, setBannerImage] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get("/admin/notifications/sticky");
|
const res = await api.get("/admin/notifications/sticky");
|
||||||
setStickyAnnouncements(res.data?.data?.announcements ?? []);
|
setStickyAnnouncements(res.data?.data?.announcements ?? []);
|
||||||
setBannerImage(res.data?.data?.bannerImage ?? null);
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -92,7 +90,6 @@ export function AdminNotificationProvider({ children }) {
|
|||||||
notifications,
|
notifications,
|
||||||
unseenCount,
|
unseenCount,
|
||||||
stickyAnnouncements,
|
stickyAnnouncements,
|
||||||
bannerImage,
|
|
||||||
loading,
|
loading,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
markSeen,
|
markSeen,
|
||||||
|
|||||||
@@ -348,6 +348,36 @@ export const UserProvider = ({ children }) => {
|
|||||||
[request, user]
|
[request, user]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/admin/users/:id/make-admin ─────────────────────────────────
|
||||||
|
const makeAdmin = useCallback(
|
||||||
|
(userId) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.post(`${BASE}/users/${userId}/make-admin`, {});
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.user_id === userId ? { ...u, acc_type: "admin" } : u))
|
||||||
|
);
|
||||||
|
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, acc_type: "admin" } : prev);
|
||||||
|
toast("User promoted to Administrator.");
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request, user]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/admin/users/:id/demote-admin ───────────────────────────────
|
||||||
|
const demoteAdmin = useCallback(
|
||||||
|
(userId) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.post(`${BASE}/users/${userId}/demote-admin`, {});
|
||||||
|
setUsers((prev) =>
|
||||||
|
prev.map((u) => (u.user_id === userId ? { ...u, acc_type: "user" } : u))
|
||||||
|
);
|
||||||
|
if (user?.user_id === userId) setUser((prev) => prev ? { ...prev, acc_type: "user" } : prev);
|
||||||
|
toast("Administrator access removed.");
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request, user]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/users/bulk/ban ───────────────────────────────────────
|
// ─── POST /api/admin/users/bulk/ban ───────────────────────────────────────
|
||||||
const bulkBanUsers = useCallback(
|
const bulkBanUsers = useCallback(
|
||||||
({ ids, ...payload }) =>
|
({ ids, ...payload }) =>
|
||||||
@@ -413,6 +443,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
fetchUserAchievements,
|
fetchUserAchievements,
|
||||||
fetchActivity, fetchUserActivity,
|
fetchActivity, fetchUserActivity,
|
||||||
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, fetchUserBans,
|
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, fetchUserBans,
|
||||||
|
makeAdmin, demoteAdmin,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
const [notifications, setNotifications] = useState([]);
|
const [notifications, setNotifications] = useState([]);
|
||||||
const [unseenCount, setUnseenCount] = useState(0);
|
const [unseenCount, setUnseenCount] = useState(0);
|
||||||
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
|
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
|
||||||
const [bannerImage, setBannerImage] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
|
||||||
const intervalRef = useRef(null);
|
const intervalRef = useRef(null);
|
||||||
@@ -37,7 +36,6 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get("/client/notifications/sticky");
|
const res = await api.get("/client/notifications/sticky");
|
||||||
setStickyAnnouncements(res.data?.data?.announcements ?? []);
|
setStickyAnnouncements(res.data?.data?.announcements ?? []);
|
||||||
setBannerImage(res.data?.data?.bannerImage ?? null);
|
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
}
|
}
|
||||||
@@ -132,7 +130,6 @@ export function ClientNotificationProvider({ children }) {
|
|||||||
notifications,
|
notifications,
|
||||||
unseenCount,
|
unseenCount,
|
||||||
stickyAnnouncements,
|
stickyAnnouncements,
|
||||||
bannerImage,
|
|
||||||
loading,
|
loading,
|
||||||
pagination,
|
pagination,
|
||||||
fetchNotifications,
|
fetchNotifications,
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const ADMIN_SECTIONS = [
|
|||||||
title: "Site Content",
|
title: "Site Content",
|
||||||
description: "Manage public-facing content",
|
description: "Manage public-facing content",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
{ key: "advertisements", label: "Ads", icon: Megaphone, link: "/admin/advertisements" },
|
||||||
{ key: "notifications", label: "Announcements", icon: Bell, link: "/admin/announcements" },
|
{ key: "notifications", label: "Alerts", icon: Bell, link: "/admin/announcements" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: advertisements,
|
allData: advertisements,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_ArchivedAdvertisements`,
|
filename: `${getTimestamp()}_ArchivedAds`,
|
||||||
sheetName: "Archived Advertisements",
|
sheetName: "Archived Ads",
|
||||||
generatedBy: formatGeneratedBy(currentUser),
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Archived Advertisements"
|
title="Archived Ads"
|
||||||
data={advertisements}
|
data={advertisements}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
@@ -146,8 +146,8 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
columnPinning={columnPinning}
|
columnPinning={columnPinning}
|
||||||
toolbarActions={toolbarActions}
|
toolbarActions={toolbarActions}
|
||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="archived advertisement"
|
recordLabel="archived ad"
|
||||||
emptyMessage="No archived advertisements found."
|
emptyMessage="No archived ads found."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Single restore ── */}
|
{/* ── Single restore ── */}
|
||||||
@@ -155,8 +155,8 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
open={!!restoreTarget}
|
open={!!restoreTarget}
|
||||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
entity={restoreTarget}
|
entity={restoreTarget}
|
||||||
entityLabel="Advertisement"
|
entityLabel="Ad"
|
||||||
getName={(a) => a?.headline ?? a?.badge_label ?? "this advertisement"}
|
getName={(a) => a?.headline ?? a?.badge_label ?? "this ad"}
|
||||||
onRestore={(a) => restoreAdvertisement(a?.advertisement_id)}
|
onRestore={(a) => restoreAdvertisement(a?.advertisement_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -167,7 +167,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
open={!!restoreIds}
|
open={!!restoreIds}
|
||||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
ids={restoreIds ?? []}
|
ids={restoreIds ?? []}
|
||||||
entityLabel="Advertisement"
|
entityLabel="Ad"
|
||||||
onRestore={(ids) => restoreAdvertisements(ids)}
|
onRestore={(ids) => restoreAdvertisements(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -178,8 +178,8 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
entity={deleteTarget}
|
entity={deleteTarget}
|
||||||
entityLabel="Advertisement"
|
entityLabel="Ad"
|
||||||
getName={(a) => a?.headline ?? a?.badge_label ?? "this advertisement"}
|
getName={(a) => a?.headline ?? a?.badge_label ?? "this ad"}
|
||||||
onDelete={(a) => permanentlyDeleteAdvertisement(a?.advertisement_id)}
|
onDelete={(a) => permanentlyDeleteAdvertisement(a?.advertisement_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
@@ -190,7 +190,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
open={!!deleteIds}
|
open={!!deleteIds}
|
||||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
ids={deleteIds ?? []}
|
ids={deleteIds ?? []}
|
||||||
entityLabel="Advertisement"
|
entityLabel="Ad"
|
||||||
onDelete={(ids) => permanentlyDeleteAdvertisements(ids)}
|
onDelete={(ids) => permanentlyDeleteAdvertisements(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
import { PermanentDeleteDialog } from "@/components/generic/Dialogs/PermanentDeleteDialog";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/categories/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/categories/archive/toolbar.config";
|
||||||
|
import { buildSelectionActions } from "../../config/categories/archive/selection.config";
|
||||||
|
import { buildRowActions } from "../../config/categories/archive/rowActions.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
||||||
|
|
||||||
|
export default function ArchivedCategoriesTable() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
const {
|
||||||
|
categories, categoryAttributes, categoryPagination, setCategoryPagination,
|
||||||
|
loading, fetchCategories, restoreCategory, bulkRestoreCategories,
|
||||||
|
fetchCategoryPermanentDeleteImpact, permanentlyDeleteCategory, bulkPermanentlyDeleteCategories,
|
||||||
|
} = useCategories();
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteIds, setDeleteIds] = useState(null);
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => {},
|
||||||
|
tableInstance: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
|
||||||
|
|
||||||
|
const fetchArchived = useCallback(
|
||||||
|
(params) => fetchCategories({ ...params, archived: true }),
|
||||||
|
[fetchCategories]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchArchived({
|
||||||
|
page: 1,
|
||||||
|
limit: categoryPagination?.limit ?? 10,
|
||||||
|
filters: tableRefsRef.current.getFilters(),
|
||||||
|
sort: tableRefsRef.current.getSort(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowActions = buildRowActions({
|
||||||
|
onRestore: async (row) => { await restoreCategory(row.id); refetch(); },
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportConfig = useMemo(() => ({
|
||||||
|
allData: categories,
|
||||||
|
attributes: categoryAttributes,
|
||||||
|
filename: `${getTimestamp()}_ArchivedCategories`,
|
||||||
|
sheetName: "Archived Categories",
|
||||||
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
|
}), [categories, categoryAttributes, currentUser]);
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchCategories: fetchArchived,
|
||||||
|
pagination: categoryPagination,
|
||||||
|
exportConfig,
|
||||||
|
navigate,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionActions = buildSelectionActions({
|
||||||
|
onRestore: async (row) => { await restoreCategory(row.id); refetch(); },
|
||||||
|
onRestoreMany: async (ids) => { await bulkRestoreCategories(ids); refetch(); },
|
||||||
|
onDelete: (row) => setDeleteTarget(row),
|
||||||
|
onDeleteMany: (ids) => setDeleteIds(ids),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(categoryAttributes, rowActions),
|
||||||
|
[categoryAttributes, rowActions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleteSuccess = () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setDeleteIds(null);
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Archived Categories"
|
||||||
|
data={categories}
|
||||||
|
columns={columns}
|
||||||
|
attributes={categoryAttributes}
|
||||||
|
pagination={categoryPagination}
|
||||||
|
setPagination={setCategoryPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchArchived}
|
||||||
|
onFetchFilterData={async () => []}
|
||||||
|
onRefsReady={handleRefsReady}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="category"
|
||||||
|
emptyMessage="No archived categories."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Single permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
|
entity={deleteTarget}
|
||||||
|
entityLabel="Category"
|
||||||
|
getName={(r) => r?.name}
|
||||||
|
onDelete={(entity) => permanentlyDeleteCategory(entity?.id)}
|
||||||
|
onImpactCheck={() => fetchCategoryPermanentDeleteImpact(deleteTarget?.id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bulk permanent delete */}
|
||||||
|
<PermanentDeleteDialog
|
||||||
|
open={!!deleteIds}
|
||||||
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
|
ids={deleteIds ?? []}
|
||||||
|
entityLabel="Category"
|
||||||
|
onDelete={({ ids }) => bulkPermanentlyDeleteCategories(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDeleteSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
|
||||||
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/categories/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/categories/toolbar.config";
|
||||||
|
import { buildSelectionActions } from "../../config/categories/selection.config";
|
||||||
|
import { buildRowActions } from "../../config/categories/rowActions.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
||||||
|
|
||||||
|
export default function CategoriesTable() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
|
||||||
|
const {
|
||||||
|
categories, categoryAttributes, categoryPagination, setCategoryPagination,
|
||||||
|
loading, fetchCategories, archiveCategory, bulkArchiveCategories,
|
||||||
|
} = useCategories();
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => {},
|
||||||
|
tableInstance: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
|
||||||
|
|
||||||
|
const refetch = () => {
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchCategories({
|
||||||
|
page: 1,
|
||||||
|
limit: categoryPagination?.limit ?? 10,
|
||||||
|
filters: tableRefsRef.current.getFilters(),
|
||||||
|
sort: tableRefsRef.current.getSort(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowActions = buildRowActions({
|
||||||
|
navigate,
|
||||||
|
onArchive: async (row) => { await archiveCategory(row.id); refetch(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportConfig = useMemo(() => ({
|
||||||
|
allData: categories,
|
||||||
|
attributes: categoryAttributes,
|
||||||
|
filename: `${getTimestamp()}_Categories`,
|
||||||
|
sheetName: "Categories",
|
||||||
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
|
}), [categories, categoryAttributes, currentUser]);
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchCategories,
|
||||||
|
pagination: categoryPagination,
|
||||||
|
exportConfig,
|
||||||
|
navigate,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionActions = buildSelectionActions({
|
||||||
|
onArchive: async (row) => { await archiveCategory(row.id); refetch(); },
|
||||||
|
onArchiveMany: async (ids) => { await bulkArchiveCategories(ids); refetch(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(categoryAttributes, rowActions),
|
||||||
|
[categoryAttributes, rowActions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
title="Categories"
|
||||||
|
data={categories}
|
||||||
|
columns={columns}
|
||||||
|
attributes={categoryAttributes}
|
||||||
|
pagination={categoryPagination}
|
||||||
|
setPagination={setCategoryPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchCategories}
|
||||||
|
onFetchFilterData={async () => []}
|
||||||
|
onRefsReady={handleRefsReady}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="category"
|
||||||
|
emptyMessage="No categories yet. Add one to get started."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,10 +16,6 @@ import CreateAchievementDialog from "./CreateAchievementDialog";
|
|||||||
export default function AchievementsBuilder({ achievementKeys, onAchievementKeysChange, registry, onRegistryChange }) {
|
export default function AchievementsBuilder({ achievementKeys, onAchievementKeysChange, registry, onRegistryChange }) {
|
||||||
const [attachOpen, setAttachOpen] = useState(false);
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
// Gate the whole New/Attach builder behind an explicit yes/no — don't
|
|
||||||
// assume every course wants an achievement. Starts open if a course being
|
|
||||||
// edited already has one selected.
|
|
||||||
const [wantsAchievement, setWantsAchievement] = useState(achievementKeys.length > 0);
|
|
||||||
|
|
||||||
const selected = registry.find((a) => a.key === achievementKeys[0]) ?? null;
|
const selected = registry.find((a) => a.key === achievementKeys[0]) ?? null;
|
||||||
|
|
||||||
@@ -32,50 +28,24 @@ export default function AchievementsBuilder({ achievementKeys, onAchievementKeys
|
|||||||
|
|
||||||
const declineAchievement = () => {
|
const declineAchievement = () => {
|
||||||
onAchievementKeysChange([]);
|
onAchievementKeysChange([]);
|
||||||
setWantsAchievement(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!wantsAchievement) {
|
|
||||||
return (
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<div className="rounded-md border border-dashed px-4 py-5 flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium flex items-center gap-1.5">
|
|
||||||
<Trophy className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
Award an achievement for completing this course?
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Optional — learners can earn a badge or milestone for finishing this course.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={declineAchievement}>
|
|
||||||
No
|
|
||||||
</Button>
|
|
||||||
<Button type="button" size="sm" onClick={() => setWantsAchievement(true)}>
|
|
||||||
Yes, add one
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<div className="flex items-start justify-between gap-3 pb-3 mb-3 border-b">
|
<div className="flex items-start justify-between gap-3 pb-3 mb-3 border-b">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
|
<h2 className="text-sm font-semibold">Achievements</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Attach an existing achievement from the registry, or define a new one from scratch.
|
Attach an existing achievement from the registry, or define a new one from scratch.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Achievement
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => setAttachOpen(true)}>
|
||||||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach
|
<Link2 /> Select
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus /> Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
|
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw, AlertTriangle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -47,6 +47,27 @@ function ProgressBar({ value, total, className = '' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only relevant once reading is fully done but the course still isn't 'completed' —
|
||||||
|
// i.e. a still-unpassed unit quiz and/or course assessment is what's blocking it.
|
||||||
|
function PendingNotice({ entry }) {
|
||||||
|
const readingDone = entry.lessons_total > 0 && entry.lessons_completed === entry.lessons_total;
|
||||||
|
if (!readingDone || entry.course_status === 'completed') return null;
|
||||||
|
if (!entry.quizzes_pending && !entry.assessment_pending) return null;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (entry.quizzes_pending > 0) {
|
||||||
|
parts.push(`${entry.quizzes_pending} quiz${entry.quizzes_pending === 1 ? '' : 'zes'} needed`);
|
||||||
|
}
|
||||||
|
if (entry.assessment_pending) parts.push('Assessment needed');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="size-3.5 shrink-0" />
|
||||||
|
{parts.join(' · ')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UserAvatar({ name, email, avatarUrl }) {
|
function UserAvatar({ name, email, avatarUrl }) {
|
||||||
const initials = name
|
const initials = name
|
||||||
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
|
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
|
||||||
@@ -102,6 +123,8 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{entry && <PendingNotice entry={entry} />}
|
||||||
|
|
||||||
{/* ── Progress bar ── */}
|
{/* ── Progress bar ── */}
|
||||||
{entry && (
|
{entry && (
|
||||||
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
||||||
@@ -197,6 +220,7 @@ function UserCard({ entry, onOpen }) {
|
|||||||
<StatusBadge status={entry.course_status} />
|
<StatusBadge status={entry.course_status} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
|
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
|
||||||
|
<PendingNotice entry={entry} />
|
||||||
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
||||||
<p className="text-xs">Last seen {lastSeen}</p>
|
<p className="text-xs">Last seen {lastSeen}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +290,8 @@ function PaginationControls({ page, totalPages, onPage }) {
|
|||||||
export default function CourseReadingProgressList({ courseId }) {
|
export default function CourseReadingProgressList({ courseId }) {
|
||||||
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
|
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [dialogEntry, setDialogEntry] = useState(null);
|
const [dialogEntry, setDialogEntry] = useState(null);
|
||||||
|
|
||||||
@@ -274,18 +299,21 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
fetchCourseReadingProgress(courseId);
|
fetchCourseReadingProgress(courseId);
|
||||||
}, [courseId]);
|
}, [courseId]);
|
||||||
|
|
||||||
// Reset to page 1 when search changes
|
const handleSearchSubmit = (e) => {
|
||||||
useEffect(() => { setPage(1); }, [search]);
|
e.preventDefault();
|
||||||
|
setQuery(searchInput.trim());
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Filter ────────────────────────────────────────────────────────────────
|
// ── Filter ────────────────────────────────────────────────────────────────
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return progressList;
|
if (!q) return progressList;
|
||||||
return progressList.filter((e) =>
|
return progressList.filter((e) =>
|
||||||
e.user.full_name?.toLowerCase().includes(q) ||
|
e.user.full_name?.toLowerCase().includes(q) ||
|
||||||
e.user.email?.toLowerCase().includes(q)
|
e.user.email?.toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}, [progressList, search]);
|
}, [progressList, query]);
|
||||||
|
|
||||||
// ── Paginate ──────────────────────────────────────────────────────────────
|
// ── Paginate ──────────────────────────────────────────────────────────────
|
||||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
@@ -343,23 +371,29 @@ export default function CourseReadingProgressList({ courseId }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Search ── */}
|
{/* ── Search ── */}
|
||||||
<div className="relative">
|
<form onSubmit={handleSearchSubmit} className="flex items-center gap-2">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
<div className="relative flex-1">
|
||||||
<Input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||||
placeholder="Search by name or email…"
|
<Input
|
||||||
value={search}
|
placeholder="Search by name or email…"
|
||||||
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
|
value={searchInput}
|
||||||
maxLength={50}
|
onChange={(e) => setSearchInput(e.target.value.slice(0, 50))}
|
||||||
className="bg-background pl-9 pr-16"
|
maxLength={50}
|
||||||
/>
|
className="bg-background pl-9 pr-16"
|
||||||
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
/>
|
||||||
{search.length}/50
|
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${searchInput.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
</span>
|
{searchInput.length}/50
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="outline" className="shrink-0">
|
||||||
|
<Search className="size-4" />
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{/* ── List ── */}
|
{/* ── List ── */}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-6">No results for "{search}".</p>
|
<p className="text-sm text-muted-foreground text-center py-6">No results for "{query}".</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{paginated.map((entry) => (
|
{paginated.map((entry) => (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
@@ -38,6 +38,17 @@ export default function CoursesTable() {
|
|||||||
tableRefsRef.current = refs;
|
tableRefsRef.current = refs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/admin/tiers/categories")
|
||||||
|
.then(({ data }) => setTierCategories(data.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const tierMap = useMemo(
|
||||||
|
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
|
||||||
|
[tierCategories]
|
||||||
|
);
|
||||||
|
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: courses,
|
allData: courses,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -90,8 +101,8 @@ export default function CoursesTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildDataColumns(attributes, rowActions),
|
() => buildDataColumns(attributes, rowActions, tierMap),
|
||||||
[attributes, rowActions]
|
[attributes, rowActions, tierMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveSuccess = () => {
|
const handleArchiveSuccess = () => {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
|
|||||||
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
|
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
|
||||||
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
|
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
|
||||||
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
|
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
|
||||||
import { DocumentBlock } from "@/components/generic/Blocks/Client/DocumentBlock";
|
|
||||||
|
|
||||||
export function LessonHeader({ lesson }) {
|
export function LessonHeader({ lesson }) {
|
||||||
if (!lesson) return null;
|
if (!lesson) return null;
|
||||||
@@ -160,8 +159,6 @@ export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnable
|
|||||||
return <CodeBlock content={content} />;
|
return <CodeBlock content={content} />;
|
||||||
case "markdown":
|
case "markdown":
|
||||||
return <MarkdownBlock content={content} />;
|
return <MarkdownBlock content={content} />;
|
||||||
case "document":
|
|
||||||
return <DocumentBlock content={content} />;
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,12 +117,13 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setCreateUnitOpen(true)}>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Unit
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => setAttachUnitOpen(true)}>
|
||||||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach
|
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setCreateUnitOpen(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
|
||||||
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -226,7 +227,7 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {
|
|||||||
{units.length === 0 && (
|
{units.length === 0 && (
|
||||||
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
|
<div className="flex items-center gap-2 text-xs text-amber-700 dark:text-amber-400">
|
||||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||||
Add at least one unit so learners have content to see. You can still continue and add units later.
|
Add at least one unit so learners have content to see.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
|
|||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Link2 className="h-4 w-4" /> Attach Existing Lessons
|
<Link2 className="h-4 w-4" /> Select Lessons
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Lessons live independently in the library — attaching adds them to this unit without copying.
|
Lessons live independently in the library — attaching adds them to this unit without copying.
|
||||||
@@ -96,7 +96,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
|
|||||||
onCheckedChange={() => toggle(l.lesson_id)}
|
onCheckedChange={() => toggle(l.lesson_id)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{l.title}</p>
|
<p className="text-sm font-medium w-64 truncate">{l.title}</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{formatDuration(l.duration_seconds ?? 0)}
|
{formatDuration(l.duration_seconds ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
@@ -120,7 +120,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
|
|||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleAttach} disabled={loading || !selected.length}>
|
<Button onClick={handleAttach} disabled={loading || !selected.length}>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Attach {selected.length > 0 ? `(${selected.length})` : ""}
|
Select {selected.length > 0 ? `(${selected.length})` : ""}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
|||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Link2 className="h-4 w-4" /> Attach Existing Units
|
<Link2 className="h-4 w-4" /> Select Units
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Units live independently in the library — attaching adds them to this course without copying.
|
Units live independently in the library — attaching adds them to this course without copying.
|
||||||
@@ -105,7 +105,9 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
|
|||||||
onCheckedChange={() => toggle(u.unit_id)}
|
onCheckedChange={() => toggle(u.unit_id)}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{u.title}</p>
|
<p className="text-sm font-medium w-64 truncate" title={u.title}>
|
||||||
|
{u.title}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState, useCallback } from "react";
|
import { useMemo, useRef, useState, useCallback, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
@@ -13,6 +13,7 @@ import { buildToolbarActions } from "../../config/library/lessons/toolbar.config
|
|||||||
import { buildSelectionActions } from "../../config/library/lessons/selection.config";
|
import { buildSelectionActions } from "../../config/library/lessons/selection.config";
|
||||||
import { buildRowActions } from "../../config/library/lessons/rowActions.config";
|
import { buildRowActions } from "../../config/library/lessons/rowActions.config";
|
||||||
|
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
||||||
|
|
||||||
@@ -41,6 +42,17 @@ export default function LessonLibraryTable() {
|
|||||||
tableRefsRef.current = refs;
|
tableRefsRef.current = refs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/admin/tiers/categories")
|
||||||
|
.then(({ data }) => setTierCategories(data.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const tierMap = useMemo(
|
||||||
|
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
|
||||||
|
[tierCategories]
|
||||||
|
);
|
||||||
|
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: lessons,
|
allData: lessons,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -77,8 +89,8 @@ export default function LessonLibraryTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildDataColumns(attributes, rowActions),
|
() => buildDataColumns(attributes, rowActions, tierMap),
|
||||||
[attributes, rowActions]
|
[attributes, rowActions, tierMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveSuccess = () => {
|
const handleArchiveSuccess = () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState, useCallback } from "react";
|
import { useMemo, useRef, useState, useCallback, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
@@ -13,6 +13,7 @@ import { buildToolbarActions } from "../../config/library/units/toolbar.config";
|
|||||||
import { buildSelectionActions } from "../../config/library/units/selection.config";
|
import { buildSelectionActions } from "../../config/library/units/selection.config";
|
||||||
import { buildRowActions } from "../../config/library/units/rowActions.config";
|
import { buildRowActions } from "../../config/library/units/rowActions.config";
|
||||||
|
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
import { formatGeneratedBy } from "@/utils/generatedBy.util";
|
||||||
|
|
||||||
@@ -41,6 +42,17 @@ export default function UnitLibraryTable() {
|
|||||||
tableRefsRef.current = refs;
|
tableRefsRef.current = refs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [tierCategories, setTierCategories] = useState([]);
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/admin/tiers/categories")
|
||||||
|
.then(({ data }) => setTierCategories(data.data ?? []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const tierMap = useMemo(
|
||||||
|
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
|
||||||
|
[tierCategories]
|
||||||
|
);
|
||||||
|
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: units,
|
allData: units,
|
||||||
attributes,
|
attributes,
|
||||||
@@ -78,8 +90,8 @@ export default function UnitLibraryTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildDataColumns(attributes, rowActions),
|
() => buildDataColumns(attributes, rowActions, tierMap),
|
||||||
[attributes, rowActions]
|
[attributes, rowActions, tierMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveSuccess = () => {
|
const handleArchiveSuccess = () => {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
|
// Mirrors components/generic/AnnouncementDetailsDialog.jsx's real layout
|
||||||
|
// (text left, optional image right, single column when no image) so what's
|
||||||
|
// shown here while composing an alert is what recipients actually see when
|
||||||
|
// they open it from the sticky banner.
|
||||||
|
export default function AlertLayoutPreview({ title, message, imageAsset, linkLabel }) {
|
||||||
|
const imageSrc = imageAsset ? resolveAssetSrc(imageAsset) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border overflow-hidden bg-background">
|
||||||
|
<div className={imageSrc ? "grid sm:grid-cols-2" : ""}>
|
||||||
|
<div className="p-4 flex flex-col gap-2 min-w-0">
|
||||||
|
<p className="text-sm font-semibold truncate">{title || "Alert"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-4">
|
||||||
|
{message || "Your message will appear here."}
|
||||||
|
</p>
|
||||||
|
{linkLabel && (
|
||||||
|
<span className="self-start mt-1 rounded-md border px-2.5 py-1 text-xs font-medium">
|
||||||
|
{linkLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageSrc && (
|
||||||
|
<div className="aspect-video sm:aspect-auto sm:h-32 bg-muted flex items-center justify-center overflow-hidden">
|
||||||
|
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-10
@@ -52,7 +52,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
allData: broadcasts,
|
allData: broadcasts,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`,
|
filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`,
|
||||||
sheetName: "Archived Announcements",
|
sheetName: "Archived Alerts",
|
||||||
generatedBy: formatGeneratedBy(currentUser),
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Archived Announcements"
|
title="Archived Alerts"
|
||||||
data={broadcasts}
|
data={broadcasts}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
@@ -125,8 +125,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
columnPinning={columnPinning}
|
columnPinning={columnPinning}
|
||||||
toolbarActions={toolbarActions}
|
toolbarActions={toolbarActions}
|
||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="archived announcement"
|
recordLabel="archived alert"
|
||||||
emptyMessage="No archived announcements found."
|
emptyMessage="No archived alerts found."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Single restore ── */}
|
{/* ── Single restore ── */}
|
||||||
@@ -134,8 +134,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!restoreTarget}
|
open={!!restoreTarget}
|
||||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
entity={restoreTarget}
|
entity={restoreTarget}
|
||||||
entityLabel="Announcement"
|
entityLabel="Alert"
|
||||||
getName={(b) => b?.title ?? "this announcement"}
|
getName={(b) => b?.title ?? "this alert"}
|
||||||
onRestore={(b) => restoreBroadcast(b?.broadcast_id)}
|
onRestore={(b) => restoreBroadcast(b?.broadcast_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -146,7 +146,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!restoreIds}
|
open={!!restoreIds}
|
||||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
ids={restoreIds ?? []}
|
ids={restoreIds ?? []}
|
||||||
entityLabel="Announcement"
|
entityLabel="Alert"
|
||||||
onRestore={(ids) => restoreBroadcasts(ids)}
|
onRestore={(ids) => restoreBroadcasts(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -157,8 +157,8 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
entity={deleteTarget}
|
entity={deleteTarget}
|
||||||
entityLabel="Announcement"
|
entityLabel="Alert"
|
||||||
getName={(b) => b?.title ?? "this announcement"}
|
getName={(b) => b?.title ?? "this alert"}
|
||||||
onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)}
|
onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
@@ -169,7 +169,7 @@ export default function ArchivedNotificationBroadcastsTable() {
|
|||||||
open={!!deleteIds}
|
open={!!deleteIds}
|
||||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
ids={deleteIds ?? []}
|
ids={deleteIds ?? []}
|
||||||
entityLabel="Announcement"
|
entityLabel="Alert"
|
||||||
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
|
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
|||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
|
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
|
||||||
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
|
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
|
||||||
|
import { MakeAdminDialog } from "@/components/generic/Dialogs/MakeAdminDialog";
|
||||||
|
import { DemoteAdminDialog } from "@/components/generic/Dialogs/DemoteAdminDialog";
|
||||||
import { TableDashboard } from "@/components/generic/Dashboard/TableDashboard";
|
import { TableDashboard } from "@/components/generic/Dashboard/TableDashboard";
|
||||||
|
|
||||||
import { buildDataColumns, columnPinning } from "../../config/users/columns.config";
|
import { buildDataColumns, columnPinning } from "../../config/users/columns.config";
|
||||||
@@ -30,6 +32,8 @@ export default function UsersTable() {
|
|||||||
const [banIds, setBanIds] = useState(null);
|
const [banIds, setBanIds] = useState(null);
|
||||||
const [unbanTarget, setUnbanTarget] = useState(null);
|
const [unbanTarget, setUnbanTarget] = useState(null);
|
||||||
const [unbanIds, setUnbanIds] = useState(null);
|
const [unbanIds, setUnbanIds] = useState(null);
|
||||||
|
const [makeAdminTarget, setMakeAdminTarget] = useState(null);
|
||||||
|
const [demoteAdminTarget, setDemoteAdminTarget] = useState(null);
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
@@ -45,7 +49,7 @@ export default function UsersTable() {
|
|||||||
const {
|
const {
|
||||||
users, attributes, pagination, setPagination, loading,
|
users, attributes, pagination, setPagination, loading,
|
||||||
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
|
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
|
||||||
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers,
|
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, makeAdmin, demoteAdmin,
|
||||||
} = useUsers();
|
} = useUsers();
|
||||||
|
|
||||||
const { usersDashboard, fetchUsersDashboard } = useDashboard();
|
const { usersDashboard, fetchUsersDashboard } = useDashboard();
|
||||||
@@ -70,9 +74,12 @@ export default function UsersTable() {
|
|||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
navigate,
|
navigate,
|
||||||
onArchive: (row) => setArchiveTarget(row),
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
onBan: (row) => setBanTarget(row),
|
onBan: (row) => setBanTarget(row),
|
||||||
onUnban: (row) => setUnbanTarget(row),
|
onUnban: (row) => setUnbanTarget(row),
|
||||||
|
onMakeAdmin: (row) => setMakeAdminTarget(row),
|
||||||
|
onDemoteAdmin: (row) => setDemoteAdminTarget(row),
|
||||||
|
currentUserId: currentUser?.user_id,
|
||||||
});
|
});
|
||||||
const toolbarActions = buildToolbarActions({
|
const toolbarActions = buildToolbarActions({
|
||||||
fetchUsers, pagination, exportConfig, navigate,
|
fetchUsers, pagination, exportConfig, navigate,
|
||||||
@@ -113,6 +120,16 @@ export default function UsersTable() {
|
|||||||
fetchUsers({ page: 1, limit: pagination.limit });
|
fetchUsers({ page: 1, limit: pagination.limit });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMakeAdminSuccess = () => {
|
||||||
|
setMakeAdminTarget(null);
|
||||||
|
fetchUsers({ page: 1, limit: pagination.limit });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoteAdminSuccess = () => {
|
||||||
|
setDemoteAdminTarget(null);
|
||||||
|
fetchUsers({ page: 1, limit: pagination.limit });
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Attach filterId/filterValue (or onClick) to each stat ────────────────
|
// ─── Attach filterId/filterValue (or onClick) to each stat ────────────────
|
||||||
const statsWithFilter = (usersDashboard?.stats ?? []).map((s) => ({
|
const statsWithFilter = (usersDashboard?.stats ?? []).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
@@ -239,6 +256,26 @@ export default function UsersTable() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleUnbanSuccess}
|
onSuccess={handleUnbanSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Make admin */}
|
||||||
|
<MakeAdminDialog
|
||||||
|
open={!!makeAdminTarget}
|
||||||
|
onOpenChange={(v) => !v && setMakeAdminTarget(null)}
|
||||||
|
entity={makeAdminTarget}
|
||||||
|
onMakeAdmin={(u) => makeAdmin(u?.user_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleMakeAdminSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Demote admin */}
|
||||||
|
<DemoteAdminDialog
|
||||||
|
open={!!demoteAdminTarget}
|
||||||
|
onOpenChange={(v) => !v && setDemoteAdminTarget(null)}
|
||||||
|
entity={demoteAdminTarget}
|
||||||
|
onDemoteAdmin={(u) => demoteAdmin(u?.user_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleDemoteAdminSuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,6 +34,6 @@ export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v
|
|||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Advertisement Actions" }),
|
buildRowActionsColumn(rowActions, { dropdownLabel: "Ad Actions" }),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function buildRowActions({ onRestore, onDelete }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "restore",
|
||||||
|
label: "Restore",
|
||||||
|
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-emerald-600",
|
||||||
|
onClick: (row) => onRestore(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive focus:text-destructive",
|
||||||
|
onClick: (row) => onDelete(row),
|
||||||
|
separator: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function buildSelectionActions({ onRestore, onRestoreMany, onDelete, onDeleteMany }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "restore-selected",
|
||||||
|
label: "Restore",
|
||||||
|
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-emerald-600 border-emerald-200 hover:bg-emerald-50",
|
||||||
|
onClick: (rows) => {
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delete-selected",
|
||||||
|
label: "Delete",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
onClick: (rows) => {
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
ids.length === 1 ? onDelete(rows[0]) : onDeleteMany(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { RefreshCw, Download, ArchiveRestore } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
export function buildToolbarActions({ fetchCategories, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "refresh",
|
||||||
|
type: "button",
|
||||||
|
label: "Refresh",
|
||||||
|
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => fetchCategories({
|
||||||
|
page: 1,
|
||||||
|
limit: pagination?.limit ?? 10,
|
||||||
|
filters: getFilters(),
|
||||||
|
sort: getSort(),
|
||||||
|
archived: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
type: "button",
|
||||||
|
label: "Export",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
tableInstance: getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "active",
|
||||||
|
type: "button",
|
||||||
|
label: "Active Categories",
|
||||||
|
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||||
|
variant: "secondary",
|
||||||
|
className: "border border-border",
|
||||||
|
onClick: () => navigate("/admin/courses/categories"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { buildColumns } from "@/utils/table.util";
|
||||||
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
right: ["actions"],
|
||||||
|
left: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDataColumns(attributes, rowActions) {
|
||||||
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
const dataColumns = buildColumns(visibleAttributes);
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildSelectionColumn(),
|
||||||
|
...dataColumns,
|
||||||
|
buildRowActionsColumn(rowActions, { dropdownLabel: "Category Actions" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function buildRowActions({ navigate, onArchive }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => navigate(`/admin/courses/categories/${row.id}/edit`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "archive",
|
||||||
|
label: "Archive",
|
||||||
|
icon: <Trash2 className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive",
|
||||||
|
onClick: (row) => onArchive(row),
|
||||||
|
separator: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Archive } from "lucide-react";
|
||||||
|
|
||||||
|
export function buildSelectionActions({ onArchive, onArchiveMany }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "archive-selected",
|
||||||
|
label: "Archive",
|
||||||
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
onClick: (rows) => {
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
export function buildToolbarActions({ fetchCategories, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "refresh",
|
||||||
|
type: "button",
|
||||||
|
label: "Refresh",
|
||||||
|
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => fetchCategories({
|
||||||
|
page: 1,
|
||||||
|
limit: pagination?.limit ?? 10,
|
||||||
|
filters: getFilters(),
|
||||||
|
sort: getSort(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
type: "button",
|
||||||
|
label: "Export",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
variant: "outline",
|
||||||
|
onClick: () => exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
tableInstance: getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "create",
|
||||||
|
type: "button",
|
||||||
|
label: "Add Category",
|
||||||
|
icon: <Plus className="h-3.5 w-3.5" />,
|
||||||
|
variant: "default",
|
||||||
|
onClick: () => navigate("/admin/courses/categories/add"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "archived",
|
||||||
|
type: "button",
|
||||||
|
label: "Archived",
|
||||||
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
variant: "secondary",
|
||||||
|
className: "border border-border",
|
||||||
|
onClick: () => navigate("/admin/courses/categories/archived"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ import { buildColumns } from "@/utils/table.util";
|
|||||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Book, BookOpenCheck, Clock } from "lucide-react";
|
import { Book, BookOpenCheck, Clock, Tag } from "lucide-react";
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
import { formatDuration } from "@/utils/timestamp.util";
|
import { formatDuration } from "@/utils/timestamp.util";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
@@ -15,7 +17,8 @@ export const columnPinning = {
|
|||||||
|
|
||||||
|
|
||||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||||
const cellOverrides = {
|
function buildCellOverrides(tierMap) {
|
||||||
|
return {
|
||||||
unitCount: (info) => {
|
unitCount: (info) => {
|
||||||
const count = parseInt(info.getValue() ?? 0, 10);
|
const count = parseInt(info.getValue() ?? 0, 10);
|
||||||
return (
|
return (
|
||||||
@@ -50,17 +53,33 @@ const cellOverrides = {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
subscription: (info) => {
|
||||||
|
const slug = info.getValue();
|
||||||
|
if (!slug) return <span className="text-muted-foreground/40">-</span>;
|
||||||
|
|
||||||
|
const { label, cls } = resolveTierBadge(slug, tierMap);
|
||||||
|
const Icon = LucideIcons[tierMap[slug]?.badge_icon] ?? Tag;
|
||||||
|
return (
|
||||||
|
<Badge className={cls}>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the full column array for the Users table.
|
* Builds the full column array for the Users table.
|
||||||
*
|
*
|
||||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||||
* @param {Array} rowActions Row-level kebab action definitions
|
* @param {Array} rowActions Row-level kebab action definitions
|
||||||
|
* @param {Object} tierMap slug → tier category, for colored Subscription badges
|
||||||
* @returns {Array} TanStack column definitions
|
* @returns {Array} TanStack column definitions
|
||||||
*/
|
*/
|
||||||
export function buildDataColumns(attributes, rowActions) {
|
export function buildDataColumns(attributes, rowActions, tierMap = {}) {
|
||||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
const cellOverrides = buildCellOverrides(tierMap);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
|
|||||||
@@ -2,18 +2,21 @@
|
|||||||
// Column definitions and pinning for the standalone Lesson Library table.
|
// Column definitions and pinning for the standalone Lesson Library table.
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Clock, Layers } from "lucide-react";
|
import { Clock, Layers, Tag } from "lucide-react";
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
import { buildColumns } from "@/utils/table.util";
|
import { buildColumns } from "@/utils/table.util";
|
||||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { formatDuration } from "@/utils/timestamp.util";
|
import { formatDuration } from "@/utils/timestamp.util";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
left: [],
|
left: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const cellOverrides = {
|
function buildCellOverrides(tierMap) {
|
||||||
|
return {
|
||||||
title: (info) => {
|
title: (info) => {
|
||||||
const inUnit = parseInt(info.row.original.unit_count ?? 0, 10) > 0;
|
const inUnit = parseInt(info.row.original.unit_count ?? 0, 10) > 0;
|
||||||
return (
|
return (
|
||||||
@@ -49,10 +52,28 @@ const cellOverrides = {
|
|||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
subscription: (info) => {
|
||||||
|
const own = info.getValue();
|
||||||
|
// Not gated directly — fall back to the tier(s) inherited from any
|
||||||
|
// affiliated course(s) instead of showing a bare "-".
|
||||||
|
const slug = own || info.row.original.course_subscription;
|
||||||
|
if (!slug) return <span className="text-muted-foreground/40">-</span>;
|
||||||
|
|
||||||
export function buildDataColumns(attributes, rowActions) {
|
const { label, cls } = resolveTierBadge(slug, tierMap);
|
||||||
|
const Icon = LucideIcons[tierMap[slug]?.badge_icon] ?? Tag;
|
||||||
|
return (
|
||||||
|
<Badge className={cls}>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDataColumns(attributes, rowActions, tierMap = {}) {
|
||||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
const cellOverrides = buildCellOverrides(tierMap);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
|
|||||||
@@ -20,21 +20,21 @@ export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArc
|
|||||||
// icon: <Pencil className="h-3.5 w-3.5" />,
|
// icon: <Pencil className="h-3.5 w-3.5" />,
|
||||||
// onClick: (row) => onEdit(row),
|
// onClick: (row) => onEdit(row),
|
||||||
// },
|
// },
|
||||||
{
|
// {
|
||||||
key: "build_page",
|
// key: "build_page",
|
||||||
label: "Page Builder",
|
// label: "Page Builder",
|
||||||
icon: <LayoutTemplate className="h-3.5 w-3.5" />,
|
// icon: <LayoutTemplate className="h-3.5 w-3.5" />,
|
||||||
className: "text-sky-700 hover:text-sky-600",
|
// className: "text-sky-700 hover:text-sky-600",
|
||||||
onClick: (row) => onBuildPage(row),
|
// onClick: (row) => onBuildPage(row),
|
||||||
separator: true,
|
// separator: true,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
key: "view_page",
|
// key: "view_page",
|
||||||
label: "View Page",
|
// label: "View Page",
|
||||||
icon: <FileText className="h-3.5 w-3.5" />,
|
// icon: <FileText className="h-3.5 w-3.5" />,
|
||||||
className: "text-sky-700 hover:text-sky-600",
|
// className: "text-sky-700 hover:text-sky-600",
|
||||||
onClick: (row) => onViewPage(row),
|
// onClick: (row) => onViewPage(row),
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// config/library/lessons/toolbar.config.jsx
|
// config/library/lessons/toolbar.config.jsx
|
||||||
// Toolbar actions for the Lesson Library (active + archived variants).
|
// Toolbar actions for the Lesson Library (active + archived variants).
|
||||||
|
|
||||||
import { Plus, RefreshCw, Download, Archive, ArrowLeft, Upload } from "lucide-react";
|
import { Plus, RefreshCw, Download, Archive, ArrowLeft } from "lucide-react";
|
||||||
import { exportTableToExcel } from "@/utils/excel.util";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
export function buildToolbarActions({
|
export function buildToolbarActions({
|
||||||
@@ -46,14 +46,6 @@ export function buildToolbarActions({
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
onClick: () => navigate("/admin/lessons/add"),
|
onClick: () => navigate("/admin/lessons/add"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "import",
|
|
||||||
type: "button",
|
|
||||||
label: "Import",
|
|
||||||
icon: <Upload className="h-3.5 w-3.5" />,
|
|
||||||
variant: "outline",
|
|
||||||
onClick: () => navigate("/admin/lessons/import"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "archived-lessons",
|
key: "archived-lessons",
|
||||||
type: "button",
|
type: "button",
|
||||||
@@ -76,15 +68,6 @@ export function buildArchivedToolbarActions({
|
|||||||
getTableInstance,
|
getTableInstance,
|
||||||
}) {
|
}) {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
key: "back",
|
|
||||||
type: "button",
|
|
||||||
label: "Back to Lessons",
|
|
||||||
icon: <ArrowLeft className="h-3.5 w-3.5" />,
|
|
||||||
variant: "secondary",
|
|
||||||
className: "border border-border",
|
|
||||||
onClick: () => navigate("/admin/lessons"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "refresh",
|
key: "refresh",
|
||||||
type: "button",
|
type: "button",
|
||||||
|
|||||||
@@ -2,54 +2,75 @@
|
|||||||
// Column definitions and pinning for the standalone Unit Library table.
|
// Column definitions and pinning for the standalone Unit Library table.
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Clock, GraduationCap } from "lucide-react";
|
import { Clock, GraduationCap, Tag } from "lucide-react";
|
||||||
|
import * as LucideIcons from "lucide-react";
|
||||||
import { buildColumns } from "@/utils/table.util";
|
import { buildColumns } from "@/utils/table.util";
|
||||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
import { formatDuration } from "@/utils/timestamp.util";
|
import { formatDuration } from "@/utils/timestamp.util";
|
||||||
|
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
left: [],
|
left: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const cellOverrides = {
|
function buildCellOverrides(tierMap) {
|
||||||
title: (info) => {
|
return {
|
||||||
const inCourse = parseInt(info.row.original.course_count ?? 0, 10) > 0;
|
title: (info) => {
|
||||||
return (
|
const inCourse = parseInt(info.row.original.course_count ?? 0, 10) > 0;
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
return (
|
||||||
{inCourse && (
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<Badge variant="secondary" className="gap-1 text-[10px] font-medium shrink-0">
|
{inCourse && (
|
||||||
<GraduationCap className="h-3 w-3" />
|
<Badge variant="secondary" className="gap-1 text-[10px] font-medium shrink-0">
|
||||||
Course
|
<GraduationCap className="h-3 w-3" />
|
||||||
|
Course
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="block truncate max-w-56 text-sm" title={info.getValue()}>
|
||||||
|
{info.getValue()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
duration_seconds: (info) => {
|
||||||
|
const seconds = parseInt(info.getValue() ?? 0, 10);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||||
|
{formatDuration(seconds)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
</div>
|
||||||
<span className="block truncate max-w-56 text-sm" title={info.getValue()}>
|
);
|
||||||
{info.getValue()}
|
},
|
||||||
</span>
|
lesson_count: (info) => (
|
||||||
</div>
|
<Badge variant="outline" className="text-xs tabular-nums">
|
||||||
);
|
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
|
||||||
},
|
</Badge>
|
||||||
duration_seconds: (info) => {
|
),
|
||||||
const seconds = parseInt(info.getValue() ?? 0, 10);
|
subscription: (info) => {
|
||||||
return (
|
const own = info.getValue();
|
||||||
<div className="flex items-center gap-1.5">
|
// Not gated directly — fall back to the tier(s) inherited from any
|
||||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
// affiliated course(s) instead of showing a bare "-".
|
||||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
const slug = own || info.row.original.course_subscription;
|
||||||
{formatDuration(seconds)}
|
if (!slug) return <span className="text-muted-foreground/40">-</span>;
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
lesson_count: (info) => (
|
|
||||||
<Badge variant="outline" className="text-xs tabular-nums">
|
|
||||||
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildDataColumns(attributes, rowActions) {
|
const { label, cls } = resolveTierBadge(slug, tierMap);
|
||||||
|
const Icon = LucideIcons[tierMap[slug]?.badge_icon] ?? Tag;
|
||||||
|
return (
|
||||||
|
<Badge className={cls}>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDataColumns(attributes, rowActions, tierMap = {}) {
|
||||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
const cellOverrides = buildCellOverrides(tierMap);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
|
|||||||
@@ -14,40 +14,40 @@ export function buildRowActions({ onView, onEdit, onManageLessons, onArchive, on
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => onView(row),
|
onClick: (row) => onView(row),
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
key: "manage_lessons",
|
// key: "manage_lessons",
|
||||||
label: "Manage Lessons",
|
// label: "Manage Lessons",
|
||||||
icon: <BookCheck className="h-3.5 w-3.5" />,
|
// icon: <BookCheck className="h-3.5 w-3.5" />,
|
||||||
className: "text-sky-700 hover:text-sky-600",
|
// className: "text-sky-700 hover:text-sky-600",
|
||||||
onClick: (row) => onManageLessons(row),
|
// onClick: (row) => onManageLessons(row),
|
||||||
separator: true,
|
// separator: true,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
key: "create_quiz",
|
// key: "create_quiz",
|
||||||
label: "Create Quiz",
|
// label: "Create Quiz",
|
||||||
icon: <PlusCircle className="h-3.5 w-3.5" />,
|
// icon: <PlusCircle className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
// className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onQuiz(row),
|
// onClick: (row) => onQuiz(row),
|
||||||
hidden: (row) => !!(row.quiz_id || row.quiz),
|
// hidden: (row) => !!(row.quiz_id || row.quiz),
|
||||||
separator: true,
|
// separator: true,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
key: "view_quiz",
|
// key: "view_quiz",
|
||||||
label: "View Quiz",
|
// label: "View Quiz",
|
||||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
// icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
// className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onViewQuiz(row),
|
// onClick: (row) => onViewQuiz(row),
|
||||||
hidden: (row) => !(row.quiz_id || row.quiz),
|
// hidden: (row) => !(row.quiz_id || row.quiz),
|
||||||
separator: true,
|
// separator: true,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
key: "modify_quiz",
|
// key: "modify_quiz",
|
||||||
label: "Modify Quiz",
|
// label: "Modify Quiz",
|
||||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
// icon: <NotebookPen className="h-3.5 w-3.5" />,
|
||||||
className: "text-purple-700 hover:text-purple-600",
|
// className: "text-purple-700 hover:text-purple-600",
|
||||||
onClick: (row) => onQuiz(row),
|
// onClick: (row) => onQuiz(row),
|
||||||
hidden: (row) => !(row.quiz_id || row.quiz),
|
// hidden: (row) => !(row.quiz_id || row.quiz),
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
key: "archive",
|
key: "archive",
|
||||||
label: "Archive",
|
label: "Archive",
|
||||||
|
|||||||
@@ -3,17 +3,20 @@
|
|||||||
//
|
//
|
||||||
// Each onClick receives the row's data object from buildRowActionsColumn.
|
// Each onClick receives the row's data object from buildRowActionsColumn.
|
||||||
|
|
||||||
import { Eye, Pencil, Archive, ShieldBan, ShieldCheck } from "lucide-react";
|
import { Eye, Pencil, Archive, ShieldBan, ShieldCheck, Crown, UserMinus } from "lucide-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Object} deps
|
* @param {Object} deps
|
||||||
* @param {Function} deps.navigate React Router navigate
|
* @param {Function} deps.navigate React Router navigate
|
||||||
* @param {Function} deps.onArchive Opens archive dialog
|
* @param {Function} deps.onArchive Opens archive dialog
|
||||||
* @param {Function} deps.onBan Opens ban dialog
|
* @param {Function} deps.onBan Opens ban dialog
|
||||||
* @param {Function} deps.onUnban Opens unban dialog
|
* @param {Function} deps.onUnban Opens unban dialog
|
||||||
|
* @param {Function} deps.onMakeAdmin Opens make-admin dialog
|
||||||
|
* @param {Function} deps.onDemoteAdmin Opens demote-admin dialog
|
||||||
|
* @param {number} deps.currentUserId Logged-in admin's own user_id (hides self role-change)
|
||||||
* @returns {Array} rowActions
|
* @returns {Array} rowActions
|
||||||
*/
|
*/
|
||||||
export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
|
export function buildRowActions({ navigate, onArchive, onBan, onUnban, onMakeAdmin, onDemoteAdmin, currentUserId }) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "view",
|
key: "view",
|
||||||
@@ -21,6 +24,21 @@ export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
|
|||||||
icon: <Eye className="h-3.5 w-3.5" />,
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
onClick: (row) => navigate(`view/${row.user_id}`),
|
onClick: (row) => navigate(`view/${row.user_id}`),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "make-admin",
|
||||||
|
label: "Make Admin",
|
||||||
|
icon: <Crown className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onMakeAdmin(row),
|
||||||
|
hidden: (row) => row.acc_type === "admin" || row.user_id === currentUserId,
|
||||||
|
separator: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "demote-admin",
|
||||||
|
label: "Demote",
|
||||||
|
icon: <UserMinus className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onDemoteAdmin(row),
|
||||||
|
hidden: (row) => row.acc_type !== "admin" || row.user_id === currentUserId,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "ban",
|
key: "ban",
|
||||||
label: "Ban User",
|
label: "Ban User",
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const schema = z.object({
|
|||||||
}).default({}),
|
}).default({}),
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
const format = PLACEMENT_MAP[data.placement]?.format;
|
const format = PLACEMENT_MAP[data.placement]?.format;
|
||||||
@@ -84,8 +83,8 @@ const ALL_STEPS = [
|
|||||||
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
|
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
|
||||||
{ id: "content", label: "Content", icon: FileText, description: "Full image, or content with badge, headline, description, and CTAs." },
|
{ id: "content", label: "Content", icon: FileText, description: "Full image, or content with badge, headline, description, and CTAs." },
|
||||||
{ id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true },
|
{ id: "pageBuilder", label: "Page Builder", icon: LayoutTemplate, description: "No redirect link? Build an internal landing page for this ad instead.", skippable: true },
|
||||||
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates, display order, and draft/active status." },
|
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." },
|
||||||
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
|
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -245,11 +244,21 @@ function StepContent({
|
|||||||
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
|
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contentMode === "image" && (
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Title</Label>
|
||||||
|
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Internal label shown in the Ads list — not displayed on the ad itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{contentMode === "content" && (
|
{contentMode === "content" && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Badge label</Label>
|
<Label className="mb-1.5 block">Badge label</Label>
|
||||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
<Input placeholder="e.g. Ad" {...register("badge_label")} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Headline</Label>
|
<Label className="mb-1.5 block">Headline</Label>
|
||||||
@@ -373,7 +382,7 @@ function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
|
|||||||
|
|
||||||
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
|
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
|
||||||
|
|
||||||
function StepScheduling({ register, watch, setValue }) {
|
function StepScheduling({ watch, setValue }) {
|
||||||
const isActive = watch("is_active");
|
const isActive = watch("is_active");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -397,13 +406,10 @@ function StepScheduling({ register, watch, setValue }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div>
|
||||||
<div>
|
<Label className="mb-1.5 block">Status</Label>
|
||||||
<Label className="mb-1.5 block">Order</Label>
|
|
||||||
<Input type="number" min={0} {...register("order")} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||||
<Label className="text-sm">{isActive ? "Active" : "Draft"}</Label>
|
<span className="text-sm">{isActive ? "Active" : "Draft"}</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isActive}
|
checked={isActive}
|
||||||
onCheckedChange={(v) => setValue("is_active", v)}
|
onCheckedChange={(v) => setValue("is_active", v)}
|
||||||
@@ -495,7 +501,6 @@ function StepReview({ data, selectedAsset, imageUrl }) {
|
|||||||
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
||||||
<SummaryRow label="Start date" value={data.start_date ? fmtDateTime(data.start_date) : null} />
|
<SummaryRow label="Start date" value={data.start_date ? fmtDateTime(data.start_date) : null} />
|
||||||
<SummaryRow label="End date" value={data.end_date ? fmtDateTime(data.end_date) : null} />
|
<SummaryRow label="End date" value={data.end_date ? fmtDateTime(data.end_date) : null} />
|
||||||
<SummaryRow label="Order" value={data.order} />
|
|
||||||
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
|
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -537,7 +542,6 @@ export default function AddAdvertisement() {
|
|||||||
landing_page: { title: "", description: "", body: "", links: [] },
|
landing_page: { title: "", description: "", body: "", links: [] },
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
order: 0,
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -565,7 +569,7 @@ export default function AddAdvertisement() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
{ label: "Ads", to: "/admin/advertisements" },
|
||||||
{ label: "New" },
|
{ label: "New" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -651,7 +655,7 @@ export default function AddAdvertisement() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{current.id === "scheduling" && (
|
{current.id === "scheduling" && (
|
||||||
<StepScheduling register={register} watch={watch} setValue={setValue} />
|
<StepScheduling watch={watch} setValue={setValue} />
|
||||||
)}
|
)}
|
||||||
{current.id === "review" && (
|
{current.id === "review" && (
|
||||||
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
|
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2, Archive } from "lucide-react";
|
import { House, Plus, Search, Megaphone, ListOrdered, Edit, Trash2, Archive, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
@@ -18,14 +18,14 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_FILTERABLE_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_FILTERABLE_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||||
import { PLACEMENT_MAP } from "@/data/placement.data";
|
import { PLACEMENTS, PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE = 10;
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
export default function AdvertisementList() {
|
export default function AdvertisementList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement, reorderAdvertisement } = useAdvertisements();
|
||||||
|
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
@@ -57,7 +57,7 @@ export default function AdvertisementList() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Advertisements" },
|
{ label: "Ads" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const total = pagination?.totalRecords ?? advertisements.length;
|
const total = pagination?.totalRecords ?? advertisements.length;
|
||||||
@@ -68,6 +68,25 @@ export default function AdvertisementList() {
|
|||||||
await archiveAdvertisement(advertisementId);
|
await archiveAdvertisement(advertisementId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReorder(advertisementId, direction) {
|
||||||
|
await reorderAdvertisement(advertisementId, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ads grouped by placement (order matters only within a placement — it's
|
||||||
|
// what the live site uses to pick which ad wins that slot), sorted by
|
||||||
|
// `order` ASC. Any ad whose placement isn't in the known registry falls
|
||||||
|
// into a trailing "Unassigned" group instead of disappearing.
|
||||||
|
const knownPlacementKeys = new Set(PLACEMENTS.map((p) => p.key));
|
||||||
|
const groups = [
|
||||||
|
...PLACEMENTS.map((p) => ({ key: p.key, heading: `${p.pageLabel} — ${p.slotLabel}` })),
|
||||||
|
{ key: null, heading: "Unassigned placement" },
|
||||||
|
].map(({ key, heading }) => ({
|
||||||
|
key, heading,
|
||||||
|
ads: advertisements
|
||||||
|
.filter((a) => (key ? a.placement === key : !knownPlacementKeys.has(a.placement)))
|
||||||
|
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||||
|
})).filter((g) => g.ads.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted h-full">
|
<section className="bg-muted h-full">
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||||
@@ -80,7 +99,7 @@ export default function AdvertisementList() {
|
|||||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Ads</h1>
|
||||||
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
|
<p className="text-sm text-muted-foreground">Manage public-facing hero and banner placements</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -90,7 +109,7 @@ export default function AdvertisementList() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New advertisement
|
New ad
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +151,7 @@ export default function AdvertisementList() {
|
|||||||
<div className="relative w-64">
|
<div className="relative w-64">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search advertisements..."
|
placeholder="Search ads..."
|
||||||
className="pl-8 bg-background"
|
className="pl-8 bg-background"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
@@ -154,15 +173,27 @@ export default function AdvertisementList() {
|
|||||||
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="flex flex-col gap-6">
|
||||||
{advertisements.map((ad) => (
|
{groups.map((group) => (
|
||||||
<AdvertisementCard
|
<div key={group.key ?? "unassigned"} className="flex flex-col gap-3">
|
||||||
key={ad.advertisement_id}
|
<h2 className="text-sm font-semibold text-muted-foreground">{group.heading}</h2>
|
||||||
ad={ad}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
|
{group.ads.map((ad, index) => (
|
||||||
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
|
<AdvertisementCard
|
||||||
onArchive={() => handleArchive(ad.advertisement_id)}
|
key={ad.advertisement_id}
|
||||||
/>
|
ad={ad}
|
||||||
|
position={index + 1}
|
||||||
|
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
|
||||||
|
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
|
||||||
|
onArchive={() => handleArchive(ad.advertisement_id)}
|
||||||
|
canMoveUp={index > 0}
|
||||||
|
canMoveDown={index < group.ads.length - 1}
|
||||||
|
onMoveUp={() => handleReorder(ad.advertisement_id, "up")}
|
||||||
|
onMoveDown={() => handleReorder(ad.advertisement_id, "down")}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -172,7 +203,7 @@ export default function AdvertisementList() {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onPageSizeChange={handlePageSizeChange}
|
onPageSizeChange={handlePageSizeChange}
|
||||||
rowCount={advertisements.length}
|
rowCount={advertisements.length}
|
||||||
recordLabel="advertisement"
|
recordLabel="ad"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -203,7 +234,7 @@ function StatCard({ label, value, tone = "default" }) {
|
|||||||
|
|
||||||
// ─── Advertisement card ─────────────────────────────────────────────────────
|
// ─── Advertisement card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp, canMoveDown, onMoveUp, onMoveDown }) {
|
||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||||
@@ -221,7 +252,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onView}
|
onClick={onView}
|
||||||
className="h-32 bg-muted dark:bg-purple-950 relative flex items-center justify-center w-full text-left cursor-pointer"
|
className="h-32 bg-muted dark:bg-purple-950 relative flex items-center justify-center w-full text-left cursor-pointer"
|
||||||
aria-label="View advertisement details"
|
aria-label="View ad details"
|
||||||
>
|
>
|
||||||
{previewSrc ? (
|
{previewSrc ? (
|
||||||
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
|
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
|
||||||
@@ -240,7 +271,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
|||||||
|
|
||||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||||
<button type="button" onClick={onView} className="text-left">
|
<button type="button" onClick={onView} className="text-left">
|
||||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
|
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled ad"}</p>
|
||||||
{placementMeta ? (
|
{placementMeta ? (
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{placementMeta.pageLabel} — {placementMeta.slotLabel}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -251,10 +282,16 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
|||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground pt-2">
|
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<MousePointerClick className="size-3.5" />
|
<ListOrdered className="size-3.5" />
|
||||||
{ad.click_count ?? 0} clicks
|
Order #{position}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={onMoveUp} disabled={!canMoveUp} aria-label="Move up">
|
||||||
|
<ArrowUp className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="size-7" onClick={onMoveDown} disabled={!canMoveDown} aria-label="Move down">
|
||||||
|
<ArrowDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
|
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
|
||||||
<Edit className="size-3.5" />
|
<Edit className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -266,9 +303,9 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
|||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Archive this advertisement?</AlertDialogTitle>
|
<AlertDialogTitle>Archive this ad?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
"{ad.headline || ad.badge_label || "This advertisement"}" will be moved to archived advertisements. You can restore it later.
|
"{ad.headline || ad.badge_label || "This ad"}" will be moved to archived ads. You can restore it later.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -291,12 +328,12 @@ function EmptyState({ onCreate }) {
|
|||||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
||||||
<Megaphone className="size-8 text-muted-foreground" />
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">No advertisements yet</p>
|
<p className="font-medium">No ads yet</p>
|
||||||
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
|
<p className="text-sm text-muted-foreground">Create your first hero or banner placement.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onCreate}>
|
<Button onClick={onCreate}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New advertisement
|
New ad
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ArchivedAdvertisementsTable from "../../components/advertisements/Archive
|
|||||||
export default function ArchivedAdvertisementList() {
|
export default function ArchivedAdvertisementList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Advertisements", to: `/admin/advertisements` },
|
{ label: "Ads", to: `/admin/advertisements` },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const schema = z.object({
|
|||||||
}).default({}),
|
}).default({}),
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
order: z.coerce.number().min(0).default(0),
|
|
||||||
is_active: z.boolean().default(true),
|
is_active: z.boolean().default(true),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
const format = PLACEMENT_MAP[data.placement]?.format;
|
const format = PLACEMENT_MAP[data.placement]?.format;
|
||||||
@@ -134,7 +133,6 @@ export default function EditAdvertisement() {
|
|||||||
landing_page: { title: "", description: "", body: "", links: [] },
|
landing_page: { title: "", description: "", body: "", links: [] },
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
order: 0,
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -152,7 +150,7 @@ export default function EditAdvertisement() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
{ label: "Ads", to: "/admin/advertisements" },
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -192,7 +190,6 @@ export default function EditAdvertisement() {
|
|||||||
},
|
},
|
||||||
start_date: ad.start_date ?? "",
|
start_date: ad.start_date ?? "",
|
||||||
end_date: ad.end_date ?? "",
|
end_date: ad.end_date ?? "",
|
||||||
order: ad.order ?? 0,
|
|
||||||
is_active: ad.is_active ?? true,
|
is_active: ad.is_active ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -287,11 +284,21 @@ export default function EditAdvertisement() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contentMode === "image" && (
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block">Title</Label>
|
||||||
|
<Input placeholder="e.g. Summer Enrollment Banner" {...register("headline")} />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
|
Internal label shown in the Ads list — not displayed on the ad itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{contentMode === "content" && (
|
{contentMode === "content" && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Badge label</Label>
|
<Label className="mb-1.5 block">Badge label</Label>
|
||||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
<Input placeholder="e.g. Ad" {...register("badge_label")} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Headline</Label>
|
<Label className="mb-1.5 block">Headline</Label>
|
||||||
@@ -458,14 +465,11 @@ export default function EditAdvertisement() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Display" description="Manual ordering and draft/active switch.">
|
<SectionCard title="Display" description="Draft/active switch.">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div>
|
||||||
<div>
|
<Label className="mb-1.5 block">Status</Label>
|
||||||
<Label className="mb-1.5 block">Order</Label>
|
|
||||||
<Input type="number" min={0} {...register("order")} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||||
<Label className="text-sm">{watch("is_active") ? "Active" : "Draft"}</Label>
|
<span className="text-sm">{watch("is_active") ? "Active" : "Draft"}</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={watch("is_active")}
|
checked={watch("is_active")}
|
||||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function ViewAdvertisement() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
{ label: "Ads", to: "/admin/advertisements" },
|
||||||
{ label: "View" },
|
{ label: "View" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export default function ViewAdvertisement() {
|
|||||||
<div className="flex flex-col gap-2 my-6 w-full">
|
<div className="flex flex-col gap-2 my-6 w-full">
|
||||||
<AppBreadcrumb items={breadcrumbItems} />
|
<AppBreadcrumb items={breadcrumbItems} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">Advertisement not found.</p>
|
<p className="text-sm text-muted-foreground">Ad not found.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -112,7 +112,7 @@ export default function ViewAdvertisement() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
|
{advertisement.headline || advertisement.badge_label || "Untitled ad"}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { House } from "lucide-react";
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import ArchivedCategoriesTable from "../../components/categories/ArchivedCategoriesTable";
|
||||||
|
|
||||||
|
const BREADCRUMB = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
|
{ label: "Categories", to: "/admin/courses/categories" },
|
||||||
|
{ label: "Archived" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ArchivedCategoryList() {
|
||||||
|
return (
|
||||||
|
<section className="bg-muted/60 min-h-full">
|
||||||
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||||
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
|
<AppBreadcrumb items={BREADCRUMB} />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<ArchivedCategoriesTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { House } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { House, Plus, Pencil, Trash2, RotateCcw, Tag } from "lucide-react";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import CategoriesTable from "../../components/categories/CategoriesTable";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
|
||||||
|
|
||||||
const BREADCRUMB = [
|
const BREADCRUMB = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -13,113 +8,15 @@ const BREADCRUMB = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export default function CategoryList() {
|
export default function CategoryList() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { categories, loading, fetchCategories, archiveCategory, restoreCategory } = useCategories();
|
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { fetchCategories(showArchived); }, [showArchived]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 min-h-full">
|
<section className="bg-muted/60 min-h-full">
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
<AppBreadcrumb items={BREADCRUMB} />
|
<AppBreadcrumb items={BREADCRUMB} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
<div className="w-full flex items-center justify-between mb-4">
|
<CategoriesTable />
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Categories</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">Manage course categories for browsing and filtering.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowArchived((v) => !v)}
|
|
||||||
>
|
|
||||||
{showArchived ? "Active" : "Archived"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={() => navigate("/admin/courses/categories/add")}>
|
|
||||||
<Plus className="size-4" /> Add Category
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full rounded-lg border bg-card overflow-hidden">
|
|
||||||
{loading ? (
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : categories.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<Tag className="size-8 text-muted-foreground/40 mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{showArchived ? "No archived categories." : "No categories yet. Add one to get started."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="border-b bg-muted/40">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Name</th>
|
|
||||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Slug</th>
|
|
||||||
<th className="text-left px-4 py-2.5 font-medium text-muted-foreground">Status</th>
|
|
||||||
<th className="px-4 py-2.5" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{categories.map((cat) => (
|
|
||||||
<tr key={cat.id} className="hover:bg-muted/30 transition-colors">
|
|
||||||
<td className="px-4 py-3 font-medium">{cat.name}</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{cat.slug}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant={cat.is_active ? "default" : "secondary"}>
|
|
||||||
{cat.is_active ? "Active" : "Inactive"}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{!showArchived ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="size-7"
|
|
||||||
onClick={() => navigate(`/admin/courses/categories/${cat.id}/edit`)}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="size-7 text-destructive hover:text-destructive"
|
|
||||||
onClick={async () => { await archiveCategory(cat.id); }}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
className="size-7 text-emerald-600"
|
|
||||||
onClick={async () => { await restoreCategory(cat.id); }}
|
|
||||||
>
|
|
||||||
<RotateCcw className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -250,6 +250,10 @@ export default function AddCourse() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (target > 1 && roadmapUnits.length === 0) {
|
||||||
|
setCurrentStep(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCurrentStep(target);
|
setCurrentStep(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -774,7 +778,11 @@ export default function AddCourse() {
|
|||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
) : currentStep < STEPS.length - 1 ? (
|
) : currentStep < STEPS.length - 1 ? (
|
||||||
<Button type="button" onClick={() => setCurrentStep((s) => s + 1)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentStep((s) => s + 1)}
|
||||||
|
disabled={currentStep === 1 && roadmapUnits.length === 0}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export default function LessonPageBuilder() {
|
|||||||
>
|
>
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<div className="flex items-center gap-3 py-3">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(pageViewPath)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -158,7 +158,7 @@ export default function LessonPageBuilder() {
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(pageViewPath)} disabled={loading}>
|
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={loading}>
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ export default function ViewLessonPage() {
|
|||||||
const builderPath = unitId
|
const builderPath = unitId
|
||||||
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
||||||
: `/admin/lessons/${lessonId}/page`;
|
: `/admin/lessons/${lessonId}/page`;
|
||||||
const viewLessonPath = unitId
|
|
||||||
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`
|
|
||||||
: `/admin/lessons/${lessonId}/view`;
|
|
||||||
const { fetchLesson, lesson, lessonPage } = useCourses();
|
const { fetchLesson, lesson, lessonPage } = useCourses();
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
|
||||||
@@ -42,7 +39,7 @@ export default function ViewLessonPage() {
|
|||||||
>
|
>
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<div className="flex items-center gap-3 py-3">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(viewLessonPath)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { nanoid } from "nanoid";
|
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import {
|
|
||||||
ArrowLeft, ChevronLeft, ChevronRight, Check, X,
|
|
||||||
FileUp, FileText, Cog, ClipboardCheck, RotateCcw,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
|
||||||
|
|
||||||
// Only these two — same scope as the Document Import block (documentConversion.service.js on the backend).
|
|
||||||
const ALLOWED_EXTENSIONS = ["pdf", "pptx"];
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
title: z.string().min(1, "Title is required."),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const DEFAULT_VALUES = { title: "", description: "" };
|
|
||||||
|
|
||||||
const STEPS = [
|
|
||||||
{ id: 0, label: "Import", icon: FileUp },
|
|
||||||
{ id: 1, label: "Processing", icon: Cog },
|
|
||||||
{ id: 2, label: "Review", icon: ClipboardCheck },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEP_FIELDS = [["title", "description"], [], []];
|
|
||||||
|
|
||||||
const STAGES = [
|
|
||||||
{ phase: "compiling", label: "Compilation" },
|
|
||||||
{ phase: "validating", label: "Validation" },
|
|
||||||
{ phase: "generating", label: "Automation" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function FieldError({ message }) {
|
|
||||||
if (!message) return null;
|
|
||||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StageProgress({ phase }) {
|
|
||||||
const activeIndex = STAGES.findIndex((s) => s.phase === phase);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 py-2">
|
|
||||||
{STAGES.map((s, i) => {
|
|
||||||
const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending";
|
|
||||||
return (
|
|
||||||
<div key={s.phase} className="flex items-center gap-2 text-sm">
|
|
||||||
{state === "done" ? (
|
|
||||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
|
||||||
) : state === "active" ? (
|
|
||||||
<Spinner className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="h-3.5 w-3.5 rounded-full border shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className={cn(state === "pending" && "text-muted-foreground")}>{s.label}…</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 1 — Import ────────────────────────────────────────────────────────────
|
|
||||||
function StepImport({ register, errors, selectedAsset, onPick, fileMissing }) {
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
|
||||||
<Input id="title" placeholder="Lesson title" {...register("title")} />
|
|
||||||
<FieldError message={errors.title?.message} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea id="description" placeholder="Optional description" rows={3} {...register("description")} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Import File <span className="text-destructive">*</span></Label>
|
|
||||||
{selectedAsset ? (
|
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border p-3">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
||||||
<span className="text-sm truncate">{selectedAsset.display_name}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPickerOpen(true)}
|
|
||||||
className="text-xs text-primary hover:underline shrink-0"
|
|
||||||
>
|
|
||||||
Change
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPickerOpen(true)}
|
|
||||||
className="w-full py-8 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<FileUp className="h-8 w-8 text-muted-foreground/50" />
|
|
||||||
<p className="text-sm text-muted-foreground">Click to select a PDF or PPTX</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fileMissing && <FieldError message="Select a file to import before continuing." />}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AssetPickerSheet
|
|
||||||
open={pickerOpen}
|
|
||||||
onOpenChange={setPickerOpen}
|
|
||||||
fileType="document"
|
|
||||||
allowedExtensions={ALLOWED_EXTENSIONS}
|
|
||||||
onSelect={onPick}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 2 — Processing ────────────────────────────────────────────────────────
|
|
||||||
function StepProcessing({ selectedAsset, phase, result, convertError, onRetry }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-lg border p-4 space-y-1">
|
|
||||||
<p className="text-sm text-muted-foreground truncate">{selectedAsset?.display_name}</p>
|
|
||||||
{phase && <StageProgress phase={phase} />}
|
|
||||||
|
|
||||||
{!phase && result && (
|
|
||||||
<div className="space-y-2 pt-1">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-primary">
|
|
||||||
<Check className="h-3.5 w-3.5" />
|
|
||||||
Conversion complete
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{result.stats?.extractedLength ?? result.markdown.length} characters extracted.
|
|
||||||
</p>
|
|
||||||
{result.warnings?.length > 0 && (
|
|
||||||
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-0.5">
|
|
||||||
{result.warnings.map((w, i) => <li key={i}>{w}</li>)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!phase && convertError && (
|
|
||||||
<div className="space-y-2 pt-1">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
Conversion failed.
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onRetry} className="gap-1.5">
|
|
||||||
<RotateCcw className="h-3.5 w-3.5" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!phase && result && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Result</Label>
|
|
||||||
<div className="border rounded-md min-h-[120px] max-h-[280px] overflow-y-auto px-3 py-3">
|
|
||||||
<div className="typeset text-sm">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result.markdown}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Step 3 — Review ─────────────────────────────────────────────────────────────
|
|
||||||
function SummaryRow({ label, value }) {
|
|
||||||
if (!value) return null;
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between py-1.5 text-sm">
|
|
||||||
<span className="text-muted-foreground min-w-[140px]">{label}</span>
|
|
||||||
<span className="text-foreground text-right">{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepReview({ data, selectedAsset, result }) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="border border-border rounded-lg p-4 space-y-1">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm font-medium">Lesson</span>
|
|
||||||
</div>
|
|
||||||
<SummaryRow label="Title" value={data.title} />
|
|
||||||
<SummaryRow label="Description" value={data.description} />
|
|
||||||
<SummaryRow label="Source file" value={selectedAsset?.display_name} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label>Converted content</Label>
|
|
||||||
<div className="border rounded-md min-h-[180px] max-h-[360px] overflow-y-auto px-3 py-3">
|
|
||||||
<div className="typeset text-sm">
|
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{result?.markdown ?? ""}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────────
|
|
||||||
export default function ImportLibraryLesson() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { createLesson, saveLessonPage, loading } = useLibrary();
|
|
||||||
const { convertAssetToMarkdown } = useAssets();
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const [fileMissing, setFileMissing] = useState(false);
|
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
|
||||||
const [phase, setPhase] = useState(null);
|
|
||||||
const [result, setResult] = useState(null);
|
|
||||||
const [convertError, setConvertError] = useState(false);
|
|
||||||
|
|
||||||
// Guards the auto-fire effect below: stores the asset_id already converted
|
|
||||||
// (or in flight) for, so re-rendering / re-entering this step with the SAME
|
|
||||||
// file never re-triggers a second API call — the one concrete mechanism
|
|
||||||
// keeping this wizard from hammering the conversion endpoint.
|
|
||||||
const firedForAssetRef = useRef(null);
|
|
||||||
|
|
||||||
const {
|
|
||||||
register, trigger, getValues,
|
|
||||||
formState: { errors, isDirty },
|
|
||||||
} = useForm({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: DEFAULT_VALUES,
|
|
||||||
mode: "onTouched",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty || !!selectedAsset);
|
|
||||||
|
|
||||||
const runConversion = async (asset) => {
|
|
||||||
setConvertError(false);
|
|
||||||
setResult(null);
|
|
||||||
setPhase("compiling");
|
|
||||||
const res = await convertAssetToMarkdown(asset.asset_id, {
|
|
||||||
onProgress: (data) => {
|
|
||||||
if (data.phase && data.phase !== "done" && data.phase !== "error") setPhase(data.phase);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setPhase(null);
|
|
||||||
if (res) setResult(res);
|
|
||||||
else setConvertError(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-fire exactly once per (step === 1, selectedAsset) pair.
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 1 || !selectedAsset) return;
|
|
||||||
if (firedForAssetRef.current === selectedAsset.asset_id) return;
|
|
||||||
firedForAssetRef.current = selectedAsset.asset_id;
|
|
||||||
runConversion(selectedAsset);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [step, selectedAsset]);
|
|
||||||
|
|
||||||
const handlePick = (asset) => {
|
|
||||||
setSelectedAsset({ asset_id: asset.asset_id, display_name: asset.display_name, extension: asset.extension });
|
|
||||||
setFileMissing(false);
|
|
||||||
setResult(null);
|
|
||||||
setConvertError(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
|
||||||
if (step === 0) {
|
|
||||||
const valid = await trigger(STEP_FIELDS[0]);
|
|
||||||
if (!selectedAsset) { setFileMissing(true); return; }
|
|
||||||
if (!valid) return;
|
|
||||||
}
|
|
||||||
setStep((s) => Math.min(s + 1, STEPS.length - 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => {
|
|
||||||
if (step === 0) navigate("/admin/lessons");
|
|
||||||
else setStep((s) => s - 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
|
||||||
const valid = await trigger();
|
|
||||||
if (!valid || !result || !selectedAsset) return;
|
|
||||||
|
|
||||||
const data = getValues();
|
|
||||||
const created = await createLesson({
|
|
||||||
title: data.title,
|
|
||||||
description: data.description || null,
|
|
||||||
createdBy: user?.user_id,
|
|
||||||
});
|
|
||||||
if (!created) return;
|
|
||||||
|
|
||||||
const lessonId = created?.data?.data?.lesson_id;
|
|
||||||
if (!lessonId) return;
|
|
||||||
|
|
||||||
await saveLessonPage(lessonId, {
|
|
||||||
blocks: [{
|
|
||||||
id: nanoid(),
|
|
||||||
type: "document",
|
|
||||||
content: {
|
|
||||||
source_asset_id: selectedAsset.asset_id,
|
|
||||||
source_filename: selectedAsset.display_name,
|
|
||||||
source_ext: selectedAsset.extension,
|
|
||||||
body: result.markdown,
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
updatedBy: user?.user_id,
|
|
||||||
});
|
|
||||||
|
|
||||||
bypassOnce();
|
|
||||||
navigate("/admin/lessons");
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextDisabled = (step === 1 && (!!phase || !result)) || loading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-muted min-h-full">
|
|
||||||
<PageMeta title="Import Lesson - STARR" />
|
|
||||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
|
|
||||||
<div className="w-full max-w-3xl mx-auto space-y-6">
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/lessons")}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold">Import Lesson</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Convert a PDF or PPTX into a ready-to-edit Lesson — text only, no images/OCR.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stepper */}
|
|
||||||
<div className="flex items-center gap-0">
|
|
||||||
{STEPS.map((s, i) => {
|
|
||||||
const Icon = s.icon;
|
|
||||||
const isActive = step === i;
|
|
||||||
const isDone = step > i;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={s.id} className="flex items-center flex-1 last:flex-none">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<div className={cn(
|
|
||||||
"h-8 w-8 rounded-full flex items-center justify-center border transition-colors",
|
|
||||||
isDone && "bg-emerald-600 border-emerald-600 text-white",
|
|
||||||
isActive && "border-primary bg-primary text-primary-foreground",
|
|
||||||
!isActive && !isDone && "border-border bg-background text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{isDone ? <Check className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
|
|
||||||
</div>
|
|
||||||
<span className={cn(
|
|
||||||
"text-[11px] font-medium whitespace-nowrap hidden sm:block",
|
|
||||||
isActive ? "text-foreground" : "text-muted-foreground",
|
|
||||||
isDone ? "text-emerald-600" : ""
|
|
||||||
)}>
|
|
||||||
{s.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{i < STEPS.length - 1 && (
|
|
||||||
<div className={cn(
|
|
||||||
"flex-1 h-px mx-2 mb-4 transition-colors",
|
|
||||||
step > i ? "bg-emerald-600" : "bg-border"
|
|
||||||
)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step content */}
|
|
||||||
<div className="rounded-lg border bg-card p-6 min-h-[320px]">
|
|
||||||
<h2 className="text-base font-medium mb-4">{STEPS[step].label}</h2>
|
|
||||||
|
|
||||||
{step === 0 && (
|
|
||||||
<StepImport
|
|
||||||
register={register}
|
|
||||||
errors={errors}
|
|
||||||
selectedAsset={selectedAsset}
|
|
||||||
onPick={handlePick}
|
|
||||||
fileMissing={fileMissing}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{step === 1 && (
|
|
||||||
<StepProcessing
|
|
||||||
selectedAsset={selectedAsset}
|
|
||||||
phase={phase}
|
|
||||||
result={result}
|
|
||||||
convertError={convertError}
|
|
||||||
onRetry={() => runConversion(selectedAsset)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{step === 2 && (
|
|
||||||
<StepReview data={getValues()} selectedAsset={selectedAsset} result={result} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<Button type="button" variant="outline" onClick={handleBack} disabled={loading || !!phase}>
|
|
||||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
|
||||||
{step === 0 ? "Cancel" : "Back"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{step < STEPS.length - 1 ? (
|
|
||||||
<Button type="button" onClick={handleNext} disabled={nextDisabled}>
|
|
||||||
Next
|
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button type="button" onClick={handleCreate} disabled={loading}>
|
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Create Lesson
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -58,7 +58,6 @@ export default function ViewLibraryLesson() {
|
|||||||
<div className="bg-card rounded-xl border p-6 space-y-4">
|
<div className="bg-card rounded-xl border p-6 space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h6 className="text-xs tracking-widest mb-1">STANDALONE LESSON</h6>
|
|
||||||
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
|
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
|
||||||
{lesson?.description && (
|
{lesson?.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{lesson.description}</p>
|
<p className="text-sm text-muted-foreground mt-0.5">{lesson.description}</p>
|
||||||
@@ -68,7 +67,7 @@ export default function ViewLibraryLesson() {
|
|||||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
|
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
|
||||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => navigate(`/admin/lessons/${lessonId}/page`)}>
|
<Button size="sm" onClick={() => navigate(`/admin/lessons/${lessonId}/page/view`)}>
|
||||||
<LayoutTemplate className="h-3.5 w-3.5 mr-1.5" /> Page Builder
|
<LayoutTemplate className="h-3.5 w-3.5 mr-1.5" /> Page Builder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function ViewLibraryUnit() {
|
|||||||
|
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
const [attachOpen, setAttachOpen] = useState(false);
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
const [descExpanded, setDescExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -82,11 +83,21 @@ export default function ViewLibraryUnit() {
|
|||||||
{/* ── Unit header ── */}
|
{/* ── Unit header ── */}
|
||||||
<div className="bg-card rounded-xl border p-6 space-y-4">
|
<div className="bg-card rounded-xl border p-6 space-y-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div className="max-w-2xl">
|
||||||
<h6 className="text-xs tracking-widest mb-1">STANDALONE UNIT</h6>
|
|
||||||
<h1 className="text-xl font-semibold">{unit?.title}</h1>
|
<h1 className="text-xl font-semibold">{unit?.title}</h1>
|
||||||
{unit?.description && (
|
{unit?.description && (
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{unit.description}</p>
|
<div className="mt-0.5">
|
||||||
|
<p className={`text-sm text-muted-foreground ${descExpanded ? '' : 'line-clamp-2'}`}>
|
||||||
|
{unit.description}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDescExpanded((v) => !v)}
|
||||||
|
className="text-xs font-medium text-primary hover:underline mt-0.5"
|
||||||
|
>
|
||||||
|
{descExpanded ? 'See less' : 'See more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<div className="flex gap-2 shrink-0">
|
||||||
@@ -154,15 +165,15 @@ export default function ViewLibraryUnit() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold">Lessons</h2>
|
<h2 className="font-semibold">Lessons</h2>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lessons are standalone too — attach existing ones or create new. Order here is this unit's order.
|
It can be standalone too attach existing ones or create new.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={() => setAttachOpen(true)}>
|
<Button size="sm" variant="outline" onClick={() => setAttachOpen(true)}>
|
||||||
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach Existing
|
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Select
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => navigate(`/admin/lessons/add?unit_id=${unitId}`)}>
|
<Button size="sm" onClick={() => navigate(`/admin/lessons/add?unit_id=${unitId}`)}>
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" /> New Lesson
|
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,27 +212,27 @@ export default function ViewLibraryUnit() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{l.title}</p>
|
<p className="text-sm font-medium truncate">{l.title}</p>
|
||||||
{l.description && (
|
{l.description && (
|
||||||
<p className="text-xs text-muted-foreground truncate">{l.description}</p>
|
<p className="text-xs w-64 text-muted-foreground truncate">{l.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="secondary" className="text-xs shrink-0 tabular-nums">
|
<Badge variant="secondary" className="text-xs shrink-0 tabular-nums">
|
||||||
<Clock className="h-3 w-3 mr-1" /> {formatDuration(l.duration_seconds ?? 0)}
|
<Clock className="h-3 w-3 mr-1" /> {formatDuration(l.duration_seconds ?? 0)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm" variant="outline"
|
||||||
onClick={() => navigate(`/admin/lessons/${l.lesson_id}/page`)}
|
onClick={() => navigate(`/admin/lessons/${l.lesson_id}/page`)}
|
||||||
title="Page Builder"
|
|
||||||
>
|
>
|
||||||
<LayoutTemplate className="h-3.5 w-3.5" />
|
<LayoutTemplate /> Page Builder
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm"
|
||||||
className="text-destructive hover:text-destructive"
|
variant="destructive"
|
||||||
onClick={() => handleDetach(l.lesson_id)}
|
onClick={() => handleDetach(l.lesson_id)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
title="Detach from unit (lesson stays in library)"
|
title="Detach from unit (lesson stays in library)"
|
||||||
>
|
>
|
||||||
<Unlink className="h-3.5 w-3.5" />
|
<Unlink className="h-3.5 w-3.5" /> Detach
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { useForm } from "react-hook-form";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
|
import { House, Check, ArrowLeft, ArrowRight, ImagePlus, X } from "lucide-react";
|
||||||
|
|
||||||
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
||||||
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
|
import AlertLayoutPreview from "../../components/notifications/AlertLayoutPreview";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -26,6 +28,7 @@ import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadca
|
|||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// Internal paths ("/course/123") pass through untouched — everything else
|
// Internal paths ("/course/123") pass through untouched — everything else
|
||||||
// gets a scheme so the saved URL always matches what goToLink() will open,
|
// gets a scheme so the saved URL always matches what goToLink() will open,
|
||||||
@@ -46,6 +49,7 @@ const schema = z.object({
|
|||||||
link_url: z.string().trim().optional(),
|
link_url: z.string().trim().optional(),
|
||||||
link_label: z.string().trim().optional(),
|
link_label: z.string().trim().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
|
image_asset_id: z.string().nullable().optional(),
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
@@ -112,6 +116,50 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
|
||||||
|
if (!selectedAsset) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPick}
|
||||||
|
className="w-full sm:w-48 aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<ImagePlus className="size-4" />
|
||||||
|
<span className="text-xs">Select an image</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = resolveAssetSrc(selectedAsset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full sm:w-48 rounded-lg overflow-hidden border aspect-video group">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={selectedAsset.display_name}
|
||||||
|
className="w-full h-full object-cover cursor-pointer"
|
||||||
|
onClick={onPick}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={onPick}
|
||||||
|
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1 right-1 size-6"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StepIndicator({ steps, current, onStepClick }) {
|
function StepIndicator({ steps, current, onStepClick }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start w-full mb-8">
|
<div className="flex items-start w-full mb-8">
|
||||||
@@ -171,6 +219,8 @@ export default function AddNotificationBroadcast() {
|
|||||||
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [targetLabel, setTargetLabel] = useState(null);
|
const [targetLabel, setTargetLabel] = useState(null);
|
||||||
|
const [imageAsset, setImageAsset] = useState(null);
|
||||||
|
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -192,6 +242,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
link_url: "",
|
link_url: "",
|
||||||
link_label: "",
|
link_label: "",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
|
image_asset_id: null,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
},
|
},
|
||||||
@@ -211,7 +262,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Announcements", to: "/admin/announcements" },
|
{ label: "Alerts", to: "/admin/announcements" },
|
||||||
{ label: "New" },
|
{ label: "New" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -243,6 +294,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
||||||
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
||||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
||||||
|
image_asset_id: values.show_in_sticky ? (values.image_asset_id || null) : null,
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_date || null,
|
end_date: values.end_date || null,
|
||||||
createdBy: user?.user_id ?? null,
|
createdBy: user?.user_id ?? null,
|
||||||
@@ -269,8 +321,8 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">New Alert</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
|
<p className="text-sm text-muted-foreground mb-6">Compose an alert. It's saved as a draft until you send it.</p>
|
||||||
|
|
||||||
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
<StepIndicator steps={STEPS} current={currentStep} onStepClick={goToStep} />
|
||||||
|
|
||||||
@@ -286,7 +338,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Message</Label>
|
<Label className="mb-1.5 block">Message</Label>
|
||||||
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
|
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
|
||||||
<FieldError message={errors.message?.message} />
|
<FieldError message={errors.message?.message} />
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -294,7 +346,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
|
|
||||||
{/* ── Step 1: Target ── */}
|
{/* ── Step 1: Target ── */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
<SectionCard title="Target" description="Who receives this alert when it's sent.">
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
value={targetType}
|
value={targetType}
|
||||||
@@ -338,7 +390,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
{/* ── Step 2: Display ── */}
|
{/* ── Step 2: Display ── */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<>
|
<>
|
||||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
<SectionCard title="Display" description="Where clients/admins can see this alert.">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -347,14 +399,14 @@ export default function AddNotificationBroadcast() {
|
|||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
const checked = v === true;
|
const checked = v === true;
|
||||||
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
// Sticky-only announcements vanish forever once dismissed (seen=true drops
|
// Sticky-only alerts vanish forever once dismissed (seen=true drops
|
||||||
// them from the sticky query, show_in_notifications=false hides them from
|
// them from the sticky query, show_in_notifications=false hides them from
|
||||||
// the list too) — force the list entry so it stays reachable afterward.
|
// the list too) — force the list entry so it stays reachable afterward.
|
||||||
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
Show in Sticky Announcements
|
Show in Sticky Alerts
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -434,7 +486,20 @@ export default function AddNotificationBroadcast() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
|
<SectionCard title="Layout image" description="Shown alongside the message when this alert is opened from the sticky banner. Optional.">
|
||||||
|
<AlertImagePicker
|
||||||
|
selectedAsset={imageAsset}
|
||||||
|
onPick={() => setImagePickerOpen(true)}
|
||||||
|
onRemove={() => {
|
||||||
|
setImageAsset(null);
|
||||||
|
setValue("image_asset_id", null, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<SectionCard title="On Open" description="What happens when someone opens this alert from the sticky banner.">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -479,6 +544,17 @@ export default function AddNotificationBroadcast() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<SectionCard title="Preview" description="What this alert looks like when opened from the sticky banner.">
|
||||||
|
<AlertLayoutPreview
|
||||||
|
title={watch("title")}
|
||||||
|
message={watch("message")}
|
||||||
|
imageAsset={imageAsset}
|
||||||
|
linkLabel={linkMode === "link" ? (watch("link_label") || "Open Link") : null}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -512,7 +588,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
<SectionCard title="Display">
|
<SectionCard title="Display">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Sticky Announcements</p>
|
<p className="text-xs text-muted-foreground">Sticky Alerts</p>
|
||||||
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
|
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -600,6 +676,16 @@ export default function AddNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
{unsavedChangesDialog}
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={imagePickerOpen}
|
||||||
|
onOpenChange={setImagePickerOpen}
|
||||||
|
fileType="image"
|
||||||
|
onSelect={(asset) => {
|
||||||
|
setImageAsset(asset);
|
||||||
|
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ArchivedNotificationBroadcastsTable from "../../components/notifications/
|
|||||||
export default function ArchivedNotificationBroadcastList() {
|
export default function ArchivedNotificationBroadcastList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Announcements", to: `/admin/announcements` },
|
{ label: "Alerts", to: `/admin/announcements` },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { useForm } from "react-hook-form";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { House, Check, ArrowLeft, ArrowRight } from "lucide-react";
|
import { House, Check, ArrowLeft, ArrowRight, ImagePlus, X } from "lucide-react";
|
||||||
|
|
||||||
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
import { BroadcastTargetPicker } from "@/components/generic/BroadcastTargetPicker";
|
||||||
|
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||||
|
import AlertLayoutPreview from "../../components/notifications/AlertLayoutPreview";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -21,11 +23,16 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
import { DateTimePicker } from "@/components/ui/date-time-picker";
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
||||||
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
|
||||||
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
|
|
||||||
// Internal paths ("/course/123") pass through untouched — everything else
|
// Internal paths ("/course/123") pass through untouched — everything else
|
||||||
// gets a scheme so the saved URL always matches what goToLink() will open,
|
// gets a scheme so the saved URL always matches what goToLink() will open,
|
||||||
@@ -46,6 +53,7 @@ const schema = z.object({
|
|||||||
link_url: z.string().trim().optional(),
|
link_url: z.string().trim().optional(),
|
||||||
link_label: z.string().trim().optional(),
|
link_label: z.string().trim().optional(),
|
||||||
color: z.string().optional(),
|
color: z.string().optional(),
|
||||||
|
image_asset_id: z.string().nullable().optional(),
|
||||||
start_date: z.string().optional(),
|
start_date: z.string().optional(),
|
||||||
end_date: z.string().optional(),
|
end_date: z.string().optional(),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
@@ -112,6 +120,50 @@ function SectionCard({ title, description, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AlertImagePicker({ selectedAsset, onPick, onRemove }) {
|
||||||
|
if (!selectedAsset) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onPick}
|
||||||
|
className="w-full sm:w-48 aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<ImagePlus className="size-4" />
|
||||||
|
<span className="text-xs">Select an image</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageUrl = resolveAssetSrc(selectedAsset);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full sm:w-48 rounded-lg overflow-hidden border aspect-video group">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={selectedAsset.display_name}
|
||||||
|
className="w-full h-full object-cover cursor-pointer"
|
||||||
|
onClick={onPick}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onClick={onPick}
|
||||||
|
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1 right-1 size-6"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StepIndicator({ steps, current, onStepClick }) {
|
function StepIndicator({ steps, current, onStepClick }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start w-full mb-8">
|
<div className="flex items-start w-full mb-8">
|
||||||
@@ -173,6 +225,8 @@ export default function EditNotificationBroadcast() {
|
|||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [targetLabel, setTargetLabel] = useState(null);
|
const [targetLabel, setTargetLabel] = useState(null);
|
||||||
const [broadcastStatus, setBroadcastStatus] = useState("draft");
|
const [broadcastStatus, setBroadcastStatus] = useState("draft");
|
||||||
|
const [imageAsset, setImageAsset] = useState(null);
|
||||||
|
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -195,6 +249,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
link_url: "",
|
link_url: "",
|
||||||
link_label: "",
|
link_label: "",
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
|
image_asset_id: null,
|
||||||
start_date: "",
|
start_date: "",
|
||||||
end_date: "",
|
end_date: "",
|
||||||
},
|
},
|
||||||
@@ -214,7 +269,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Announcements", to: "/admin/announcements" },
|
{ label: "Alerts", to: "/admin/announcements" },
|
||||||
{ label: "Edit" },
|
{ label: "Edit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -239,9 +294,11 @@ export default function EditNotificationBroadcast() {
|
|||||||
link_url: b.link_url ?? "",
|
link_url: b.link_url ?? "",
|
||||||
link_label: b.link_label ?? "",
|
link_label: b.link_label ?? "",
|
||||||
color: b.color ?? "indigo",
|
color: b.color ?? "indigo",
|
||||||
|
image_asset_id: b.image_asset_id ? String(b.image_asset_id) : null,
|
||||||
start_date: b.start_date ?? "",
|
start_date: b.start_date ?? "",
|
||||||
end_date: b.end_date ?? "",
|
end_date: b.end_date ?? "",
|
||||||
});
|
});
|
||||||
|
setImageAsset(b.image ?? null);
|
||||||
setBroadcastStatus(b.status ?? "draft");
|
setBroadcastStatus(b.status ?? "draft");
|
||||||
setCurrentStep(0);
|
setCurrentStep(0);
|
||||||
})();
|
})();
|
||||||
@@ -276,6 +333,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
|
||||||
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
||||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
||||||
|
image_asset_id: values.show_in_sticky ? (values.image_asset_id || null) : null,
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
end_date: values.end_date || null,
|
end_date: values.end_date || null,
|
||||||
updatedBy: user?.user_id ?? null,
|
updatedBy: user?.user_id ?? null,
|
||||||
@@ -301,10 +359,10 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full max-w-2xl pb-10">
|
<div className="w-full max-w-2xl pb-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit announcement</h1>
|
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit Alert</h1>
|
||||||
<p className="text-sm text-muted-foreground mb-6">
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
{broadcastStatus === "sent"
|
{broadcastStatus === "sent"
|
||||||
? "This announcement has already been sent — changes apply immediately to anyone currently seeing it."
|
? "This alert has already been sent — changes apply immediately to anyone currently seeing it."
|
||||||
: "It's saved as a draft until you send it."}
|
: "It's saved as a draft until you send it."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -322,7 +380,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block">Message</Label>
|
<Label className="mb-1.5 block">Message</Label>
|
||||||
<Textarea rows={4} placeholder="Full announcement text" {...register("message")} />
|
<Textarea rows={4} placeholder="Full alert text" {...register("message")} />
|
||||||
<FieldError message={errors.message?.message} />
|
<FieldError message={errors.message?.message} />
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -330,7 +388,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
|
|
||||||
{/* ── Step 1: Target ── */}
|
{/* ── Step 1: Target ── */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<SectionCard title="Target" description="Who receives this announcement when it's sent.">
|
<SectionCard title="Target" description="Who receives this alert when it's sent.">
|
||||||
<div>
|
<div>
|
||||||
<Select
|
<Select
|
||||||
value={targetType}
|
value={targetType}
|
||||||
@@ -374,7 +432,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
{/* ── Step 2: Display ── */}
|
{/* ── Step 2: Display ── */}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<>
|
<>
|
||||||
<SectionCard title="Display" description="Where clients/admins can see this announcement.">
|
<SectionCard title="Display" description="Where clients/admins can see this alert.">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -383,14 +441,14 @@ export default function EditNotificationBroadcast() {
|
|||||||
onCheckedChange={(v) => {
|
onCheckedChange={(v) => {
|
||||||
const checked = v === true;
|
const checked = v === true;
|
||||||
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
setValue("show_in_sticky", checked, { shouldValidate: true, shouldDirty: true });
|
||||||
// Sticky-only announcements vanish forever once dismissed (seen=true drops
|
// Sticky-only alerts vanish forever once dismissed (seen=true drops
|
||||||
// them from the sticky query, show_in_notifications=false hides them from
|
// them from the sticky query, show_in_notifications=false hides them from
|
||||||
// the list too) — force the list entry so it stays reachable afterward.
|
// the list too) — force the list entry so it stays reachable afterward.
|
||||||
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
<Label htmlFor="show_in_sticky" className="cursor-pointer">
|
||||||
Show in Sticky Announcements
|
Show in Sticky Alerts
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -470,7 +528,20 @@ export default function EditNotificationBroadcast() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{showInSticky && (
|
{showInSticky && (
|
||||||
<SectionCard title="On Open" description="What happens when someone opens this announcement from the sticky banner.">
|
<SectionCard title="Layout image" description="Shown alongside the message when this alert is opened from the sticky banner. Optional.">
|
||||||
|
<AlertImagePicker
|
||||||
|
selectedAsset={imageAsset}
|
||||||
|
onPick={() => setImagePickerOpen(true)}
|
||||||
|
onRemove={() => {
|
||||||
|
setImageAsset(null);
|
||||||
|
setValue("image_asset_id", null, { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<SectionCard title="On Open" description="What happens when someone opens this alert from the sticky banner.">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -515,6 +586,17 @@ export default function EditNotificationBroadcast() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showInSticky && (
|
||||||
|
<SectionCard title="Preview" description="What this alert looks like when opened from the sticky banner.">
|
||||||
|
<AlertLayoutPreview
|
||||||
|
title={watch("title")}
|
||||||
|
message={watch("message")}
|
||||||
|
imageAsset={imageAsset}
|
||||||
|
linkLabel={linkMode === "link" ? (watch("link_label") || "Open Link") : null}
|
||||||
|
/>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -548,7 +630,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
<SectionCard title="Display">
|
<SectionCard title="Display">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Sticky Announcements</p>
|
<p className="text-xs text-muted-foreground">Sticky Alerts</p>
|
||||||
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
|
<p className="font-medium">{showInSticky ? "Shown" : "Hidden"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -610,14 +692,28 @@ export default function EditNotificationBroadcast() {
|
|||||||
<ArrowRight className="h-4 w-4 ml-2" />
|
<ArrowRight className="h-4 w-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
) : broadcastStatus === "sent" ? (
|
) : broadcastStatus === "sent" ? (
|
||||||
<Button
|
<AlertDialog>
|
||||||
type="button"
|
<AlertDialogTrigger asChild>
|
||||||
disabled={loading}
|
<Button type="button" disabled={loading}>
|
||||||
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
>
|
Save changes
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
</Button>
|
||||||
Save changes
|
</AlertDialogTrigger>
|
||||||
</Button>
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Save changes to this alert?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This alert has already been sent — saving will update it immediately for anyone currently seeing it.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}>
|
||||||
|
Save changes
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -645,6 +741,16 @@ export default function EditNotificationBroadcast() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{unsavedChangesDialog}
|
{unsavedChangesDialog}
|
||||||
|
|
||||||
|
<AssetPickerSheet
|
||||||
|
open={imagePickerOpen}
|
||||||
|
onOpenChange={setImagePickerOpen}
|
||||||
|
fileType="image"
|
||||||
|
onSelect={(asset) => {
|
||||||
|
setImageAsset(asset);
|
||||||
|
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Archive, ImagePlus, X } from "lucide-react";
|
import { House, Plus, Search, Megaphone, Send, Edit, Trash2, Users, Archive } from "lucide-react";
|
||||||
|
|
||||||
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
import { useNotificationBroadcasts } from "@/contexts/AdminNotificationBroadcastContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -20,29 +18,17 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
|
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
|
||||||
|
|
||||||
export default function NotificationBroadcastList() {
|
export default function NotificationBroadcastList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
|
||||||
const {
|
const {
|
||||||
broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast,
|
broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast,
|
||||||
stickyBannerSetting, fetchStickyBannerSetting, updateStickyBannerSetting,
|
|
||||||
} = useNotificationBroadcasts();
|
} = useNotificationBroadcasts();
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [limit, setLimit] = useState(12);
|
const [limit, setLimit] = useState(10);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
|
||||||
|
|
||||||
const bannerAsset = stickyBannerSetting?.image ?? null;
|
|
||||||
const bannerImageUrl = bannerAsset ? resolveAssetSrc(bannerAsset) : null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchStickyBannerSetting();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
function buildFilters() {
|
function buildFilters() {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
@@ -58,7 +44,7 @@ export default function NotificationBroadcastList() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Announcements" },
|
{ label: "Alerts" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const total = pagination?.totalRecords ?? broadcasts.length;
|
const total = pagination?.totalRecords ?? broadcasts.length;
|
||||||
@@ -85,8 +71,8 @@ export default function NotificationBroadcastList() {
|
|||||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Alerts</h1>
|
||||||
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
|
<p className="text-sm text-muted-foreground">Compose and send alerts to admins and users</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
|
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
|
||||||
@@ -95,7 +81,7 @@ export default function NotificationBroadcastList() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => navigate("/admin/announcements/add")}>
|
<Button onClick={() => navigate("/admin/announcements/add")}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New announcement
|
New Alert
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,24 +93,6 @@ export default function NotificationBroadcastList() {
|
|||||||
<StatCard label="Sent" value={sentCount} tone="success" />
|
<StatCard label="Sent" value={sentCount} tone="success" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Sticky banner image (shared across all active announcements) ── */}
|
|
||||||
<div className="bg-background rounded-lg border p-4 flex items-center gap-4">
|
|
||||||
<div className="w-40 shrink-0">
|
|
||||||
<StickyBannerPicker
|
|
||||||
selectedAsset={bannerAsset}
|
|
||||||
imageUrl={bannerImageUrl}
|
|
||||||
onPick={() => setPickerOpen(true)}
|
|
||||||
onRemove={() => updateStickyBannerSetting({ image_asset_id: null, updatedBy: user?.user_id ?? null })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Sticky banner image</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Shown in the details dialog for every currently-active sticky announcement (up to 3 share this one image).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
@@ -143,7 +111,7 @@ export default function NotificationBroadcastList() {
|
|||||||
<div className="relative w-64">
|
<div className="relative w-64">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search announcements..."
|
placeholder="Search alerts..."
|
||||||
className="pl-8 bg-background text-sm"
|
className="pl-8 bg-background text-sm"
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
@@ -183,8 +151,7 @@ export default function NotificationBroadcastList() {
|
|||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
rowCount={broadcasts.length}
|
rowCount={broadcasts.length}
|
||||||
totalRecords={pagination?.totalRecords}
|
totalRecords={pagination?.totalRecords}
|
||||||
recordLabel="notification"
|
recordLabel="alert"
|
||||||
pageSizeOptions={[12, 24, 48, 96]}
|
|
||||||
onPageChange={(page) => fetchBroadcasts({ page, limit, filters: buildFilters() })}
|
onPageChange={(page) => fetchBroadcasts({ page, limit, filters: buildFilters() })}
|
||||||
onPageSizeChange={(size) => setLimit(size)}
|
onPageSizeChange={(size) => setLimit(size)}
|
||||||
/>
|
/>
|
||||||
@@ -193,65 +160,12 @@ export default function NotificationBroadcastList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AssetPickerSheet
|
|
||||||
open={pickerOpen}
|
|
||||||
onOpenChange={setPickerOpen}
|
|
||||||
fileType="image"
|
|
||||||
onSelect={(asset) => {
|
|
||||||
updateStickyBannerSetting({ image_asset_id: asset.asset_id, updatedBy: user?.user_id ?? null });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Stat card ──────────────────────────────────────────────────────────────
|
// ─── Stat card ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Sticky banner image picker ────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StickyBannerPicker({ selectedAsset, imageUrl, onPick, onRemove }) {
|
|
||||||
if (!selectedAsset) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onPick}
|
|
||||||
className="w-full aspect-video rounded-lg border border-dashed flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<ImagePlus className="size-4" />
|
|
||||||
<span className="text-xs">Select an image</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative rounded-lg overflow-hidden border aspect-video group">
|
|
||||||
<img
|
|
||||||
src={imageUrl || resolveAssetSrc(selectedAsset)}
|
|
||||||
alt={selectedAsset.display_name}
|
|
||||||
className="w-full h-full object-cover cursor-pointer"
|
|
||||||
onClick={onPick}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
onClick={onPick}
|
|
||||||
className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center cursor-pointer"
|
|
||||||
>
|
|
||||||
<span className="text-white text-xs opacity-0 group-hover:opacity-100">Change image</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
size="icon"
|
|
||||||
className="absolute top-1 right-1 size-6"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
|
||||||
aria-label="Remove image"
|
|
||||||
>
|
|
||||||
<X className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value, tone = "default" }) {
|
function StatCard({ label, value, tone = "default" }) {
|
||||||
const toneClass = {
|
const toneClass = {
|
||||||
default: "text-foreground",
|
default: "text-foreground",
|
||||||
@@ -290,7 +204,7 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
|
|||||||
{targetText}
|
{targetText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{broadcast.title || "Untitled notification"}</p>
|
<p className="text-sm font-medium leading-snug truncate hover:underline">{broadcast.title || "Untitled alert"}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{broadcast.message}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{broadcast.message}</p>
|
||||||
{broadcast.sent_at && (
|
{broadcast.sent_at && (
|
||||||
<p className="text-xs text-muted-foreground mt-1.5">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
@@ -308,10 +222,10 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
|
<AlertDialogTitle>Send this alert?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
"{broadcast.title || "This notification"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
"{broadcast.title || "This alert"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -332,9 +246,9 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
|
|||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Archive this notification?</AlertDialogTitle>
|
<AlertDialogTitle>Archive this alert?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
"{broadcast.title || "This notification"}" will be moved to archived notifications. You can restore it later.
|
"{broadcast.title || "This alert"}" will be moved to archived alerts. You can restore it later.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
@@ -356,12 +270,12 @@ function EmptyState({ onCreate }) {
|
|||||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
||||||
<Megaphone className="size-8 text-muted-foreground" />
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">No announcements yet</p>
|
<p className="font-medium">No alerts yet</p>
|
||||||
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
|
<p className="text-sm text-muted-foreground">Compose your first alert to admins or users.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={onCreate}>
|
<Button onClick={onCreate}>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
New announcement
|
New Alert
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function ViewNotificationBroadcast() {
|
|||||||
if (!broadcast) {
|
if (!broadcast) {
|
||||||
return (
|
return (
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4 py-6">
|
||||||
<p className="text-sm text-muted-foreground">Announcement not found.</p>
|
<p className="text-sm text-muted-foreground">Alert not found.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,7 @@ export default function ViewNotificationBroadcast() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
|
||||||
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
<Megaphone className="h-5 w-5 text-muted-foreground" />
|
||||||
{broadcast.title || "Untitled announcement"}
|
{broadcast.title || "Untitled alert"}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
|
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
|
||||||
@@ -180,9 +180,9 @@ export default function ViewNotificationBroadcast() {
|
|||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Send this announcement?</AlertDialogTitle>
|
<AlertDialogTitle>Send this alert?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
"{broadcast.title || "This announcement"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
"{broadcast.title || "This alert"}" will be delivered to {targetText?.toLowerCase() ?? broadcast.target_type} immediately. This cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export default function CreateTaskList() {
|
|||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="name">Name *</Label>
|
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
@@ -210,21 +210,22 @@ export default function CreateTaskList() {
|
|||||||
{/* ── Step 2: Assign Groups ── */}
|
{/* ── Step 2: Assign Groups ── */}
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>
|
<Label>Assign to Groups <span className="text-destructive">*</span></Label>
|
||||||
Assign to Groups
|
|
||||||
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
|
|
||||||
(optional)
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
<GroupMultiSelect
|
<GroupMultiSelect
|
||||||
value={selectedGroupIds}
|
value={selectedGroupIds}
|
||||||
onChange={setSelectedGroupIds}
|
onChange={setSelectedGroupIds}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
placeholder="Select groups to assign…"
|
placeholder="Select groups to assign…"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
{selectedGroupIds.length === 0 ? (
|
||||||
Members of selected groups will be able to see and complete this task list.
|
<p className="text-xs text-destructive">
|
||||||
</p>
|
Select at least one group to continue.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Members of selected groups will be able to see and complete this task list.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -309,7 +310,11 @@ export default function CreateTaskList() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{step < STEPS.length - 1 ? (
|
{step < STEPS.length - 1 ? (
|
||||||
<Button type="button" onClick={handleNext}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={step === 1 && selectedGroupIds.length === 0}
|
||||||
|
>
|
||||||
Next
|
Next
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function EditTaskList() {
|
|||||||
|
|
||||||
{/* Name */}
|
{/* Name */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="name">Name *</Label>
|
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function TaskQueueStep({ tasks, onChange, courses, units, lessons
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="queuedTaskName">Name *</Label>
|
<Label htmlFor="queuedTaskName">Name <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="queuedTaskName"
|
id="queuedTaskName"
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export default function CreateTask() {
|
|||||||
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="name">Name *</Label>
|
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export default function EditTask() {
|
|||||||
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="name">Name *</Label>
|
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
|
|||||||
@@ -19,14 +19,18 @@ import api from '@/utils/api.util';
|
|||||||
const REQUIREMENT_TYPES = [
|
const REQUIREMENT_TYPES = [
|
||||||
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
|
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
|
||||||
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
|
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
|
||||||
{ value: 'submit_text', label: 'Submit a Response', icon: PenLine, category: 'Action' },
|
|
||||||
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
|
||||||
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
|
||||||
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
|
||||||
{ value: 'pass_quiz', label: 'Pass a Quiz', icon: ClipboardCheck, category: 'Content' },
|
{ value: 'pass_quiz', label: 'Pass a Quiz', icon: ClipboardCheck, category: 'Content' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
|
// No longer a choosable option (removed from Add Task) — kept here only so
|
||||||
|
// TYPE_MAP still resolves the icon/label for requirements created before the
|
||||||
|
// removal.
|
||||||
|
const LEGACY_REQUIREMENT_TYPE = { value: 'submit_text', label: 'Submit a Response', icon: PenLine, category: 'Action' };
|
||||||
|
|
||||||
|
const TYPE_MAP = Object.fromEntries([...REQUIREMENT_TYPES, LEGACY_REQUIREMENT_TYPE].map((t) => [t.value, t]));
|
||||||
|
|
||||||
const FILE_TYPE_OPTIONS = [
|
const FILE_TYPE_OPTIONS = [
|
||||||
{ value: 'pdf', label: 'PDF' },
|
{ value: 'pdf', label: 'PDF' },
|
||||||
@@ -304,6 +308,14 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{item.type === 'submit_text' && (
|
||||||
|
<SelectItem value="submit_text">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<PenLine className="h-3.5 w-3.5" />
|
||||||
|
Submit a Response
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -322,7 +334,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
|||||||
{item.type === 'visit_link' && (
|
{item.type === 'visit_link' && (
|
||||||
<div className="grid grid-cols-2 gap-3 pl-7">
|
<div className="grid grid-cols-2 gap-3 pl-7">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">URL *</Label>
|
<Label className="text-xs">URL <span className="text-destructive">*</span></Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
value={item.link_url}
|
value={item.link_url}
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ import ViewLibraryUnit from '../pages/library/units/ViewLibraryUnit'
|
|||||||
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
|
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
|
||||||
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
|
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
|
||||||
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
|
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
|
||||||
import ImportLibraryLesson from '../pages/library/lessons/ImportLibraryLesson'
|
|
||||||
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
|
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
|
||||||
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
|
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
|
||||||
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
|
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
|
||||||
@@ -92,6 +91,7 @@ import ViewAudioAsset from '../pages/assets/ViewAudioAsset'
|
|||||||
|
|
||||||
// Categories
|
// Categories
|
||||||
import CategoryList from '../pages/categories/CategoryList';
|
import CategoryList from '../pages/categories/CategoryList';
|
||||||
|
import ArchivedCategoryList from '../pages/categories/ArchivedCategoryList';
|
||||||
import AddCategory from '../pages/categories/AddCategory';
|
import AddCategory from '../pages/categories/AddCategory';
|
||||||
import EditCategory from '../pages/categories/EditCategory';
|
import EditCategory from '../pages/categories/EditCategory';
|
||||||
|
|
||||||
@@ -203,6 +203,7 @@ export const AdminRoutes = {
|
|||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <CategoryList /> },
|
{ index: true, element: <CategoryList /> },
|
||||||
|
{ path: 'archived', element: <ArchivedCategoryList /> },
|
||||||
{ path: 'add', element: <AddCategory /> },
|
{ path: 'add', element: <AddCategory /> },
|
||||||
{ path: ':id/edit', element: <EditCategory /> },
|
{ path: ':id/edit', element: <EditCategory /> },
|
||||||
]
|
]
|
||||||
@@ -264,7 +265,6 @@ export const AdminRoutes = {
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <LessonLibraryList /> },
|
{ index: true, element: <LessonLibraryList /> },
|
||||||
{ path: 'add', element: <AddLibraryLesson /> },
|
{ path: 'add', element: <AddLibraryLesson /> },
|
||||||
{ path: 'import', element: <ImportLibraryLesson /> },
|
|
||||||
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
|
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
|
||||||
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
|
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
|
||||||
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
|
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
|
||||||
@@ -373,6 +373,7 @@ export const AdminRoutes = {
|
|||||||
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// Backwards-compatible aliases (keep old URLs working)
|
// Backwards-compatible aliases (keep old URLs working)
|
||||||
{
|
{
|
||||||
path: 'notifications',
|
path: 'notifications',
|
||||||
|
|||||||
Reference in New Issue
Block a user