mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
updated some of things
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// components/generic/CMS/AddBlockMenu.jsx
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+10
-10
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user