updated some of things

This commit is contained in:
rgrgogu
2026-08-03 12:03:11 +08:00
parent 1315796412
commit 6be0c29850
75 changed files with 1743 additions and 1702 deletions
+1 -7
View File
@@ -1,6 +1,6 @@
// 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 {
DropdownMenu,
@@ -60,12 +60,6 @@ const BLOCK_TYPES = [
description: "Rich text written in Markdown",
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 }) {
@@ -1,13 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useState } from "react";
import { X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import { goToLink } from "@/components/generic/notificationDisplay";
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
const ROTATE_INTERVAL_MS = 6000;
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
// explicit link_url from the "On Open" section — the type-based fallback
@@ -24,47 +22,23 @@ function resolveClickAction(stickyAnnouncement) {
export default function AdminStickyAnnouncementBar() {
const navigate = useNavigate();
const { stickyAnnouncements, bannerImage, markSeen } = useAdminNotifications();
const [activeIndex, setActiveIndex] = useState(0);
const { stickyAnnouncements, markSeen } = useAdminNotifications();
const [detailsOpen, setDetailsOpen] = useState(false);
const count = stickyAnnouncements.length;
// Derived rather than clamped via effect — safe the instant the array
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
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];
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
// it (X on the bar) reveals whichever is next in the queue.
const current = stickyAnnouncements[0];
const onDismiss = useCallback(async () => {
if (!current) return;
await markSeen(current.notification_id);
}, [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(() => {
if (!current) return;
setDetailsOpen(true);
}, [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;
const swatch = getTierColor(current.color || "indigo").swatch;
@@ -99,63 +73,38 @@ export default function AdminStickyAnnouncementBar() {
)}
</div>
{count > 1 && (
<div className="absolute right-2 flex items-center gap-1.5">
{stickyAnnouncements.map((a, i) => (
<button
key={a.notification_id}
type="button"
aria-label={`Show announcement ${i + 1}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveIndex(i);
}}
className="size-1.5 rounded-full transition-opacity"
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
/>
))}
</div>
)}
{/* Dismiss-X only makes sense for a single active announcement —
with multiple, the dialog's own close button (shadcn Dialog)
is the way to close/step away, no per-item dismiss from the bar. */}
{count <= 1 && (
<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>
)}
{/* Only way to dismiss a sticky alert — closing the details dialog
no longer dismisses it. */}
<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>
<AnnouncementCarouselDialog
<AnnouncementDetailsDialog
open={detailsOpen}
onOpenChange={onDialogOpenChange}
announcements={stickyAnnouncements}
activeIndex={safeIndex}
onIndexChange={setActiveIndex}
onOpenChange={setDetailsOpen}
announcement={current}
resolveClickAction={resolveClickAction}
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>
);
}
+11 -1
View File
@@ -9,6 +9,7 @@ import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { formatPlayerTime } from "@/utils/format.util";
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const DEBOUNCE_MS = 400;
@@ -26,6 +27,10 @@ const EXT_OPTIONS = {
function AssetCard({ asset, streamSrc, selected, onSelect }) {
const directThumb = asset.thumbnail_url ?? asset.file_url;
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 (
<button
@@ -37,7 +42,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
].join(" ")}
>
<div className="aspect-video bg-muted w-full overflow-hidden">
<div className="relative aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<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>
</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 className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p>
-4
View File
@@ -9,7 +9,6 @@ import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
import { DocumentBlock } from "./Blocks/Admin/DocumentBlock";
// ─── Block renderer ───────────────────────────────────────────────────────────
//
@@ -42,8 +41,6 @@ function BlockContent({ block, onUpdate }) {
return <CodeBlock content={content} onUpdate={onUpdate} />;
case "markdown":
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
case "document":
return <DocumentBlock content={content} onUpdate={onUpdate} />;
default:
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 },
"code": { language: "javascript", code: "" },
"markdown": { body: "" },
"document": { source_asset_id: null, source_filename: null, source_ext: null, body: "" },
};
// ─── 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' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div className="space-y-1">
<Label className="text-xs">URL *</Label>
<Label className="text-xs">URL <span className="text-destructive">*</span></Label>
<Input
placeholder="https://example.com"
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 { Button } from "@/components/ui/button";
import { useNavigate } from "react-router-dom";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
const ROTATE_INTERVAL_MS = 6000;
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
// resolveNotificationLink already gives explicit link_url (the admin "On
// Open" section) precedence over the type-based fallbacks, for broadcasts
@@ -18,48 +16,23 @@ function resolveClickAction(stickyAnnouncement) {
export default function StickyAnnouncementBar() {
const navigate = useNavigate();
const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications();
const [activeIndex, setActiveIndex] = useState(0);
const { stickyAnnouncements, markSeen } = useClientNotifications();
const [detailsOpen, setDetailsOpen] = useState(false);
const count = stickyAnnouncements.length;
// Derived rather than clamped via effect — safe the instant the array
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
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];
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
// it (X on the bar) reveals whichever is next in the queue.
const current = stickyAnnouncements[0];
const onDismiss = useCallback(async () => {
if (!current) return;
await markSeen(current.notification_id);
}, [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(() => {
if (!current) return;
setDetailsOpen(true);
}, [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;
const swatch = getTierColor(current.color || "indigo").swatch;
@@ -94,63 +67,38 @@ export default function StickyAnnouncementBar() {
)}
</div>
{count > 1 && (
<div className="absolute right-2 flex items-center gap-1.5">
{stickyAnnouncements.map((a, i) => (
<button
key={a.notification_id}
type="button"
aria-label={`Show announcement ${i + 1}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveIndex(i);
}}
className="size-1.5 rounded-full transition-opacity"
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
/>
))}
</div>
)}
{/* Dismiss-X only makes sense for a single active announcement —
with multiple, the dialog's own close button (shadcn Dialog)
is the way to close/step away, no per-item dismiss from the bar. */}
{count <= 1 && (
<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>
)}
{/* Only way to dismiss a sticky alert — closing the details dialog
no longer dismisses it. */}
<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>
<AnnouncementCarouselDialog
<AnnouncementDetailsDialog
open={detailsOpen}
onOpenChange={onDialogOpenChange}
announcements={stickyAnnouncements}
activeIndex={safeIndex}
onIndexChange={setActiveIndex}
onOpenChange={setDetailsOpen}
announcement={current}
resolveClickAction={resolveClickAction}
navigate={navigate}
bannerImage={bannerImage}
/>
</>
);
+26 -8
View File
@@ -100,7 +100,7 @@ export function AdvertisementsProvider({ children }) {
const advertisement = res.data?.data?.data ?? null;
if (advertisement) {
setAdvertisements((prev) => [advertisement, ...prev]);
toast("Advertisement created successfully.");
toast("Ad created successfully.");
}
return res.data;
}),
@@ -116,7 +116,24 @@ export function AdvertisementsProvider({ children }) {
if (advertisement) {
setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a)));
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;
}),
@@ -132,7 +149,7 @@ export function AdvertisementsProvider({ children }) {
});
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev));
toast("Advertisement archived.");
toast("Ad archived.");
return res.data;
}),
[request]
@@ -146,7 +163,7 @@ export function AdvertisementsProvider({ children }) {
data: { ids, deletedBy },
});
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;
}),
[request]
@@ -160,7 +177,7 @@ export function AdvertisementsProvider({ children }) {
const advertisement = res.data?.data?.data ?? null;
if (advertisement) {
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
toast("Advertisement restored.");
toast("Ad restored.");
}
return res.data;
}),
@@ -173,7 +190,7 @@ export function AdvertisementsProvider({ children }) {
request(async () => {
const res = await api.patch("/admin/advertisements/bulk-restore", { ids });
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;
}),
[request]
@@ -185,7 +202,7 @@ export function AdvertisementsProvider({ children }) {
request(async () => {
const res = await api.delete(`/admin/advertisements/${advertisementId}/permanent`);
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
toast("Advertisement permanently deleted.");
toast("Ad permanently deleted.");
return res.data;
}),
[request]
@@ -197,7 +214,7 @@ export function AdvertisementsProvider({ children }) {
request(async () => {
const res = await api.delete("/admin/advertisements/bulk/permanent", { data: { ids } });
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;
}),
[request]
@@ -227,6 +244,7 @@ export function AdvertisementsProvider({ children }) {
fetchArchivedAdvertisements,
createAdvertisement,
updateAdvertisement,
reorderAdvertisement,
archiveAdvertisement,
archiveAdvertisements,
restoreAdvertisement,
-82
View File
@@ -1,60 +1,8 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import { nanoid } from "nanoid";
import api from "@/utils/api.util";
import { useAuth } from "@/contexts/AuthContext";
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
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);
export function useAssets() {
@@ -84,7 +32,6 @@ const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
export function AssetsProvider({ children }) {
const { accessTokenRef } = useAuth();
const [assets, setAssets] = useState([]);
const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT);
@@ -286,34 +233,6 @@ export function AssetsProvider({ children }) {
[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 ────────────────────────────────────
//
// A replacement file (video thumbnail, or an image/audio asset's main
@@ -462,7 +381,6 @@ export function AssetsProvider({ children }) {
fetchAsset,
fetchArchivedAssets,
uploadAsset,
convertAssetToMarkdown,
updateAsset,
archiveAsset,
archiveAssets,
+130 -38
View File
@@ -7,62 +7,154 @@ const AdminCategoriesContext = createContext(null);
export function AdminCategoriesProvider({ children }) {
const [categories, setCategories] = useState([]);
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 wrap = useCallback(async (fn) => {
const fetchCategories = useCallback(async ({ page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) => {
setLoading(true);
try { return await fn(); }
catch (err) {
toast(err?.response?.data?.message ?? "Something went wrong.");
try {
const { data } = await api.get("/admin/categories", {
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;
} finally { setLoading(false); }
}, []);
const fetchCategories = useCallback((archived = false) => wrap(async () => {
const { data } = await api.get("/admin/categories", { params: { archived } });
setCategories(data.data ?? []);
return data.data;
}), [wrap]);
const createCategory = useCallback(async (payload) => {
setLoading(true);
try {
const { data } = await api.post("/admin/categories", payload);
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 { data } = await api.get(`/admin/categories/${id}`);
setCategory(data.data ?? null);
return data.data;
}), [wrap]);
const updateCategory = useCallback(async (id, payload) => {
setLoading(true);
try {
const { data } = await api.put(`/admin/categories/${id}`, payload);
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 { data } = await api.post("/admin/categories", payload);
toast("Category created.");
return data.data;
}), [wrap]);
const archiveCategory = useCallback(async (id) => {
setLoading(true);
try {
await api.delete(`/admin/categories/${id}`);
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 { data } = await api.put(`/admin/categories/${id}`, payload);
setCategory(data.data ?? null);
toast("Category updated.");
return data.data;
}), [wrap]);
const restoreCategory = useCallback(async (id) => {
setLoading(true);
try {
await api.post(`/admin/categories/${id}/restore`);
toast("Category restored.");
return true;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not restore category.");
return false;
} finally { setLoading(false); }
}, []);
const archiveCategory = useCallback((id) => wrap(async () => {
await api.delete(`/admin/categories/${id}`);
setCategories((prev) => prev.filter((c) => c.id !== id));
toast("Category archived.");
return true;
}), [wrap]);
const bulkArchiveCategories = useCallback(async (ids) => {
setLoading(true);
try {
await api.delete("/admin/categories/bulk", { data: { ids } });
toast("Categories archived.");
return true;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not archive categories.");
return false;
} finally { setLoading(false); }
}, []);
const restoreCategory = useCallback((id) => wrap(async () => {
await api.post(`/admin/categories/${id}/restore`);
setCategories((prev) => prev.filter((c) => c.id !== id));
toast("Category restored.");
return true;
}), [wrap]);
const bulkRestoreCategories = useCallback(async (ids) => {
setLoading(true);
try {
await api.post("/admin/categories/bulk-restore", { ids });
toast("Categories restored.");
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 (
<AdminCategoriesContext.Provider value={{
categories, category, loading,
categories, category, categoryAttributes, categoryPagination, setCategoryPagination, loading,
fetchCategories, fetchCategory,
createCategory, updateCategory,
archiveCategory, restoreCategory,
bulkArchiveCategories, bulkRestoreCategories,
fetchCategoryPermanentDeleteImpact, permanentlyDeleteCategory, bulkPermanentlyDeleteCategories,
}}>
{children}
</AdminCategoriesContext.Provider>
@@ -26,7 +26,6 @@ export function NotificationBroadcastsProvider({ children }) {
const [attributes, setAttributes] = useState([]);
const [pagination, setPagination] = useState(PAGINATION_INIT);
const [selectedBroadcast, setSelectedBroadcast] = useState(null);
const [stickyBannerSetting, setStickyBannerSetting] = useState(null);
const [loading, setLoading] = useState(false);
const request = useCallback(async (fn) => {
@@ -101,7 +100,7 @@ export function NotificationBroadcastsProvider({ children }) {
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => [broadcast, ...prev]);
toast("Notification broadcast created.");
toast("Alert created.");
}
return res.data;
}),
@@ -117,7 +116,7 @@ export function NotificationBroadcastsProvider({ children }) {
if (broadcast) {
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
setSelectedBroadcast(broadcast);
toast("Notification broadcast updated.");
toast("Alert updated.");
}
return res.data;
}),
@@ -133,7 +132,7 @@ export function NotificationBroadcastsProvider({ children }) {
if (broadcast) {
setBroadcasts((prev) => prev.map((b) => (b.broadcast_id === broadcastId ? broadcast : b)));
setSelectedBroadcast(broadcast);
toast("Notification broadcast sent.");
toast("Alert sent.");
}
return res.data;
}),
@@ -149,7 +148,7 @@ export function NotificationBroadcastsProvider({ children }) {
});
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
setSelectedBroadcast((prev) => (prev?.broadcast_id === broadcastId ? null : prev));
toast("Notification broadcast archived.");
toast("Alert archived.");
return res.data;
}),
[request]
@@ -163,7 +162,7 @@ export function NotificationBroadcastsProvider({ children }) {
data: { ids, deletedBy },
});
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;
}),
[request]
@@ -177,7 +176,7 @@ export function NotificationBroadcastsProvider({ children }) {
const broadcast = res.data?.data?.data ?? null;
if (broadcast) {
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
toast("Notification broadcast restored.");
toast("Alert restored.");
}
return res.data;
}),
@@ -190,7 +189,7 @@ export function NotificationBroadcastsProvider({ children }) {
request(async () => {
const res = await api.patch("/admin/announcements/bulk-restore", { ids });
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;
}),
[request]
@@ -202,36 +201,7 @@ export function NotificationBroadcastsProvider({ children }) {
request(async () => {
const res = await api.delete(`/admin/announcements/${broadcastId}/permanent`);
setBroadcasts((prev) => prev.filter((b) => b.broadcast_id !== broadcastId));
toast("Notification broadcast 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.");
}
toast("Alert permanently deleted.");
return res.data;
}),
[request]
@@ -243,7 +213,7 @@ export function NotificationBroadcastsProvider({ children }) {
request(async () => {
const res = await api.delete("/admin/announcements/bulk/permanent", { data: { ids } });
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;
}),
[request]
@@ -255,7 +225,6 @@ export function NotificationBroadcastsProvider({ children }) {
attributes,
pagination,
selectedBroadcast,
stickyBannerSetting,
loading,
setPagination,
setSelectedBroadcast,
@@ -271,8 +240,6 @@ export function NotificationBroadcastsProvider({ children }) {
restoreBroadcasts,
permanentlyDeleteBroadcast,
permanentlyDeleteBroadcasts,
fetchStickyBannerSetting,
updateStickyBannerSetting,
}}>
{children}
</NotificationBroadcastsContext.Provider>
@@ -15,7 +15,6 @@ export function AdminNotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const [unseenCount, setUnseenCount] = useState(0);
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
const [bannerImage, setBannerImage] = useState(null);
const [loading, setLoading] = useState(false);
const intervalRef = useRef(null);
@@ -32,7 +31,6 @@ export function AdminNotificationProvider({ children }) {
try {
const res = await api.get("/admin/notifications/sticky");
setStickyAnnouncements(res.data?.data?.announcements ?? []);
setBannerImage(res.data?.data?.bannerImage ?? null);
} catch {
// silent
}
@@ -92,7 +90,6 @@ export function AdminNotificationProvider({ children }) {
notifications,
unseenCount,
stickyAnnouncements,
bannerImage,
loading,
fetchNotifications,
markSeen,
+31
View File
@@ -348,6 +348,36 @@ export const UserProvider = ({ children }) => {
[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 ───────────────────────────────────────
const bulkBanUsers = useCallback(
({ ids, ...payload }) =>
@@ -413,6 +443,7 @@ export const UserProvider = ({ children }) => {
fetchUserAchievements,
fetchActivity, fetchUserActivity,
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, fetchUserBans,
makeAdmin, demoteAdmin,
}}>
{children}
</UserContext.Provider>
@@ -18,7 +18,6 @@ export function ClientNotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const [unseenCount, setUnseenCount] = useState(0);
const [stickyAnnouncements, setStickyAnnouncements] = useState([]);
const [bannerImage, setBannerImage] = useState(null);
const [loading, setLoading] = useState(false);
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
const intervalRef = useRef(null);
@@ -37,7 +36,6 @@ export function ClientNotificationProvider({ children }) {
try {
const res = await api.get("/client/notifications/sticky");
setStickyAnnouncements(res.data?.data?.announcements ?? []);
setBannerImage(res.data?.data?.bannerImage ?? null);
} catch {
// silent
}
@@ -132,7 +130,6 @@ export function ClientNotificationProvider({ children }) {
notifications,
unseenCount,
stickyAnnouncements,
bannerImage,
loading,
pagination,
fetchNotifications,
+2 -2
View File
@@ -41,8 +41,8 @@ export const ADMIN_SECTIONS = [
title: "Site Content",
description: "Manage public-facing content",
tiles: [
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
{ key: "notifications", label: "Announcements", icon: Bell, link: "/admin/announcements" },
{ key: "advertisements", label: "Ads", icon: Megaphone, link: "/admin/advertisements" },
{ key: "notifications", label: "Alerts", icon: Bell, link: "/admin/announcements" },
],
},
{
@@ -55,8 +55,8 @@ export default function ArchivedAdvertisementsTable() {
const exportConfig = {
allData: advertisements,
attributes,
filename: `${getTimestamp()}_ArchivedAdvertisements`,
sheetName: "Archived Advertisements",
filename: `${getTimestamp()}_ArchivedAds`,
sheetName: "Archived Ads",
generatedBy: formatGeneratedBy(currentUser),
};
@@ -123,7 +123,7 @@ export default function ArchivedAdvertisementsTable() {
return (
<>
<DataTable
title="Archived Advertisements"
title="Archived Ads"
data={advertisements}
columns={columns}
attributes={attributes}
@@ -146,8 +146,8 @@ export default function ArchivedAdvertisementsTable() {
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="archived advertisement"
emptyMessage="No archived advertisements found."
recordLabel="archived ad"
emptyMessage="No archived ads found."
/>
{/* ── Single restore ── */}
@@ -155,8 +155,8 @@ export default function ArchivedAdvertisementsTable() {
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Advertisement"
getName={(a) => a?.headline ?? a?.badge_label ?? "this advertisement"}
entityLabel="Ad"
getName={(a) => a?.headline ?? a?.badge_label ?? "this ad"}
onRestore={(a) => restoreAdvertisement(a?.advertisement_id)}
loading={loading}
onSuccess={handleRestoreSuccess}
@@ -167,7 +167,7 @@ export default function ArchivedAdvertisementsTable() {
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Advertisement"
entityLabel="Ad"
onRestore={(ids) => restoreAdvertisements(ids)}
loading={loading}
onSuccess={handleRestoreSuccess}
@@ -178,8 +178,8 @@ export default function ArchivedAdvertisementsTable() {
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
entity={deleteTarget}
entityLabel="Advertisement"
getName={(a) => a?.headline ?? a?.badge_label ?? "this advertisement"}
entityLabel="Ad"
getName={(a) => a?.headline ?? a?.badge_label ?? "this ad"}
onDelete={(a) => permanentlyDeleteAdvertisement(a?.advertisement_id)}
loading={loading}
onSuccess={handleDeleteSuccess}
@@ -190,7 +190,7 @@ export default function ArchivedAdvertisementsTable() {
open={!!deleteIds}
onOpenChange={(v) => !v && setDeleteIds(null)}
ids={deleteIds ?? []}
entityLabel="Advertisement"
entityLabel="Ad"
onDelete={(ids) => permanentlyDeleteAdvertisements(ids)}
loading={loading}
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 }) {
const [attachOpen, setAttachOpen] = 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;
@@ -32,50 +28,24 @@ export default function AchievementsBuilder({ achievementKeys, onAchievementKeys
const declineAchievement = () => {
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 (
<div className="border-t pt-4">
<div className="flex items-start justify-between gap-3 pb-3 mb-3 border-b">
<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">
Attach an existing achievement from the registry, or define a new one from scratch.
</p>
</div>
<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)}>
<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>
</div>
</div>
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from 'react';
import {
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw, AlertTriangle
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
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 }) {
const initials = name
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
@@ -102,6 +123,8 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
</div>
)}
{entry && <PendingNotice entry={entry} />}
{/* ── Progress bar ── */}
{entry && (
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
@@ -197,6 +220,7 @@ function UserCard({ entry, onOpen }) {
<StatusBadge status={entry.course_status} />
</div>
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
<PendingNotice entry={entry} />
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
<p className="text-xs">Last seen {lastSeen}</p>
</div>
@@ -266,7 +290,8 @@ function PaginationControls({ page, totalPages, onPage }) {
export default function CourseReadingProgressList({ courseId }) {
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [dialogEntry, setDialogEntry] = useState(null);
@@ -274,18 +299,21 @@ export default function CourseReadingProgressList({ courseId }) {
fetchCourseReadingProgress(courseId);
}, [courseId]);
// Reset to page 1 when search changes
useEffect(() => { setPage(1); }, [search]);
const handleSearchSubmit = (e) => {
e.preventDefault();
setQuery(searchInput.trim());
setPage(1);
};
// ── Filter ────────────────────────────────────────────────────────────────
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
const q = query.trim().toLowerCase();
if (!q) return progressList;
return progressList.filter((e) =>
e.user.full_name?.toLowerCase().includes(q) ||
e.user.email?.toLowerCase().includes(q)
);
}, [progressList, search]);
}, [progressList, query]);
// ── Paginate ──────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
@@ -343,23 +371,29 @@ export default function CourseReadingProgressList({ courseId }) {
</div>
{/* ── Search ── */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name or email…"
value={search}
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
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>
</div>
<form onSubmit={handleSearchSubmit} className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Search by name or email…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value.slice(0, 50))}
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 ${searchInput.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
{searchInput.length}/50
</span>
</div>
<Button type="submit" variant="outline" className="shrink-0">
<Search className="size-4" />
Search
</Button>
</form>
{/* ── List ── */}
{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">
{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 api from "@/utils/api.util";
@@ -38,6 +38,17 @@ export default function CoursesTable() {
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 = {
allData: courses,
attributes,
@@ -90,8 +101,8 @@ export default function CoursesTable() {
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
() => buildDataColumns(attributes, rowActions, tierMap),
[attributes, rowActions, tierMap]
);
const handleArchiveSuccess = () => {
@@ -11,7 +11,6 @@ import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
import { DocumentBlock } from "@/components/generic/Blocks/Client/DocumentBlock";
export function LessonHeader({ lesson }) {
if (!lesson) return null;
@@ -160,8 +159,6 @@ export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnable
return <CodeBlock content={content} />;
case "markdown":
return <MarkdownBlock content={content} />;
case "document":
return <DocumentBlock content={content} />;
default:
return null;
}
@@ -117,12 +117,13 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {
</p>
</div>
<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)}>
<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 type="button" variant="outline" size="sm" onClick={() => setCreateUnitOpen(true)}>
<Plus className="h-3.5 w-3.5 mr-1.5" /> Create
</Button>
</div>
</div>
@@ -226,7 +227,7 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {
{units.length === 0 && (
<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" />
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>
)}
@@ -58,7 +58,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<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>
<DialogDescription>
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)}
/>
<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">
{formatDuration(l.duration_seconds ?? 0)}
</p>
@@ -120,7 +120,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
Select {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
@@ -63,7 +63,7 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<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>
<DialogDescription>
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)}
/>
<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">
{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}
</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 { 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 { buildRowActions } from "../../config/library/lessons/rowActions.config";
import api from "@/utils/api.util";
import { getTimestamp } from "@/utils/timestamp.util";
import { formatGeneratedBy } from "@/utils/generatedBy.util";
@@ -41,6 +42,17 @@ export default function LessonLibraryTable() {
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 = {
allData: lessons,
attributes,
@@ -77,8 +89,8 @@ export default function LessonLibraryTable() {
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
() => buildDataColumns(attributes, rowActions, tierMap),
[attributes, rowActions, tierMap]
);
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 { 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 { buildRowActions } from "../../config/library/units/rowActions.config";
import api from "@/utils/api.util";
import { getTimestamp } from "@/utils/timestamp.util";
import { formatGeneratedBy } from "@/utils/generatedBy.util";
@@ -41,6 +42,17 @@ export default function UnitLibraryTable() {
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 = {
allData: units,
attributes,
@@ -78,8 +90,8 @@ export default function UnitLibraryTable() {
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes, rowActions]
() => buildDataColumns(attributes, rowActions, tierMap),
[attributes, rowActions, tierMap]
);
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>
);
}
@@ -52,7 +52,7 @@ export default function ArchivedNotificationBroadcastsTable() {
allData: broadcasts,
attributes,
filename: `${getTimestamp()}_ArchivedNotificationBroadcasts`,
sheetName: "Archived Announcements",
sheetName: "Archived Alerts",
generatedBy: formatGeneratedBy(currentUser),
};
@@ -102,7 +102,7 @@ export default function ArchivedNotificationBroadcastsTable() {
return (
<>
<DataTable
title="Archived Announcements"
title="Archived Alerts"
data={broadcasts}
columns={columns}
attributes={attributes}
@@ -125,8 +125,8 @@ export default function ArchivedNotificationBroadcastsTable() {
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="archived announcement"
emptyMessage="No archived announcements found."
recordLabel="archived alert"
emptyMessage="No archived alerts found."
/>
{/* ── Single restore ── */}
@@ -134,8 +134,8 @@ export default function ArchivedNotificationBroadcastsTable() {
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Announcement"
getName={(b) => b?.title ?? "this announcement"}
entityLabel="Alert"
getName={(b) => b?.title ?? "this alert"}
onRestore={(b) => restoreBroadcast(b?.broadcast_id)}
loading={loading}
onSuccess={handleRestoreSuccess}
@@ -146,7 +146,7 @@ export default function ArchivedNotificationBroadcastsTable() {
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Announcement"
entityLabel="Alert"
onRestore={(ids) => restoreBroadcasts(ids)}
loading={loading}
onSuccess={handleRestoreSuccess}
@@ -157,8 +157,8 @@ export default function ArchivedNotificationBroadcastsTable() {
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
entity={deleteTarget}
entityLabel="Announcement"
getName={(b) => b?.title ?? "this announcement"}
entityLabel="Alert"
getName={(b) => b?.title ?? "this alert"}
onDelete={(b) => permanentlyDeleteBroadcast(b?.broadcast_id)}
loading={loading}
onSuccess={handleDeleteSuccess}
@@ -169,7 +169,7 @@ export default function ArchivedNotificationBroadcastsTable() {
open={!!deleteIds}
onOpenChange={(v) => !v && setDeleteIds(null)}
ids={deleteIds ?? []}
entityLabel="Announcement"
entityLabel="Alert"
onDelete={(ids) => permanentlyDeleteBroadcasts(ids)}
loading={loading}
onSuccess={handleDeleteSuccess}
@@ -12,6 +12,8 @@ import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
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 { buildDataColumns, columnPinning } from "../../config/users/columns.config";
@@ -30,6 +32,8 @@ export default function UsersTable() {
const [banIds, setBanIds] = useState(null);
const [unbanTarget, setUnbanTarget] = useState(null);
const [unbanIds, setUnbanIds] = useState(null);
const [makeAdminTarget, setMakeAdminTarget] = useState(null);
const [demoteAdminTarget, setDemoteAdminTarget] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
@@ -45,7 +49,7 @@ export default function UsersTable() {
const {
users, attributes, pagination, setPagination, loading,
fetchUsers, fetchUserFieldValues, deactivateUser, deactivateUsers,
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers,
banUser, unbanUser, bulkBanUsers, bulkUnbanUsers, makeAdmin, demoteAdmin,
} = useUsers();
const { usersDashboard, fetchUsersDashboard } = useDashboard();
@@ -70,9 +74,12 @@ export default function UsersTable() {
const rowActions = buildRowActions({
navigate,
onArchive: (row) => setArchiveTarget(row),
onBan: (row) => setBanTarget(row),
onUnban: (row) => setUnbanTarget(row),
onArchive: (row) => setArchiveTarget(row),
onBan: (row) => setBanTarget(row),
onUnban: (row) => setUnbanTarget(row),
onMakeAdmin: (row) => setMakeAdminTarget(row),
onDemoteAdmin: (row) => setDemoteAdminTarget(row),
currentUserId: currentUser?.user_id,
});
const toolbarActions = buildToolbarActions({
fetchUsers, pagination, exportConfig, navigate,
@@ -113,6 +120,16 @@ export default function UsersTable() {
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 ────────────────
const statsWithFilter = (usersDashboard?.stats ?? []).map((s) => ({
...s,
@@ -239,6 +256,26 @@ export default function UsersTable() {
loading={loading}
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 [
buildSelectionColumn(),
...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 { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
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 { resolveTierBadge } from "@/utils/tierBadge.util";
export const columnPinning = {
right: ["actions"],
@@ -15,7 +17,8 @@ export const columnPinning = {
// ─── Custom cell overrides ────────────────────────────────────────────────────
const cellOverrides = {
function buildCellOverrides(tierMap) {
return {
unitCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
@@ -50,17 +53,33 @@ const cellOverrides = {
</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.
*
* @param {Array} attributes Field definitions from the server (drives data columns)
* @param {Array} rowActions Row-level kebab action definitions
* @param {Object} tierMap slug → tier category, for colored Subscription badges
* @returns {Array} TanStack column definitions
*/
export function buildDataColumns(attributes, rowActions) {
export function buildDataColumns(attributes, rowActions, tierMap = {}) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
const cellOverrides = buildCellOverrides(tierMap);
return [
buildSelectionColumn(),
@@ -2,18 +2,21 @@
// Column definitions and pinning for the standalone Lesson Library table.
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 { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { formatDuration } from "@/utils/timestamp.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
function buildCellOverrides(tierMap) {
return {
title: (info) => {
const inUnit = parseInt(info.row.original.unit_count ?? 0, 10) > 0;
return (
@@ -49,10 +52,28 @@ const cellOverrides = {
</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 cellOverrides = buildCellOverrides(tierMap);
return [
buildSelectionColumn(),
@@ -20,21 +20,21 @@ export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArc
// icon: <Pencil className="h-3.5 w-3.5" />,
// onClick: (row) => onEdit(row),
// },
{
key: "build_page",
label: "Page Builder",
icon: <LayoutTemplate className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onBuildPage(row),
separator: true,
},
{
key: "view_page",
label: "View Page",
icon: <FileText className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onViewPage(row),
},
// {
// key: "build_page",
// label: "Page Builder",
// icon: <LayoutTemplate className="h-3.5 w-3.5" />,
// className: "text-sky-700 hover:text-sky-600",
// onClick: (row) => onBuildPage(row),
// separator: true,
// },
// {
// key: "view_page",
// label: "View Page",
// icon: <FileText className="h-3.5 w-3.5" />,
// className: "text-sky-700 hover:text-sky-600",
// onClick: (row) => onViewPage(row),
// },
{
key: "archive",
label: "Archive",
@@ -1,7 +1,7 @@
// config/library/lessons/toolbar.config.jsx
// 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";
export function buildToolbarActions({
@@ -46,14 +46,6 @@ export function buildToolbarActions({
variant: "default",
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",
type: "button",
@@ -76,15 +68,6 @@ export function buildArchivedToolbarActions({
getTableInstance,
}) {
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",
type: "button",
@@ -2,54 +2,75 @@
// Column definitions and pinning for the standalone Unit Library table.
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 { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { formatDuration } from "@/utils/timestamp.util";
import { resolveTierBadge } from "@/utils/tierBadge.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
title: (info) => {
const inCourse = parseInt(info.row.original.course_count ?? 0, 10) > 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{inCourse && (
<Badge variant="secondary" className="gap-1 text-[10px] font-medium shrink-0">
<GraduationCap className="h-3 w-3" />
Course
function buildCellOverrides(tierMap) {
return {
title: (info) => {
const inCourse = parseInt(info.row.original.course_count ?? 0, 10) > 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{inCourse && (
<Badge variant="secondary" className="gap-1 text-[10px] font-medium shrink-0">
<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>
)}
<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>
</div>
);
},
lesson_count: (info) => (
<Badge variant="outline" className="text-xs tabular-nums">
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
</Badge>
),
};
</div>
);
},
lesson_count: (info) => (
<Badge variant="outline" className="text-xs tabular-nums">
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
</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 cellOverrides = buildCellOverrides(tierMap);
return [
buildSelectionColumn(),
@@ -14,40 +14,40 @@ export function buildRowActions({ onView, onEdit, onManageLessons, onArchive, on
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row),
},
{
key: "manage_lessons",
label: "Manage Lessons",
icon: <BookCheck className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600",
onClick: (row) => onManageLessons(row),
separator: true,
},
{
key: "create_quiz",
label: "Create Quiz",
icon: <PlusCircle className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !!(row.quiz_id || row.quiz),
separator: true,
},
{
key: "view_quiz",
label: "View Quiz",
icon: <ClipboardList className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onViewQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
separator: true,
},
{
key: "modify_quiz",
label: "Modify Quiz",
icon: <NotebookPen className="h-3.5 w-3.5" />,
className: "text-purple-700 hover:text-purple-600",
onClick: (row) => onQuiz(row),
hidden: (row) => !(row.quiz_id || row.quiz),
},
// {
// key: "manage_lessons",
// label: "Manage Lessons",
// icon: <BookCheck className="h-3.5 w-3.5" />,
// className: "text-sky-700 hover:text-sky-600",
// onClick: (row) => onManageLessons(row),
// separator: true,
// },
// {
// key: "create_quiz",
// label: "Create Quiz",
// icon: <PlusCircle className="h-3.5 w-3.5" />,
// className: "text-purple-700 hover:text-purple-600",
// onClick: (row) => onQuiz(row),
// hidden: (row) => !!(row.quiz_id || row.quiz),
// separator: true,
// },
// {
// key: "view_quiz",
// label: "View Quiz",
// icon: <ClipboardList className="h-3.5 w-3.5" />,
// className: "text-purple-700 hover:text-purple-600",
// onClick: (row) => onViewQuiz(row),
// hidden: (row) => !(row.quiz_id || row.quiz),
// separator: true,
// },
// {
// key: "modify_quiz",
// label: "Modify Quiz",
// icon: <NotebookPen className="h-3.5 w-3.5" />,
// className: "text-purple-700 hover:text-purple-600",
// onClick: (row) => onQuiz(row),
// hidden: (row) => !(row.quiz_id || row.quiz),
// },
{
key: "archive",
label: "Archive",
@@ -3,17 +3,20 @@
//
// 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 {Function} deps.navigate React Router navigate
* @param {Function} deps.onArchive Opens archive dialog
* @param {Function} deps.onBan Opens ban dialog
* @param {Function} deps.onUnban Opens unban dialog
* @param {Function} deps.navigate React Router navigate
* @param {Function} deps.onArchive Opens archive dialog
* @param {Function} deps.onBan Opens ban 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
*/
export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
export function buildRowActions({ navigate, onArchive, onBan, onUnban, onMakeAdmin, onDemoteAdmin, currentUserId }) {
return [
{
key: "view",
@@ -21,6 +24,21 @@ export function buildRowActions({ navigate, onArchive, onBan, onUnban }) {
icon: <Eye className="h-3.5 w-3.5" />,
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",
label: "Ban User",
@@ -60,7 +60,6 @@ const schema = z.object({
}).default({}),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
}).superRefine((data, ctx) => {
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: "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: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates, display order, and draft/active status." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this advertisement." },
{ 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 ad." },
];
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -245,11 +244,21 @@ function StepContent({
<StepImagePicker selectedAsset={selectedAsset} imageUrl={imageUrl} setPickerOpen={setPickerOpen} />
</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" && (
<div className="space-y-5">
<div>
<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>
<Label className="mb-1.5 block">Headline</Label>
@@ -373,7 +382,7 @@ function StepPageBuilder({ register, linkFields, appendLink, removeLink }) {
// ─── Step 4: Scheduling & Display ───────────────────────────────────────────
function StepScheduling({ register, watch, setValue }) {
function StepScheduling({ watch, setValue }) {
const isActive = watch("is_active");
return (
@@ -397,13 +406,10 @@ function StepScheduling({ register, watch, setValue }) {
</div>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<div>
<Label className="mb-1.5 block">Status</Label>
<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
checked={isActive}
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>
<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="Order" value={data.order} />
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
</div>
</div>
@@ -537,7 +542,6 @@ export default function AddAdvertisement() {
landing_page: { title: "", description: "", body: "", links: [] },
start_date: "",
end_date: "",
order: 0,
is_active: true,
},
});
@@ -565,7 +569,7 @@ export default function AddAdvertisement() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Ads", to: "/admin/advertisements" },
{ label: "New" },
];
@@ -651,7 +655,7 @@ export default function AddAdvertisement() {
/>
)}
{current.id === "scheduling" && (
<StepScheduling register={register} watch={watch} setValue={setValue} />
<StepScheduling watch={watch} setValue={setValue} />
)}
{current.id === "review" && (
<StepReview data={getValues()} selectedAsset={selectedAsset} imageUrl={imagePreviewUrl} />
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
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 { resolveAssetSrc } from "@/utils/media.util";
@@ -18,14 +18,14 @@ import {
} from "@/components/ui/alert-dialog";
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";
const DEFAULT_PAGE_SIZE = 10;
export default function AdvertisementList() {
const navigate = useNavigate();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement, reorderAdvertisement } = useAdvertisements();
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
@@ -57,7 +57,7 @@ export default function AdvertisementList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements" },
{ label: "Ads" },
];
const total = pagination?.totalRecords ?? advertisements.length;
@@ -68,6 +68,25 @@ export default function AdvertisementList() {
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 (
<section className="bg-muted h-full">
<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 ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<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>
</div>
<div className="flex items-center gap-2">
@@ -90,7 +109,7 @@ export default function AdvertisementList() {
</Button>
<Button onClick={() => navigate("/admin/advertisements/add")}>
<Plus className="size-4" />
New advertisement
New ad
</Button>
</div>
</div>
@@ -132,7 +151,7 @@ export default function AdvertisementList() {
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search advertisements..."
placeholder="Search ads..."
className="pl-8 bg-background"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
@@ -154,15 +173,27 @@ export default function AdvertisementList() {
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{advertisements.map((ad) => (
<AdvertisementCard
key={ad.advertisement_id}
ad={ad}
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
onArchive={() => handleArchive(ad.advertisement_id)}
/>
<div className="flex flex-col gap-6">
{groups.map((group) => (
<div key={group.key ?? "unassigned"} className="flex flex-col gap-3">
<h2 className="text-sm font-semibold text-muted-foreground">{group.heading}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.ads.map((ad, index) => (
<AdvertisementCard
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>
@@ -172,7 +203,7 @@ export default function AdvertisementList() {
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
rowCount={advertisements.length}
recordLabel="advertisement"
recordLabel="ad"
/>
</div>
</>
@@ -203,7 +234,7 @@ function StatCard({ label, value, tone = "default" }) {
// ─── Advertisement card ─────────────────────────────────────────────────────
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
function AdvertisementCard({ ad, position, onView, onEdit, onArchive, canMoveUp, canMoveDown, onMoveUp, onMoveDown }) {
const { fmtDateTime } = useDateFormat();
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
@@ -221,7 +252,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
type="button"
onClick={onView}
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 ? (
<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">
<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 ? (
<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">
<span className="flex items-center gap-1">
<MousePointerClick className="size-3.5" />
{ad.click_count ?? 0} clicks
<ListOrdered className="size-3.5" />
Order #{position}
</span>
<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">
<Edit className="size-3.5" />
</Button>
@@ -266,9 +303,9 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this advertisement?</AlertDialogTitle>
<AlertDialogTitle>Archive this ad?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<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">
<Megaphone className="size-8 text-muted-foreground" />
<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>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
New advertisement
New ad
</Button>
</div>
);
@@ -6,7 +6,7 @@ import ArchivedAdvertisementsTable from "../../components/advertisements/Archive
export default function ArchivedAdvertisementList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Advertisements", to: `/admin/advertisements` },
{ label: "Ads", to: `/admin/advertisements` },
{ label: "Archived" },
];
@@ -54,7 +54,6 @@ const schema = z.object({
}).default({}),
start_date: z.string().optional(),
end_date: z.string().optional(),
order: z.coerce.number().min(0).default(0),
is_active: z.boolean().default(true),
}).superRefine((data, ctx) => {
const format = PLACEMENT_MAP[data.placement]?.format;
@@ -134,7 +133,6 @@ export default function EditAdvertisement() {
landing_page: { title: "", description: "", body: "", links: [] },
start_date: "",
end_date: "",
order: 0,
is_active: true,
},
});
@@ -152,7 +150,7 @@ export default function EditAdvertisement() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Ads", to: "/admin/advertisements" },
{ label: "Edit" },
];
@@ -192,7 +190,6 @@ export default function EditAdvertisement() {
},
start_date: ad.start_date ?? "",
end_date: ad.end_date ?? "",
order: ad.order ?? 0,
is_active: ad.is_active ?? true,
});
@@ -287,11 +284,21 @@ export default function EditAdvertisement() {
))}
</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" && (
<>
<div>
<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>
<Label className="mb-1.5 block">Headline</Label>
@@ -458,14 +465,11 @@ export default function EditAdvertisement() {
</div>
</SectionCard>
<SectionCard title="Display" description="Manual ordering and draft/active switch.">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1.5 block">Order</Label>
<Input type="number" min={0} {...register("order")} />
</div>
<SectionCard title="Display" description="Draft/active switch.">
<div>
<Label className="mb-1.5 block">Status</Label>
<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
checked={watch("is_active")}
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
@@ -60,7 +60,7 @@ export default function ViewAdvertisement() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Advertisements", to: "/admin/advertisements" },
{ label: "Ads", to: "/admin/advertisements" },
{ label: "View" },
];
@@ -81,7 +81,7 @@ export default function ViewAdvertisement() {
<div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} />
</div>
<p className="text-sm text-muted-foreground">Advertisement not found.</p>
<p className="text-sm text-muted-foreground">Ad not found.</p>
</div>
</section>
);
@@ -112,7 +112,7 @@ export default function ViewAdvertisement() {
</Button>
<div>
<h1 className="text-xl font-semibold tracking-tight">
{advertisement.headline || advertisement.badge_label || "Untitled advertisement"}
{advertisement.headline || advertisement.badge_label || "Untitled ad"}
</h1>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<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 { useNavigate } from "react-router-dom";
import { House, Plus, Pencil, Trash2, RotateCcw, Tag } from "lucide-react";
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { useCategories } from "@/contexts/AdminCategoriesContext";
import CategoriesTable from "../../components/categories/CategoriesTable";
const BREADCRUMB = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -13,113 +8,15 @@ const BREADCRUMB = [
];
export default function CategoryList() {
const navigate = useNavigate();
const { categories, loading, fetchCategories, archiveCategory, restoreCategory } = useCategories();
const [showArchived, setShowArchived] = useState(false);
useEffect(() => { fetchCategories(showArchived); }, [showArchived]);
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 flex items-center justify-between mb-4">
<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 className="w-full">
<CategoriesTable />
</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>
</section>
);
@@ -250,6 +250,10 @@ export default function AddCourse() {
return;
}
}
if (target > 1 && roadmapUnits.length === 0) {
setCurrentStep(1);
return;
}
setCurrentStep(target);
};
@@ -774,7 +778,11 @@ export default function AddCourse() {
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : 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
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
@@ -130,7 +130,7 @@ export default function LessonPageBuilder() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<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" />
</Button>
<div className="flex-1 min-w-0">
@@ -158,7 +158,7 @@ export default function LessonPageBuilder() {
<Eye className="h-4 w-4" />
Preview
</Button>
<Button type="button" variant="outline" onClick={() => navigate(pageViewPath)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading}>
@@ -16,9 +16,6 @@ export default function ViewLessonPage() {
const builderPath = unitId
? `/admin/courses/${courseId}/units/${unitId}/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 [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="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" />
</Button>
<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="flex items-start justify-between gap-4">
<div>
<h6 className="text-xs tracking-widest mb-1">STANDALONE LESSON</h6>
<h1 className="text-xl font-semibold">{lesson?.title}</h1>
{lesson?.description && (
<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`)}>
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</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
</Button>
</div>
@@ -25,6 +25,7 @@ export default function ViewLibraryUnit() {
const [initializing, setInitializing] = useState(true);
const [attachOpen, setAttachOpen] = useState(false);
const [descExpanded, setDescExpanded] = useState(false);
useEffect(() => {
(async () => {
@@ -82,11 +83,21 @@ export default function ViewLibraryUnit() {
{/* ── Unit header ── */}
<div className="bg-card rounded-xl border p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h6 className="text-xs tracking-widest mb-1">STANDALONE UNIT</h6>
<div className="max-w-2xl">
<h1 className="text-xl font-semibold">{unit?.title}</h1>
{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 className="flex gap-2 shrink-0">
@@ -154,15 +165,15 @@ export default function ViewLibraryUnit() {
<div>
<h2 className="font-semibold">Lessons</h2>
<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>
</div>
<div className="flex gap-2">
<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 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>
</div>
</div>
@@ -201,27 +212,27 @@ export default function ViewLibraryUnit() {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{l.title}</p>
{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>
<Badge variant="secondary" className="text-xs shrink-0 tabular-nums">
<Clock className="h-3 w-3 mr-1" /> {formatDuration(l.duration_seconds ?? 0)}
</Badge>
<Button
size="sm" variant="ghost"
size="sm" variant="outline"
onClick={() => navigate(`/admin/lessons/${l.lesson_id}/page`)}
title="Page Builder"
>
<LayoutTemplate className="h-3.5 w-3.5" />
<LayoutTemplate /> Page Builder
</Button>
<Button
size="sm" variant="ghost"
className="text-destructive hover:text-destructive"
size="sm"
variant="destructive"
onClick={() => handleDetach(l.lesson_id)}
disabled={loading}
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>
</div>
))}
@@ -6,12 +6,14 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
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 { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
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 { Checkbox } from "@/components/ui/checkbox";
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 { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
import { resolveAssetSrc } from "@/utils/media.util";
// Internal paths ("/course/123") pass through untouched — everything else
// 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_label: z.string().trim().optional(),
color: z.string().optional(),
image_asset_id: z.string().nullable().optional(),
start_date: z.string().optional(),
end_date: z.string().optional(),
}).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 }) {
return (
<div className="flex items-start w-full mb-8">
@@ -171,6 +219,8 @@ export default function AddNotificationBroadcast() {
const [currentStep, setCurrentStep] = useState(0);
const [targetLabel, setTargetLabel] = useState(null);
const [imageAsset, setImageAsset] = useState(null);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const {
register,
@@ -192,6 +242,7 @@ export default function AddNotificationBroadcast() {
link_url: "",
link_label: "",
color: "indigo",
image_asset_id: null,
start_date: "",
end_date: "",
},
@@ -211,7 +262,7 @@ export default function AddNotificationBroadcast() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Announcements", to: "/admin/announcements" },
{ label: "Alerts", to: "/admin/announcements" },
{ 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_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
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,
end_date: values.end_date || null,
createdBy: user?.user_id ?? null,
@@ -269,8 +321,8 @@ export default function AddNotificationBroadcast() {
</div>
<div className="w-full max-w-2xl pb-10">
<h1 className="text-2xl font-semibold tracking-tight mb-1">New announcement</h1>
<p className="text-sm text-muted-foreground mb-6">Compose an announcement. It's saved as a draft until you send it.</p>
<h1 className="text-2xl font-semibold tracking-tight mb-1">New Alert</h1>
<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} />
@@ -286,7 +338,7 @@ export default function AddNotificationBroadcast() {
</div>
<div>
<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} />
</div>
</SectionCard>
@@ -294,7 +346,7 @@ export default function AddNotificationBroadcast() {
{/* ── Step 1: Target ── */}
{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>
<Select
value={targetType}
@@ -338,7 +390,7 @@ export default function AddNotificationBroadcast() {
{/* ── Step 2: Display ── */}
{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="flex items-center gap-3">
<Checkbox
@@ -347,14 +399,14 @@ export default function AddNotificationBroadcast() {
onCheckedChange={(v) => {
const checked = v === 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
// the list too) — force the list entry so it stays reachable afterward.
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
}}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
Show in Sticky Alerts
</Label>
</div>
@@ -434,7 +486,20 @@ export default function AddNotificationBroadcast() {
</SectionCard>
{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">
<Button
type="button"
@@ -479,6 +544,17 @@ export default function AddNotificationBroadcast() {
)}
</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">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<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>
</div>
<div>
@@ -600,6 +676,16 @@ export default function AddNotificationBroadcast() {
</div>
{unsavedChangesDialog}
<AssetPickerSheet
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
fileType="image"
onSelect={(asset) => {
setImageAsset(asset);
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
}}
/>
</section>
);
}
@@ -6,7 +6,7 @@ import ArchivedNotificationBroadcastsTable from "../../components/notifications/
export default function ArchivedNotificationBroadcastList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Announcements", to: `/admin/announcements` },
{ label: "Alerts", to: `/admin/announcements` },
{ label: "Archived" },
];
@@ -6,12 +6,14 @@ import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
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 { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
@@ -21,11 +23,16 @@ import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
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 { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
import { resolveAssetSrc } from "@/utils/media.util";
// Internal paths ("/course/123") pass through untouched — everything else
// 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_label: z.string().trim().optional(),
color: z.string().optional(),
image_asset_id: z.string().nullable().optional(),
start_date: z.string().optional(),
end_date: z.string().optional(),
}).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 }) {
return (
<div className="flex items-start w-full mb-8">
@@ -173,6 +225,8 @@ export default function EditNotificationBroadcast() {
const [currentStep, setCurrentStep] = useState(0);
const [targetLabel, setTargetLabel] = useState(null);
const [broadcastStatus, setBroadcastStatus] = useState("draft");
const [imageAsset, setImageAsset] = useState(null);
const [imagePickerOpen, setImagePickerOpen] = useState(false);
const {
register,
@@ -195,6 +249,7 @@ export default function EditNotificationBroadcast() {
link_url: "",
link_label: "",
color: "indigo",
image_asset_id: null,
start_date: "",
end_date: "",
},
@@ -214,7 +269,7 @@ export default function EditNotificationBroadcast() {
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Announcements", to: "/admin/announcements" },
{ label: "Alerts", to: "/admin/announcements" },
{ label: "Edit" },
];
@@ -239,9 +294,11 @@ export default function EditNotificationBroadcast() {
link_url: b.link_url ?? "",
link_label: b.link_label ?? "",
color: b.color ?? "indigo",
image_asset_id: b.image_asset_id ? String(b.image_asset_id) : null,
start_date: b.start_date ?? "",
end_date: b.end_date ?? "",
});
setImageAsset(b.image ?? null);
setBroadcastStatus(b.status ?? "draft");
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_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
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,
end_date: values.end_date || null,
updatedBy: user?.user_id ?? null,
@@ -301,10 +359,10 @@ export default function EditNotificationBroadcast() {
</div>
<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">
{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."}
</p>
@@ -322,7 +380,7 @@ export default function EditNotificationBroadcast() {
</div>
<div>
<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} />
</div>
</SectionCard>
@@ -330,7 +388,7 @@ export default function EditNotificationBroadcast() {
{/* ── Step 1: Target ── */}
{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>
<Select
value={targetType}
@@ -374,7 +432,7 @@ export default function EditNotificationBroadcast() {
{/* ── Step 2: Display ── */}
{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="flex items-center gap-3">
<Checkbox
@@ -383,14 +441,14 @@ export default function EditNotificationBroadcast() {
onCheckedChange={(v) => {
const checked = v === 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
// the list too) — force the list entry so it stays reachable afterward.
if (checked) setValue("show_in_notifications", true, { shouldValidate: true, shouldDirty: true });
}}
/>
<Label htmlFor="show_in_sticky" className="cursor-pointer">
Show in Sticky Announcements
Show in Sticky Alerts
</Label>
</div>
@@ -470,7 +528,20 @@ export default function EditNotificationBroadcast() {
</SectionCard>
{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">
<Button
type="button"
@@ -515,6 +586,17 @@ export default function EditNotificationBroadcast() {
)}
</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">
<div className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<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>
</div>
<div>
@@ -610,14 +692,28 @@ export default function EditNotificationBroadcast() {
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
) : broadcastStatus === "sent" ? (
<Button
type="button"
disabled={loading}
onClick={handleSubmit((values) => saveBroadcast(values, { publish: false }))}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save changes
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save changes
</Button>
</AlertDialogTrigger>
<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">
<Button
@@ -645,6 +741,16 @@ export default function EditNotificationBroadcast() {
</div>
{unsavedChangesDialog}
<AssetPickerSheet
open={imagePickerOpen}
onOpenChange={setImagePickerOpen}
fileType="image"
onSelect={(asset) => {
setImageAsset(asset);
setValue("image_asset_id", String(asset.asset_id), { shouldDirty: true });
}}
/>
</section>
);
}
@@ -2,13 +2,11 @@
import { useEffect, useState } from "react";
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 { useAuth } from "@/contexts/AuthContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { TablePagination } from "@/components/generic/Table/TablePagination";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -20,29 +18,17 @@ import {
} from "@/components/ui/alert-dialog";
import { TARGET_TYPE_MAP, BROADCAST_STATUSES, BROADCAST_STATUS_MAP } from "@/data/notificationBroadcast.data";
import { resolveAssetSrc } from "@/utils/media.util";
export default function NotificationBroadcastList() {
const navigate = useNavigate();
const { user } = useAuth();
const {
broadcasts, pagination, loading, fetchBroadcasts, sendBroadcast, archiveBroadcast,
stickyBannerSetting, fetchStickyBannerSetting, updateStickyBannerSetting,
} = useNotificationBroadcasts();
const [statusFilter, setStatusFilter] = useState("all");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [limit, setLimit] = useState(12);
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
}, []);
const [limit, setLimit] = useState(10);
function buildFilters() {
const filters = [];
@@ -58,7 +44,7 @@ export default function NotificationBroadcastList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Announcements" },
{ label: "Alerts" },
];
const total = pagination?.totalRecords ?? broadcasts.length;
@@ -85,8 +71,8 @@ export default function NotificationBroadcastList() {
{/* ── Header ─────────────────────────────────────────────────── */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Announcements</h1>
<p className="text-sm text-muted-foreground">Compose and send announcements to admins and users</p>
<h1 className="text-2xl font-semibold tracking-tight">Alerts</h1>
<p className="text-sm text-muted-foreground">Compose and send alerts to admins and users</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate("/admin/announcements/archived")}>
@@ -95,7 +81,7 @@ export default function NotificationBroadcastList() {
</Button>
<Button onClick={() => navigate("/admin/announcements/add")}>
<Plus className="size-4" />
New announcement
New Alert
</Button>
</div>
</div>
@@ -107,24 +93,6 @@ export default function NotificationBroadcastList() {
<StatCard label="Sent" value={sentCount} tone="success" />
</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 ────────────────────────────────────────────────── */}
<div className="flex items-center gap-2 flex-wrap">
<Select value={statusFilter} onValueChange={setStatusFilter}>
@@ -143,7 +111,7 @@ export default function NotificationBroadcastList() {
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search announcements..."
placeholder="Search alerts..."
className="pl-8 bg-background text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
@@ -183,8 +151,7 @@ export default function NotificationBroadcastList() {
pagination={pagination}
rowCount={broadcasts.length}
totalRecords={pagination?.totalRecords}
recordLabel="notification"
pageSizeOptions={[12, 24, 48, 96]}
recordLabel="alert"
onPageChange={(page) => fetchBroadcasts({ page, limit, filters: buildFilters() })}
onPageSizeChange={(size) => setLimit(size)}
/>
@@ -193,65 +160,12 @@ export default function NotificationBroadcastList() {
)}
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => {
updateStickyBannerSetting({ image_asset_id: asset.asset_id, updatedBy: user?.user_id ?? null });
}}
/>
</section>
);
}
// ─── 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" }) {
const toneClass = {
default: "text-foreground",
@@ -290,7 +204,7 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
{targetText}
</span>
</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>
{broadcast.sent_at && (
<p className="text-xs text-muted-foreground mt-1.5">
@@ -308,10 +222,10 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this notification?</AlertDialogTitle>
<AlertDialogHeader>
<AlertDialogTitle>Send this alert?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -332,9 +246,9 @@ function BroadcastCard({ broadcast, onView, onEdit, onSend, onArchive }) {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive this notification?</AlertDialogTitle>
<AlertDialogTitle>Archive this alert?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<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">
<Megaphone className="size-8 text-muted-foreground" />
<div>
<p className="font-medium">No announcements yet</p>
<p className="text-sm text-muted-foreground">Compose your first announcement to admins or users.</p>
<p className="font-medium">No alerts yet</p>
<p className="text-sm text-muted-foreground">Compose your first alert to admins or users.</p>
</div>
<Button onClick={onCreate}>
<Plus className="size-4" />
New announcement
New Alert
</Button>
</div>
);
@@ -121,7 +121,7 @@ export default function ViewNotificationBroadcast() {
if (!broadcast) {
return (
<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>
);
}
@@ -156,7 +156,7 @@ export default function ViewNotificationBroadcast() {
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold leading-tight flex items-center gap-2">
<Megaphone className="h-5 w-5 text-muted-foreground" />
{broadcast.title || "Untitled announcement"}
{broadcast.title || "Untitled alert"}
</h1>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant={broadcast.status === "sent" ? "default" : "secondary"}>
@@ -180,9 +180,9 @@ export default function ViewNotificationBroadcast() {
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send this announcement?</AlertDialogTitle>
<AlertDialogTitle>Send this alert?</AlertDialogTitle>
<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>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -182,7 +182,7 @@ export default function CreateTaskList() {
{step === 0 && (
<div className="space-y-4">
<div className="space-y-3">
<Label htmlFor="name">Name *</Label>
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input
id="name"
value={form.name}
@@ -210,21 +210,22 @@ export default function CreateTaskList() {
{/* ── Step 2: Assign Groups ── */}
{step === 1 && (
<div className="space-y-3">
<Label>
Assign to Groups
<span className="ml-1.5 text-xs text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Label>Assign to Groups <span className="text-destructive">*</span></Label>
<GroupMultiSelect
value={selectedGroupIds}
onChange={setSelectedGroupIds}
disabled={loading}
placeholder="Select groups to assign…"
/>
<p className="text-xs text-muted-foreground">
Members of selected groups will be able to see and complete this task list.
</p>
{selectedGroupIds.length === 0 ? (
<p className="text-xs text-destructive">
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>
)}
@@ -309,7 +310,11 @@ export default function CreateTaskList() {
</Button>
{step < STEPS.length - 1 ? (
<Button type="button" onClick={handleNext}>
<Button
type="button"
onClick={handleNext}
disabled={step === 1 && selectedGroupIds.length === 0}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
@@ -121,7 +121,7 @@ export default function EditTaskList() {
{/* Name */}
<div className="space-y-1">
<Label htmlFor="name">Name *</Label>
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input
id="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-1">
<Label htmlFor="queuedTaskName">Name *</Label>
<Label htmlFor="queuedTaskName">Name <span className="text-destructive">*</span></Label>
<Input
id="queuedTaskName"
value={draft.name}
@@ -181,7 +181,7 @@ export default function CreateTask() {
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">Name *</Label>
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input
id="name"
value={form.name}
@@ -167,7 +167,7 @@ export default function EditTask() {
<CardHeader><CardTitle className="text-base">Task Details</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name">Name *</Label>
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input
id="name"
value={form.name}
@@ -19,14 +19,18 @@ import api from '@/utils/api.util';
const REQUIREMENT_TYPES = [
{ value: 'visit_link', label: 'Visit a Link', icon: Link, 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_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, 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 = [
{ value: 'pdf', label: 'PDF' },
@@ -304,6 +308,14 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
</span>
</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>
</Select>
@@ -322,7 +334,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
{item.type === 'visit_link' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div className="space-y-1">
<Label className="text-xs">URL *</Label>
<Label className="text-xs">URL <span className="text-destructive">*</span></Label>
<Input
placeholder="https://example.com"
value={item.link_url}
+3 -2
View File
@@ -68,7 +68,6 @@ import ViewLibraryUnit from '../pages/library/units/ViewLibraryUnit'
import ArchivedUnitLibraryList from '../pages/library/units/ArchivedUnitLibraryList'
import LessonLibraryList from '../pages/library/lessons/LessonLibraryList'
import AddLibraryLesson from '../pages/library/lessons/AddLibraryLesson'
import ImportLibraryLesson from '../pages/library/lessons/ImportLibraryLesson'
import EditLibraryLesson from '../pages/library/lessons/EditLibraryLesson'
import ViewLibraryLesson from '../pages/library/lessons/ViewLibraryLesson'
import ArchivedLessonLibraryList from '../pages/library/lessons/ArchivedLessonLibraryList'
@@ -92,6 +91,7 @@ import ViewAudioAsset from '../pages/assets/ViewAudioAsset'
// Categories
import CategoryList from '../pages/categories/CategoryList';
import ArchivedCategoryList from '../pages/categories/ArchivedCategoryList';
import AddCategory from '../pages/categories/AddCategory';
import EditCategory from '../pages/categories/EditCategory';
@@ -203,6 +203,7 @@ export const AdminRoutes = {
element: <Outlet />,
children: [
{ index: true, element: <CategoryList /> },
{ path: 'archived', element: <ArchivedCategoryList /> },
{ path: 'add', element: <AddCategory /> },
{ path: ':id/edit', element: <EditCategory /> },
]
@@ -264,7 +265,6 @@ export const AdminRoutes = {
children: [
{ index: true, element: <LessonLibraryList /> },
{ path: 'add', element: <AddLibraryLesson /> },
{ path: 'import', element: <ImportLibraryLesson /> },
{ path: 'archived', element: <ArchivedLessonLibraryList /> },
{ path: ':lessonId/view', element: <ViewLibraryLesson /> },
{ path: ':lessonId/edit', element: <EditLibraryLesson /> },
@@ -373,6 +373,7 @@ export const AdminRoutes = {
{ path: ':broadcastId/edit', element: <EditNotificationBroadcast /> },
]
},
// Backwards-compatible aliases (keep old URLs working)
{
path: 'notifications',