diff --git a/src/components/generic/AddBlockMenu.jsx b/src/components/generic/AddBlockMenu.jsx index 26bd37e..8fce213 100644 --- a/src/components/generic/AddBlockMenu.jsx +++ b/src/components/generic/AddBlockMenu.jsx @@ -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: , }, - { - type: "document", - label: "Document", - description: "Upload a PDF/PPTX only, auto-convert to Markdown", - icon: , - }, ]; export function AddBlockMenu({ onAdd }) { diff --git a/src/components/generic/AdminStickyAnnouncementBar.jsx b/src/components/generic/AdminStickyAnnouncementBar.jsx index 419ac61..b90906d 100644 --- a/src/components/generic/AdminStickyAnnouncementBar.jsx +++ b/src/components/generic/AdminStickyAnnouncementBar.jsx @@ -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() { )} - {count > 1 && ( -
- {stickyAnnouncements.map((a, i) => ( -
- )} - - {/* 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 && ( -
{ - 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 }} - > - -
- )} + {/* Only way to dismiss a sticky alert — closing the details dialog + no longer dismisses it. */} +
{ + 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 }} + > + +
- ); diff --git a/src/components/generic/AnnouncementCarouselDialog.jsx b/src/components/generic/AnnouncementCarouselDialog.jsx deleted file mode 100644 index 63921d0..0000000 --- a/src/components/generic/AnnouncementCarouselDialog.jsx +++ /dev/null @@ -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 ( - - -
-
- - - {current.title || "Announcement"} - - -

- {current.message || ""} -

-
-
- -
- {clickAction && ( - - )} - - {announcements.length > 1 && ( -
- {announcements.map((a, i) => ( -
- )} -
-
- -
- {imageSrc ? ( - - ) : ( - No image - )} -
-
-
-
- ); -} diff --git a/src/components/generic/AnnouncementDetailsDialog.jsx b/src/components/generic/AnnouncementDetailsDialog.jsx new file mode 100644 index 0000000..ed0b41f --- /dev/null +++ b/src/components/generic/AnnouncementDetailsDialog.jsx @@ -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 ( + + +
+
+ + + {announcement.title || "Alert"} + + +

+ {announcement.message || ""} +

+
+
+ + {clickAction && ( +
+ +
+ )} +
+ + {imageSrc && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index e3908f6..25e776a 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -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 ( - - )} - - )} - - {!readOnly && ( - - )} - - ); - } - - // ── Converting ────────────────────────────────────────────────────────── - if (phase) { - return ( -
- -
-

{pendingAsset?.display_name}

- -
-
- ); - } - - // ── Draft review ──────────────────────────────────────────────────────── - if (draft) { - return ( -
- -
-
- - Converted from {draft.asset.display_name} — review before inserting - - -
- - {draft.warnings.length > 0 && ( -
- {draft.warnings.map((w, i) => ( - - {w} - - ))} -
- )} - -
- {preview ? ( -
- {draft.markdown} -
- ) : ( -
{draft.markdown}
- )} -
- -
- - -
-
-
- ); - } - - // ── Empty: pick a file ───────────────────────────────────────────────── - return ( -
- {!readOnly && } - - - {!readOnly && ( - - )} -
- ); -} diff --git a/src/components/generic/Blocks/Client/DocumentBlock.jsx b/src/components/generic/Blocks/Client/DocumentBlock.jsx deleted file mode 100644 index f1e593b..0000000 --- a/src/components/generic/Blocks/Client/DocumentBlock.jsx +++ /dev/null @@ -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 ; -} diff --git a/src/components/generic/Dialogs/DemoteAdminDialog.jsx b/src/components/generic/Dialogs/DemoteAdminDialog.jsx new file mode 100644 index 0000000..deb6ce5 --- /dev/null +++ b/src/components/generic/Dialogs/DemoteAdminDialog.jsx @@ -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 ( + + + + Demote Administrator + + Are you sure you want to remove Administrator access from{" "} + {displayName}? + Their account will be returned to a standard User role, and they will + be notified by email. + + + + Cancel + + {loading && } + Demote + + + + + ); +} diff --git a/src/components/generic/Dialogs/MakeAdminDialog.jsx b/src/components/generic/Dialogs/MakeAdminDialog.jsx new file mode 100644 index 0000000..9f88de3 --- /dev/null +++ b/src/components/generic/Dialogs/MakeAdminDialog.jsx @@ -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 ( + + + + Make Administrator + + Are you sure you want to grant{" "} + {displayName}{" "} + Administrator access? They will gain full access to all administrative + features, and will be notified by email. + + + + Cancel + + {loading && } + Make Admin + + + + + ); +} diff --git a/src/components/generic/RequirementBuilder.jsx b/src/components/generic/RequirementBuilder.jsx index 71693b3..817db37 100644 --- a/src/components/generic/RequirementBuilder.jsx +++ b/src/components/generic/RequirementBuilder.jsx @@ -135,7 +135,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [], {item.type === 'visit_link' && (
- + { - 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() { )}
- {count > 1 && ( -
- {stickyAnnouncements.map((a, i) => ( -
- )} - - {/* 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 && ( -
{ - 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 }} - > - -
- )} + {/* Only way to dismiss a sticky alert — closing the details dialog + no longer dismisses it. */} +
{ + 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 }} + > + +
- ); diff --git a/src/contexts/AdminAdvertisementContext.jsx b/src/contexts/AdminAdvertisementContext.jsx index 1a6bbb1..8528d92 100644 --- a/src/contexts/AdminAdvertisementContext.jsx +++ b/src/contexts/AdminAdvertisementContext.jsx @@ -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, diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index bba5c25..536bedd 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -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, diff --git a/src/contexts/AdminCategoriesContext.jsx b/src/contexts/AdminCategoriesContext.jsx index cd4b88b..ecb110d 100644 --- a/src/contexts/AdminCategoriesContext.jsx +++ b/src/contexts/AdminCategoriesContext.jsx @@ -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 ( {children} diff --git a/src/contexts/AdminNotificationBroadcastContext.jsx b/src/contexts/AdminNotificationBroadcastContext.jsx index 0d791ed..8c0eb80 100644 --- a/src/contexts/AdminNotificationBroadcastContext.jsx +++ b/src/contexts/AdminNotificationBroadcastContext.jsx @@ -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} diff --git a/src/contexts/AdminNotificationContext.jsx b/src/contexts/AdminNotificationContext.jsx index 0656906..34978f2 100644 --- a/src/contexts/AdminNotificationContext.jsx +++ b/src/contexts/AdminNotificationContext.jsx @@ -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, diff --git a/src/contexts/AdminUserContext.jsx b/src/contexts/AdminUserContext.jsx index b43bd16..a0ec457 100644 --- a/src/contexts/AdminUserContext.jsx +++ b/src/contexts/AdminUserContext.jsx @@ -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} diff --git a/src/contexts/ClientNotificationContext.jsx b/src/contexts/ClientNotificationContext.jsx index 80aa21f..bcbd1a2 100644 --- a/src/contexts/ClientNotificationContext.jsx +++ b/src/contexts/ClientNotificationContext.jsx @@ -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, diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index 2fe281d..80f1951 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -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" }, ], }, { diff --git a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx index 0704bc2..4d9ff33 100644 --- a/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx +++ b/src/modules/admin/components/advertisements/ArchivedAdvertisementsTable.jsx @@ -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 ( <> {/* ── 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} diff --git a/src/modules/admin/components/categories/ArchivedCategoriesTable.jsx b/src/modules/admin/components/categories/ArchivedCategoriesTable.jsx new file mode 100644 index 0000000..e5fe9d7 --- /dev/null +++ b/src/modules/admin/components/categories/ArchivedCategoriesTable.jsx @@ -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 ( + <> + []} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="category" + emptyMessage="No archived categories." + /> + + {/* Single permanent delete */} + !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 */} + !v && setDeleteIds(null)} + ids={deleteIds ?? []} + entityLabel="Category" + onDelete={({ ids }) => bulkPermanentlyDeleteCategories(ids)} + loading={loading} + onSuccess={handleDeleteSuccess} + /> + + ); +} diff --git a/src/modules/admin/components/categories/CategoriesTable.jsx b/src/modules/admin/components/categories/CategoriesTable.jsx new file mode 100644 index 0000000..2fc6407 --- /dev/null +++ b/src/modules/admin/components/categories/CategoriesTable.jsx @@ -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 ( + []} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="category" + emptyMessage="No categories yet. Add one to get started." + /> + ); +} diff --git a/src/modules/admin/components/courses/AchievementsBuilder.jsx b/src/modules/admin/components/courses/AchievementsBuilder.jsx index 6d550a4..cc4a220 100644 --- a/src/modules/admin/components/courses/AchievementsBuilder.jsx +++ b/src/modules/admin/components/courses/AchievementsBuilder.jsx @@ -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 ( -
-
-
-

- - Award an achievement for completing this course? -

-

- Optional — learners can earn a badge or milestone for finishing this course. -

-
-
- - -
-
-
- ); - } - return (
-

Achievements

+

Achievements

Attach an existing achievement from the registry, or define a new one from scratch.

- + +
diff --git a/src/modules/admin/components/courses/CourseReadingProgressList.jsx b/src/modules/admin/components/courses/CourseReadingProgressList.jsx index 148fa5d..69775ba 100644 --- a/src/modules/admin/components/courses/CourseReadingProgressList.jsx +++ b/src/modules/admin/components/courses/CourseReadingProgressList.jsx @@ -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 ( +

+ + {parts.join(' · ')} +

+ ); +} + 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 }) {
)} + {entry && } + {/* ── Progress bar ── */} {entry && ( @@ -197,6 +220,7 @@ function UserCard({ entry, onOpen }) {

{entry.user.email}

+

Last seen {lastSeen}

@@ -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 }) { {/* ── Search ── */} -
- - setSearch(e.target.value.slice(0, 50))} - maxLength={50} - className="bg-background pl-9 pr-16" - /> - = 50 ? 'text-destructive' : 'text-muted-foreground'}`}> - {search.length}/50 - -
+
+
+ + setSearchInput(e.target.value.slice(0, 50))} + maxLength={50} + className="bg-background pl-9 pr-16" + /> + = 50 ? 'text-destructive' : 'text-muted-foreground'}`}> + {searchInput.length}/50 + +
+ +
{/* ── List ── */} {filtered.length === 0 ? ( -

No results for "{search}".

+

No results for "{query}".

) : (
{paginated.map((entry) => ( diff --git a/src/modules/admin/components/courses/CourseTable.jsx b/src/modules/admin/components/courses/CourseTable.jsx index 1eb798f..2cdad68 100644 --- a/src/modules/admin/components/courses/CourseTable.jsx +++ b/src/modules/admin/components/courses/CourseTable.jsx @@ -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 = () => { diff --git a/src/modules/admin/components/courses/LessonsPreview.jsx b/src/modules/admin/components/courses/LessonsPreview.jsx index 203f1f9..587e22b 100644 --- a/src/modules/admin/components/courses/LessonsPreview.jsx +++ b/src/modules/admin/components/courses/LessonsPreview.jsx @@ -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 ; case "markdown": return ; - case "document": - return ; default: return null; } diff --git a/src/modules/admin/components/courses/RoadmapBuilder.jsx b/src/modules/admin/components/courses/RoadmapBuilder.jsx index 7c2d7c0..39ab2ed 100644 --- a/src/modules/admin/components/courses/RoadmapBuilder.jsx +++ b/src/modules/admin/components/courses/RoadmapBuilder.jsx @@ -117,12 +117,13 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {

- + +
@@ -130,7 +131,7 @@ export default function RoadmapBuilder({ units, onUnitsChange }) {

No units yet

- +
) : (
@@ -226,7 +227,7 @@ export default function RoadmapBuilder({ units, onUnitsChange }) { {units.length === 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.
)} diff --git a/src/modules/admin/components/library/AttachLessonsDialog.jsx b/src/modules/admin/components/library/AttachLessonsDialog.jsx index b9d4bb5..3afd236 100644 --- a/src/modules/admin/components/library/AttachLessonsDialog.jsx +++ b/src/modules/admin/components/library/AttachLessonsDialog.jsx @@ -58,7 +58,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson - Attach Existing Lessons + Select Lessons 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)} />
-

{l.title}

+

{l.title}

{formatDuration(l.duration_seconds ?? 0)}

@@ -120,7 +120,7 @@ export default function AttachLessonsDialog({ open, onOpenChange, attachedLesson diff --git a/src/modules/admin/components/library/AttachUnitsDialog.jsx b/src/modules/admin/components/library/AttachUnitsDialog.jsx index 59083aa..762630a 100644 --- a/src/modules/admin/components/library/AttachUnitsDialog.jsx +++ b/src/modules/admin/components/library/AttachUnitsDialog.jsx @@ -63,7 +63,7 @@ export default function AttachUnitsDialog({ open, onOpenChange, attachedUnitIds - Attach Existing Units + Select Units 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)} />
-

{u.title}

+

+ {u.title} +

{u.lesson_count ?? 0} lesson{(u.lesson_count ?? 0) === 1 ? "" : "s"} · {formatDuration(u.duration_seconds ?? 0)}

diff --git a/src/modules/admin/components/library/LessonLibraryTable.jsx b/src/modules/admin/components/library/LessonLibraryTable.jsx index 42a1ed9..259ea90 100644 --- a/src/modules/admin/components/library/LessonLibraryTable.jsx +++ b/src/modules/admin/components/library/LessonLibraryTable.jsx @@ -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 = () => { diff --git a/src/modules/admin/components/library/UnitLibraryTable.jsx b/src/modules/admin/components/library/UnitLibraryTable.jsx index a7c1418..e9a1383 100644 --- a/src/modules/admin/components/library/UnitLibraryTable.jsx +++ b/src/modules/admin/components/library/UnitLibraryTable.jsx @@ -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 = () => { diff --git a/src/modules/admin/components/notifications/AlertLayoutPreview.jsx b/src/modules/admin/components/notifications/AlertLayoutPreview.jsx new file mode 100644 index 0000000..1917548 --- /dev/null +++ b/src/modules/admin/components/notifications/AlertLayoutPreview.jsx @@ -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 ( +
+
+
+

{title || "Alert"}

+

+ {message || "Your message will appear here."} +

+ {linkLabel && ( + + {linkLabel} + + )} +
+ + {imageSrc && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx b/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx index 611589e..06b441a 100644 --- a/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx +++ b/src/modules/admin/components/notifications/ArchivedNotificationBroadcastsTable.jsx @@ -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 ( <> {/* ── 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} diff --git a/src/modules/admin/components/users/UserTable.jsx b/src/modules/admin/components/users/UserTable.jsx index 9460a1b..2489da8 100644 --- a/src/modules/admin/components/users/UserTable.jsx +++ b/src/modules/admin/components/users/UserTable.jsx @@ -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 */} + !v && setMakeAdminTarget(null)} + entity={makeAdminTarget} + onMakeAdmin={(u) => makeAdmin(u?.user_id)} + loading={loading} + onSuccess={handleMakeAdminSuccess} + /> + + {/* Demote admin */} + !v && setDemoteAdminTarget(null)} + entity={demoteAdminTarget} + onDemoteAdmin={(u) => demoteAdmin(u?.user_id)} + loading={loading} + onSuccess={handleDemoteAdminSuccess} + /> ); } \ No newline at end of file diff --git a/src/modules/admin/config/advertisements/archive/columns.config.jsx b/src/modules/admin/config/advertisements/archive/columns.config.jsx index dd5be08..1dfbbc8 100644 --- a/src/modules/admin/config/advertisements/archive/columns.config.jsx +++ b/src/modules/admin/config/advertisements/archive/columns.config.jsx @@ -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" }), ]; } diff --git a/src/modules/admin/config/categories/archive/rowActions.config.jsx b/src/modules/admin/config/categories/archive/rowActions.config.jsx new file mode 100644 index 0000000..ab1411a --- /dev/null +++ b/src/modules/admin/config/categories/archive/rowActions.config.jsx @@ -0,0 +1,21 @@ +import { RotateCcw, Trash2 } from "lucide-react"; + +export function buildRowActions({ onRestore, onDelete }) { + return [ + { + key: "restore", + label: "Restore", + icon: , + className: "text-emerald-600", + onClick: (row) => onRestore(row), + }, + { + key: "delete", + label: "Delete", + icon: , + className: "text-destructive focus:text-destructive", + onClick: (row) => onDelete(row), + separator: true, + }, + ]; +} diff --git a/src/modules/admin/config/categories/archive/selection.config.jsx b/src/modules/admin/config/categories/archive/selection.config.jsx new file mode 100644 index 0000000..905c1b3 --- /dev/null +++ b/src/modules/admin/config/categories/archive/selection.config.jsx @@ -0,0 +1,26 @@ +import { RotateCcw, Trash2 } from "lucide-react"; + +export function buildSelectionActions({ onRestore, onRestoreMany, onDelete, onDeleteMany }) { + return [ + { + key: "restore-selected", + label: "Restore", + icon: , + 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: , + 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); + }, + }, + ]; +} diff --git a/src/modules/admin/config/categories/archive/toolbar.config.jsx b/src/modules/admin/config/categories/archive/toolbar.config.jsx new file mode 100644 index 0000000..adce119 --- /dev/null +++ b/src/modules/admin/config/categories/archive/toolbar.config.jsx @@ -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: , + variant: "outline", + onClick: () => fetchCategories({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + archived: true, + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + { + key: "active", + type: "button", + label: "Active Categories", + icon: , + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/courses/categories"), + }, + ]; +} diff --git a/src/modules/admin/config/categories/columns.config.jsx b/src/modules/admin/config/categories/columns.config.jsx new file mode 100644 index 0000000..e9db3ff --- /dev/null +++ b/src/modules/admin/config/categories/columns.config.jsx @@ -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" }), + ]; +} diff --git a/src/modules/admin/config/categories/rowActions.config.jsx b/src/modules/admin/config/categories/rowActions.config.jsx new file mode 100644 index 0000000..bc1efad --- /dev/null +++ b/src/modules/admin/config/categories/rowActions.config.jsx @@ -0,0 +1,20 @@ +import { Pencil, Trash2 } from "lucide-react"; + +export function buildRowActions({ navigate, onArchive }) { + return [ + { + key: "edit", + label: "Edit", + icon: , + onClick: (row) => navigate(`/admin/courses/categories/${row.id}/edit`), + }, + { + key: "archive", + label: "Archive", + icon: , + className: "text-destructive", + onClick: (row) => onArchive(row), + separator: true, + }, + ]; +} diff --git a/src/modules/admin/config/categories/selection.config.jsx b/src/modules/admin/config/categories/selection.config.jsx new file mode 100644 index 0000000..29db9a9 --- /dev/null +++ b/src/modules/admin/config/categories/selection.config.jsx @@ -0,0 +1,16 @@ +import { Archive } from "lucide-react"; + +export function buildSelectionActions({ onArchive, onArchiveMany }) { + return [ + { + key: "archive-selected", + label: "Archive", + icon: , + 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); + }, + }, + ]; +} diff --git a/src/modules/admin/config/categories/toolbar.config.jsx b/src/modules/admin/config/categories/toolbar.config.jsx new file mode 100644 index 0000000..e2d215f --- /dev/null +++ b/src/modules/admin/config/categories/toolbar.config.jsx @@ -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: , + variant: "outline", + onClick: () => fetchCategories({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + { + key: "create", + type: "button", + label: "Add Category", + icon: , + variant: "default", + onClick: () => navigate("/admin/courses/categories/add"), + }, + { + key: "archived", + type: "button", + label: "Archived", + icon: , + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/courses/categories/archived"), + }, + ]; +} diff --git a/src/modules/admin/config/courses/columns.config.jsx b/src/modules/admin/config/courses/columns.config.jsx index 25a1d56..2983b0e 100644 --- a/src/modules/admin/config/courses/columns.config.jsx +++ b/src/modules/admin/config/courses/columns.config.jsx @@ -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 = {
); }, -}; + subscription: (info) => { + const slug = info.getValue(); + if (!slug) return -; + + const { label, cls } = resolveTierBadge(slug, tierMap); + const Icon = LucideIcons[tierMap[slug]?.badge_icon] ?? Tag; + return ( + + + {label} + + ); + }, + }; +} /** * 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(), diff --git a/src/modules/admin/config/library/lessons/columns.config.jsx b/src/modules/admin/config/library/lessons/columns.config.jsx index 9194c50..18c1a78 100644 --- a/src/modules/admin/config/library/lessons/columns.config.jsx +++ b/src/modules/admin/config/library/lessons/columns.config.jsx @@ -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 = { ); }, -}; + 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 -; -export function buildDataColumns(attributes, rowActions) { + const { label, cls } = resolveTierBadge(slug, tierMap); + const Icon = LucideIcons[tierMap[slug]?.badge_icon] ?? Tag; + return ( + + + {label} + + ); + }, + }; +} + +export function buildDataColumns(attributes, rowActions, tierMap = {}) { const visibleAttributes = attributes.filter((a) => !a.hidden); + const cellOverrides = buildCellOverrides(tierMap); return [ buildSelectionColumn(), diff --git a/src/modules/admin/config/library/lessons/rowActions.config.jsx b/src/modules/admin/config/library/lessons/rowActions.config.jsx index ccab2c0..c626883 100644 --- a/src/modules/admin/config/library/lessons/rowActions.config.jsx +++ b/src/modules/admin/config/library/lessons/rowActions.config.jsx @@ -20,21 +20,21 @@ export function buildRowActions({ onView, onEdit, onBuildPage, onViewPage, onArc // icon: , // onClick: (row) => onEdit(row), // }, - { - key: "build_page", - label: "Page Builder", - icon: , - className: "text-sky-700 hover:text-sky-600", - onClick: (row) => onBuildPage(row), - separator: true, - }, - { - key: "view_page", - label: "View Page", - icon: , - className: "text-sky-700 hover:text-sky-600", - onClick: (row) => onViewPage(row), - }, + // { + // key: "build_page", + // label: "Page Builder", + // icon: , + // className: "text-sky-700 hover:text-sky-600", + // onClick: (row) => onBuildPage(row), + // separator: true, + // }, + // { + // key: "view_page", + // label: "View Page", + // icon: , + // className: "text-sky-700 hover:text-sky-600", + // onClick: (row) => onViewPage(row), + // }, { key: "archive", label: "Archive", diff --git a/src/modules/admin/config/library/lessons/toolbar.config.jsx b/src/modules/admin/config/library/lessons/toolbar.config.jsx index 7dfc066..9e90bfb 100644 --- a/src/modules/admin/config/library/lessons/toolbar.config.jsx +++ b/src/modules/admin/config/library/lessons/toolbar.config.jsx @@ -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: , - 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: , - variant: "secondary", - className: "border border-border", - onClick: () => navigate("/admin/lessons"), - }, { key: "refresh", type: "button", diff --git a/src/modules/admin/config/library/units/columns.config.jsx b/src/modules/admin/config/library/units/columns.config.jsx index 09cd097..0a0fda8 100644 --- a/src/modules/admin/config/library/units/columns.config.jsx +++ b/src/modules/admin/config/library/units/columns.config.jsx @@ -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 ( -
- {inCourse && ( - - - Course +function buildCellOverrides(tierMap) { + return { + title: (info) => { + const inCourse = parseInt(info.row.original.course_count ?? 0, 10) > 0; + return ( +
+ {inCourse && ( + + + Course + + )} + + {info.getValue()} + +
+ ); + }, + duration_seconds: (info) => { + const seconds = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {formatDuration(seconds)} - )} - - {info.getValue()} - -
- ); - }, - duration_seconds: (info) => { - const seconds = parseInt(info.getValue() ?? 0, 10); - return ( -
- - - {formatDuration(seconds)} - -
- ); - }, - lesson_count: (info) => ( - - {info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"} - - ), -}; +
+ ); + }, + lesson_count: (info) => ( + + {info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"} + + ), + 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 -; -export function buildDataColumns(attributes, rowActions) { + const { label, cls } = resolveTierBadge(slug, tierMap); + const Icon = LucideIcons[tierMap[slug]?.badge_icon] ?? Tag; + return ( + + + {label} + + ); + }, + }; +} + +export function buildDataColumns(attributes, rowActions, tierMap = {}) { const visibleAttributes = attributes.filter((a) => !a.hidden); + const cellOverrides = buildCellOverrides(tierMap); return [ buildSelectionColumn(), diff --git a/src/modules/admin/config/library/units/rowActions.config.jsx b/src/modules/admin/config/library/units/rowActions.config.jsx index a1e2c3d..edcfafe 100644 --- a/src/modules/admin/config/library/units/rowActions.config.jsx +++ b/src/modules/admin/config/library/units/rowActions.config.jsx @@ -14,40 +14,40 @@ export function buildRowActions({ onView, onEdit, onManageLessons, onArchive, on icon: , onClick: (row) => onView(row), }, - { - key: "manage_lessons", - label: "Manage Lessons", - icon: , - className: "text-sky-700 hover:text-sky-600", - onClick: (row) => onManageLessons(row), - separator: true, - }, - { - key: "create_quiz", - label: "Create Quiz", - icon: , - 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: , - 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: , - 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: , + // className: "text-sky-700 hover:text-sky-600", + // onClick: (row) => onManageLessons(row), + // separator: true, + // }, + // { + // key: "create_quiz", + // label: "Create Quiz", + // icon: , + // 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: , + // 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: , + // className: "text-purple-700 hover:text-purple-600", + // onClick: (row) => onQuiz(row), + // hidden: (row) => !(row.quiz_id || row.quiz), + // }, { key: "archive", label: "Archive", diff --git a/src/modules/admin/config/users/rowActions.config.jsx b/src/modules/admin/config/users/rowActions.config.jsx index 3134499..d072bb2 100644 --- a/src/modules/admin/config/users/rowActions.config.jsx +++ b/src/modules/admin/config/users/rowActions.config.jsx @@ -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: , onClick: (row) => navigate(`view/${row.user_id}`), }, + { + key: "make-admin", + label: "Make Admin", + icon: , + onClick: (row) => onMakeAdmin(row), + hidden: (row) => row.acc_type === "admin" || row.user_id === currentUserId, + separator: true, + }, + { + key: "demote-admin", + label: "Demote", + icon: , + onClick: (row) => onDemoteAdmin(row), + hidden: (row) => row.acc_type !== "admin" || row.user_id === currentUserId, + }, { key: "ban", label: "Ban User", diff --git a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx index 5cd6aa9..509ae1c 100644 --- a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx @@ -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({
+ {contentMode === "image" && ( +
+ + +

+ Internal label shown in the Ads list — not displayed on the ad itself. +

+
+ )} + {contentMode === "content" && (
- +
@@ -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 }) {
-
-
- - -
+
+
- + {isActive ? "Active" : "Draft"} setValue("is_active", v)} @@ -495,7 +501,6 @@ function StepReview({ data, selectedAsset, imageUrl }) {

Scheduling & display

-
@@ -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: , 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" && ( - + )} {current.id === "review" && ( diff --git a/src/modules/admin/pages/advertisements/AdvertisementList.jsx b/src/modules/admin/pages/advertisements/AdvertisementList.jsx index a8dd568..a3aa0c4 100644 --- a/src/modules/admin/pages/advertisements/AdvertisementList.jsx +++ b/src/modules/admin/pages/advertisements/AdvertisementList.jsx @@ -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: , 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 (
@@ -80,7 +99,7 @@ export default function AdvertisementList() { {/* ── Header ─────────────────────────────────────────────────── */}
-

Advertisements

+

Ads

Manage public-facing hero and banner placements

@@ -90,7 +109,7 @@ export default function AdvertisementList() {
@@ -132,7 +151,7 @@ export default function AdvertisementList() {
setSearchInput(e.target.value)} @@ -154,15 +173,27 @@ export default function AdvertisementList() { navigate("/admin/advertisements/add")} /> ) : ( <> -
- {advertisements.map((ad) => ( - navigate(`/admin/advertisements/${ad.advertisement_id}/view`)} - onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)} - onArchive={() => handleArchive(ad.advertisement_id)} - /> +
+ {groups.map((group) => ( +
+

{group.heading}

+
+ {group.ads.map((ad, index) => ( + 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")} + /> + ))} +
+
))}
@@ -172,7 +203,7 @@ export default function AdvertisementList() { onPageChange={setPage} onPageSizeChange={handlePageSizeChange} rowCount={advertisements.length} - recordLabel="advertisement" + recordLabel="ad" />
@@ -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 ? ( {ad.headline @@ -240,7 +271,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
+ @@ -266,9 +303,9 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) { - Archive this advertisement? + Archive this ad? - "{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. @@ -291,12 +328,12 @@ function EmptyState({ onCreate }) {
-

No advertisements yet

+

No ads yet

Create your first hero or banner placement.

); diff --git a/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx b/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx index 9753adb..896c852 100644 --- a/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx +++ b/src/modules/admin/pages/advertisements/ArchivedAdvertisementList.jsx @@ -6,7 +6,7 @@ import ArchivedAdvertisementsTable from "../../components/advertisements/Archive export default function ArchivedAdvertisementList() { const items = [ { label: "Home", icon: , to: `/admin` }, - { label: "Advertisements", to: `/admin/advertisements` }, + { label: "Ads", to: `/admin/advertisements` }, { label: "Archived" }, ]; diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx index beee98c..f5e8c6c 100644 --- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx @@ -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: , 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() { ))}
+ {contentMode === "image" && ( +
+ + +

+ Internal label shown in the Ads list — not displayed on the ad itself. +

+
+ )} + {contentMode === "content" && ( <>
- +
@@ -458,14 +465,11 @@ export default function EditAdvertisement() {
- -
-
- - -
+ +
+
- + {watch("is_active") ? "Active" : "Draft"} setValue("is_active", v, { shouldDirty: true })} diff --git a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx index fa9546e..17d1a21 100644 --- a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx @@ -60,7 +60,7 @@ export default function ViewAdvertisement() { const breadcrumbItems = [ { label: "Home", icon: , to: "/admin" }, - { label: "Advertisements", to: "/admin/advertisements" }, + { label: "Ads", to: "/admin/advertisements" }, { label: "View" }, ]; @@ -81,7 +81,7 @@ export default function ViewAdvertisement() {
-

Advertisement not found.

+

Ad not found.

); @@ -112,7 +112,7 @@ export default function ViewAdvertisement() {

- {advertisement.headline || advertisement.badge_label || "Untitled advertisement"} + {advertisement.headline || advertisement.badge_label || "Untitled ad"}

diff --git a/src/modules/admin/pages/categories/ArchivedCategoryList.jsx b/src/modules/admin/pages/categories/ArchivedCategoryList.jsx new file mode 100644 index 0000000..70f53e0 --- /dev/null +++ b/src/modules/admin/pages/categories/ArchivedCategoryList.jsx @@ -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: , to: "/admin" }, + { label: "Categories", to: "/admin/courses/categories" }, + { label: "Archived" }, +]; + +export default function ArchivedCategoryList() { + return ( +
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/modules/admin/pages/categories/CategoryList.jsx b/src/modules/admin/pages/categories/CategoryList.jsx index e29a672..7012da1 100644 --- a/src/modules/admin/pages/categories/CategoryList.jsx +++ b/src/modules/admin/pages/categories/CategoryList.jsx @@ -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: , 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 (
-
- -
-
-

Categories

-

Manage course categories for browsing and filtering.

-
-
- - -
+
+
- -
- {loading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
- ) : categories.length === 0 ? ( -
- -

- {showArchived ? "No archived categories." : "No categories yet. Add one to get started."} -

-
- ) : ( - - - - - - - - - - {categories.map((cat) => ( - - - - - - - ))} - -
NameSlugStatus -
{cat.name}{cat.slug} - - {cat.is_active ? "Active" : "Inactive"} - - -
- {!showArchived ? ( - <> - - - - ) : ( - - )} -
-
- )} -
-
); diff --git a/src/modules/admin/pages/courses/AddCourse.jsx b/src/modules/admin/pages/courses/AddCourse.jsx index 37bb8d4..4e5334a 100644 --- a/src/modules/admin/pages/courses/AddCourse.jsx +++ b/src/modules/admin/pages/courses/AddCourse.jsx @@ -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() { ) : currentStep < STEPS.length - 1 ? ( - diff --git a/src/modules/admin/pages/courses/lessons/LessonPageBuilder.jsx b/src/modules/admin/pages/courses/lessons/LessonPageBuilder.jsx index 0f64dc9..cfaaf0d 100644 --- a/src/modules/admin/pages/courses/lessons/LessonPageBuilder.jsx +++ b/src/modules/admin/pages/courses/lessons/LessonPageBuilder.jsx @@ -130,7 +130,7 @@ export default function LessonPageBuilder() { >
-
@@ -158,7 +158,7 @@ export default function LessonPageBuilder() { Preview -
diff --git a/src/modules/admin/pages/library/lessons/ImportLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/ImportLibraryLesson.jsx deleted file mode 100644 index bcff293..0000000 --- a/src/modules/admin/pages/library/lessons/ImportLibraryLesson.jsx +++ /dev/null @@ -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

{message}

; -} - -function StageProgress({ phase }) { - const activeIndex = STAGES.findIndex((s) => s.phase === phase); - return ( -
- {STAGES.map((s, i) => { - const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending"; - return ( -
- {state === "done" ? ( - - ) : state === "active" ? ( - - ) : ( - - )} - {s.label}… -
- ); - })} -
- ); -} - -// ─── Step 1 — Import ──────────────────────────────────────────────────────────── -function StepImport({ register, errors, selectedAsset, onPick, fileMissing }) { - const [pickerOpen, setPickerOpen] = useState(false); - - return ( -
-
- - - -
- -
- -