From 7e964f24327cb27f33384b2484d006ee7c690b6a Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Fri, 3 Jul 2026 16:21:27 +0800 Subject: [PATCH] add: more commits Signed-off-by: Kenneth Obsequio --- public/media-fallback-patient-tv.svg | 23 + src/App.jsx | 15 +- src/components/generic/AssetPickerSheet.jsx | 85 +- .../generic/Blocks/Admin/AudioBlock.jsx | 5 +- .../Blocks/Client/Advertisements/Banner.jsx | 20 +- .../Blocks/Client/Advertisements/Hero.jsx | 15 +- .../Blocks/Client/Advertisements/Popup.jsx | 31 +- .../Blocks/Client/Advertisements/Sidebar.jsx | 9 +- .../generic/Blocks/Client/AudioBlock.jsx | 7 +- .../generic/Blocks/Client/VideoBlock.jsx | 7 +- .../generic/BroadcastTargetPicker.jsx | 124 ++ .../generic/ClientNotificationBell.jsx | 200 +-- .../generic/Dialogs/ArchiveDialog.jsx | 83 +- src/components/generic/MarkdownCheatsheet.jsx | 31 + src/components/generic/MediaFallback.jsx | 20 + .../generic/NotificationDetailDialog.jsx | 96 ++ src/components/generic/PlacementSkeleton.jsx | 142 ++ .../generic/SendEmailBroadcastDialog.jsx | 98 ++ src/components/generic/UserMenu.jsx | 2 +- .../generic/notificationDisplay.jsx | 103 ++ src/contexts/AdminAchievementsContext.jsx | 76 + src/contexts/AdminAssetsContext.jsx | 106 +- src/contexts/AdminCoursesContext.jsx | 44 +- src/contexts/AdminEmailBroadcastContext.jsx | 74 + src/contexts/AdminEmailTemplateContext.jsx | 76 + .../AdminNotificationBroadcastContext.jsx | 221 +++ src/contexts/ClientAdvertisementContext.jsx | 211 ++- src/contexts/ClientNotificationContext.jsx | 24 +- src/contexts/ClientTiersProvider.jsx | 9 +- src/contexts/CurrencyPreferenceContext.jsx | 28 - src/contexts/ProfileProvider.jsx | 5 +- src/contexts/provider/AdminProvider.jsx | 33 +- src/data/adminTiles.data.js | 5 +- src/data/cronPresets.data.js | 24 + src/data/emailBroadcastStatus.data.js | 10 + src/data/emailTemplateCategories.data.js | 46 + src/data/emailTemplatePlaceholders.data.js | 14 + src/data/emailTemplateStatus.data.js | 11 + src/data/notificationBroadcast.data.js | 32 + src/data/placement.data.js | 27 + src/data/placementLayouts.data.js | 75 + src/hooks/useCurrency.js | 55 - src/index.css | 68 +- .../courses/CourseReadingProgressList.jsx | 8 +- .../admin/components/courses/CourseTable.jsx | 11 + .../admin/components/courses/UnitsTable.jsx | 11 + .../admin/components/tiers/CoursePicker.jsx | 66 +- .../admin/components/tiers/CurrencyPicker.jsx | 61 + .../config/courses/rowActions.config.jsx | 25 +- .../courses/units/rowActions.config.jsx | 19 +- .../config/tiers/payments/columns.config.jsx | 17 +- .../config/tiers/plans/rowActions.config.jsx | 24 +- .../config/tiers/plans/toolbar.config.jsx | 10 +- src/modules/admin/layouts/AdminLayout.jsx | 4 +- .../admin/pages/achievements/Achievements.jsx | 172 ++ .../pages/achievements/EditAchievement.jsx | 255 +++ .../pages/advertisements/AddAdvertisement.jsx | 695 +++++--- .../advertisements/AdvertisementList.jsx | 48 +- .../advertisements/EditAdvertisement.jsx | 125 +- .../advertisements/ViewAdvertisement.jsx | 22 +- .../admin/pages/assets/ViewAudioAsset.jsx | 16 +- .../admin/pages/assets/ViewDocumentAsset.jsx | 11 +- .../admin/pages/assets/ViewImageAsset.jsx | 19 +- .../admin/pages/assets/ViewVideoAsset.jsx | 19 +- src/modules/admin/pages/courses/AddCourse.jsx | 754 +++++---- .../admin/pages/courses/CourseAssessment.jsx | 58 +- .../admin/pages/courses/EditCourse.jsx | 1391 +++++++++-------- .../admin/pages/courses/ViewAssessment.jsx | 50 +- .../admin/pages/courses/ViewCourse.jsx | 557 ++++--- .../pages/courses/lessons/LessonsList.jsx | 2 +- .../pages/courses/lessons/ViewLesson.jsx | 8 +- .../admin/pages/courses/units/ModifyQuiz.jsx | 56 +- .../admin/pages/courses/units/UnitsList.jsx | 2 +- .../email_templates/AddEmailTemplate.jsx | 367 +++++ .../email_templates/EditEmailTemplate.jsx | 297 ++++ .../pages/email_templates/EmailBroadcasts.jsx | 140 ++ .../pages/email_templates/EmailTemplates.jsx | 272 ++++ .../AddNotificationBroadcast.jsx | 182 +++ .../EditNotificationBroadcast.jsx | 202 +++ .../NotificationBroadcastList.jsx | 276 ++++ .../notifications/NotificationSettings.jsx | 162 ++ .../ViewNotificationBroadcast.jsx | 233 +++ .../admin/pages/task_list/CreateTaskList.jsx | 228 ++- .../admin/pages/task_list/task/CreateTask.jsx | 269 +++- .../admin/pages/task_list/task/EditTask.jsx | 124 +- .../task_list/task/RequirementBuilder.jsx | 313 +++- .../admin/pages/task_list/task/Tasks.jsx | 57 +- src/modules/admin/pages/tiers/AddPlan.jsx | 319 ++-- src/modules/admin/pages/tiers/EditPlan.jsx | 19 +- .../admin/pages/tiers/EditTierCategory.jsx | 2 +- .../admin/pages/tiers/LocalizedPrices.jsx | 525 ------- .../admin/pages/tiers/PaymentPolicy.jsx | 50 +- src/modules/admin/pages/tiers/PlanList.jsx | 47 - src/modules/admin/pages/tiers/ViewPlan.jsx | 679 ++++++-- src/modules/admin/routes/AdminRoutes.jsx | 59 +- .../components/blocks/CourseCompleteBlock.jsx | 45 +- .../client/components/blocks/ReadCourse.jsx | 33 +- .../client/components/blocks/ReadLesson.jsx | 14 +- .../client/components/blocks/ReadUnit.jsx | 26 +- src/modules/client/pages/AccountSettings.jsx | 166 +- src/modules/client/pages/Checkout.jsx | 51 +- src/modules/client/pages/CourseDetails.jsx | 99 +- src/modules/client/pages/CourseList.jsx | 14 + src/modules/client/pages/Dashboard.jsx | 39 +- src/modules/client/pages/MyAchievements.jsx | 20 +- src/modules/client/pages/Notifications.jsx | 283 ++++ src/modules/client/pages/PlanList.jsx | 37 +- src/modules/client/pages/ViewPlan.jsx | 15 +- src/modules/client/routes/ClientRoutes.jsx | 4 +- src/utils/achievements.data.js | 14 - src/utils/markdownToHtml.util.js | 11 + src/utils/media.util.js | 22 + 112 files changed, 9160 insertions(+), 3461 deletions(-) create mode 100644 public/media-fallback-patient-tv.svg create mode 100644 src/components/generic/BroadcastTargetPicker.jsx create mode 100644 src/components/generic/MarkdownCheatsheet.jsx create mode 100644 src/components/generic/MediaFallback.jsx create mode 100644 src/components/generic/NotificationDetailDialog.jsx create mode 100644 src/components/generic/PlacementSkeleton.jsx create mode 100644 src/components/generic/SendEmailBroadcastDialog.jsx create mode 100644 src/components/generic/notificationDisplay.jsx create mode 100644 src/contexts/AdminAchievementsContext.jsx create mode 100644 src/contexts/AdminEmailBroadcastContext.jsx create mode 100644 src/contexts/AdminEmailTemplateContext.jsx create mode 100644 src/contexts/AdminNotificationBroadcastContext.jsx delete mode 100644 src/contexts/CurrencyPreferenceContext.jsx create mode 100644 src/data/cronPresets.data.js create mode 100644 src/data/emailBroadcastStatus.data.js create mode 100644 src/data/emailTemplateCategories.data.js create mode 100644 src/data/emailTemplatePlaceholders.data.js create mode 100644 src/data/emailTemplateStatus.data.js create mode 100644 src/data/notificationBroadcast.data.js create mode 100644 src/data/placement.data.js create mode 100644 src/data/placementLayouts.data.js delete mode 100644 src/hooks/useCurrency.js create mode 100644 src/modules/admin/components/tiers/CurrencyPicker.jsx create mode 100644 src/modules/admin/pages/achievements/Achievements.jsx create mode 100644 src/modules/admin/pages/achievements/EditAchievement.jsx create mode 100644 src/modules/admin/pages/email_templates/AddEmailTemplate.jsx create mode 100644 src/modules/admin/pages/email_templates/EditEmailTemplate.jsx create mode 100644 src/modules/admin/pages/email_templates/EmailBroadcasts.jsx create mode 100644 src/modules/admin/pages/email_templates/EmailTemplates.jsx create mode 100644 src/modules/admin/pages/notifications/AddNotificationBroadcast.jsx create mode 100644 src/modules/admin/pages/notifications/EditNotificationBroadcast.jsx create mode 100644 src/modules/admin/pages/notifications/NotificationBroadcastList.jsx create mode 100644 src/modules/admin/pages/notifications/NotificationSettings.jsx create mode 100644 src/modules/admin/pages/notifications/ViewNotificationBroadcast.jsx delete mode 100644 src/modules/admin/pages/tiers/LocalizedPrices.jsx create mode 100644 src/modules/client/pages/Notifications.jsx delete mode 100644 src/utils/achievements.data.js create mode 100644 src/utils/markdownToHtml.util.js create mode 100644 src/utils/media.util.js diff --git a/public/media-fallback-patient-tv.svg b/public/media-fallback-patient-tv.svg new file mode 100644 index 0000000..68a64e9 --- /dev/null +++ b/public/media-fallback-patient-tv.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + Media is currently loading... + If media did not load correctly just reload a page again.. + \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index ed05867..8942580 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext'; -import { CurrencyPreferenceProvider } from './contexts/CurrencyPreferenceContext'; import { Helmet, HelmetProvider } from "react-helmet-async"; import { TooltipProvider } from './components/ui/tooltip'; import { setAuthInterceptor } from './utils/api.util'; @@ -82,15 +81,13 @@ export default function App() { - + - - - - - - - + + + + + diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index 4daf3ed..5d51545 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -9,7 +9,6 @@ import { Spinner } from "@/components/ui/spinner"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAssets } from "@/contexts/AdminAssetsContext"; -import api from "@/utils/api.util"; const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`; const DEBOUNCE_MS = 400; @@ -73,23 +72,34 @@ function EmptyState({ fileType }) { // ─── Main Sheet ─────────────────────────────────────────────────────────────── export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { - if (!open) return null; + // NOTE: previously this returned null before any hooks ran when `open` was + // false. Since the parent renders this component unconditionally (only the + // `open` prop toggles), that meant React remounted every hook from scratch + // on each open — wiping local state and forcing a full refetch every time, + // plus skipping the close transition. Visibility is controlled by + // below instead, so state (and the caches in + // AdminAssetsContext) survive across open/close toggles. - const { fetchAssets, assets, pagination, loading } = useAssets(); + const { fetchAssets, assets, pagination, loading, mediaTokens, getMediaTokens } = useAssets(); const [search, setSearch] = useState(""); const [activeExts, setActiveExts] = useState(new Set()); const [page, setPage] = useState(1); const [selected, setSelected] = useState(null); const [filterOpen, setFilterOpen] = useState(false); - // { [asset_id]: streamUrl } — resolved once per asset list via batch token request - const [streamUrls, setStreamUrls] = useState({}); const debounceRef = useRef(null); + const isFirstSearchRun = useRef(true); const LIMIT = 12; const extOptions = EXT_OPTIONS[fileType] ?? []; + const resolveStreamSrc = useCallback((assetId) => { + const entry = mediaTokens[String(assetId)]; + if (!entry) return null; + return entry.thumbnail_url ?? `${STREAM_BASE}/${entry.token}`; + }, [mediaTokens]); + // ── Build and fire fetch ────────────────────────────────────────────────── const doFetch = useCallback((searchVal, extSet, pg) => { const filters = [ @@ -100,8 +110,19 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { fetchAssets({ page: pg, limit: LIMIT, filters }); }, [fileType, fetchAssets]); - // ── Auto-search: debounce on search input change ────────────────────────── + // ── Immediate fetch: on open, or when filters/page change while open ────── + // (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening + // with the same query within the cache window costs no network round-trip.) useEffect(() => { + if (!open) return; + doFetch(search, activeExts, page); + }, [open, activeExts, page]); + + // ── Debounced fetch: only when the user edits the search box ────────────── + // Deliberately does NOT depend on `open` — otherwise this and the effect + // above both fire on every sheet open, doubling the request. + useEffect(() => { + if (isFirstSearchRun.current) { isFirstSearchRun.current = false; return; } if (!open) return; clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { @@ -109,51 +130,26 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { doFetch(search, activeExts, 1); }, DEBOUNCE_MS); return () => clearTimeout(debounceRef.current); - }, [search, open]); - - // ── Immediate fetch on ext filter or page change ────────────────────────── - useEffect(() => { - if (!open) return; - doFetch(search, activeExts, page); - }, [activeExts, page, open]); + }, [search]); // ── Batch token fetch after assets load ─────────────────────────────────── - // One request for all S3 assets on the current page instead of N per-card requests. - // This eliminates the thundering-herd / auth-refresh race that caused some cards to - // silently show "No preview" after a page reload (multiple 401s queuing simultaneously - // while the interceptor refreshes, some dropping if cancelled mid-flight). + // One request for all S3 assets on the current page instead of N per-card + // requests. getMediaTokens (AdminAssetsContext) already skips any asset_id + // whose cached token is still valid, so reopening the sheet within the + // token's ~30min TTL issues no request at all for previously-seen assets. useEffect(() => { - if (!assets.length) return; + if (!open || !assets.length) return; - // Only request tokens for S3 assets we don't already have a URL for — - // this prevents duplicate POST /tokens when the assets list triggers - // this effect more than once per sheet open (e.g., two fetch effects - // both reacting to open mounting, producing two assets updates). const s3Ids = assets - .filter((a) => a.storage_provider === "s3" && !streamUrls[String(a.asset_id)]) + .filter((a) => a.storage_provider === "s3") .map((a) => a.asset_id); if (!s3Ids.length) return; - let cancelled = false; - api.post("/admin/media/tokens", { asset_ids: s3Ids }) - .then(({ data }) => { - if (cancelled) return; - const tokens = data.data?.tokens ?? {}; - const thumbnails = data.data?.thumbnails ?? {}; - const urls = {}; - for (const [id, token] of Object.entries(tokens)) { - // Prefer presigned thumbnail URL (faster, direct); fall back to stream proxy - urls[id] = thumbnails[id] ?? `${STREAM_BASE}/${token}`; - } - setStreamUrls((prev) => ({ ...prev, ...urls })); - }) - .catch((err) => { - console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message); - }); - - return () => { cancelled = true; }; - }, [assets]); + getMediaTokens(s3Ids).catch((err) => { + console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message); + }); + }, [assets, open, getMediaTokens]); // ── Reset on close ──────────────────────────────────────────────────────── useEffect(() => { @@ -163,7 +159,6 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { setPage(1); setSelected(null); setFilterOpen(false); - setStreamUrls({}); } }, [open]); @@ -187,7 +182,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { // (e.g. badge image picker) can use the authenticated URL directly // rather than falling back to asset.file_url which is a private CDN // key that the browser cannot load without S3 credentials. - const resolvedUrl = streamUrls[String(asset.asset_id)] + const resolvedUrl = resolveStreamSrc(asset.asset_id) ?? asset.thumbnail_url ?? asset.file_url ?? null; @@ -304,7 +299,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { diff --git a/src/components/generic/Blocks/Admin/AudioBlock.jsx b/src/components/generic/Blocks/Admin/AudioBlock.jsx index 19a53c6..fbe93fb 100644 --- a/src/components/generic/Blocks/Admin/AudioBlock.jsx +++ b/src/components/generic/Blocks/Admin/AudioBlock.jsx @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { AssetPickerSheet } from "../../AssetPickerSheet"; import api from "@/utils/api.util"; +import { MediaFallback } from "@/components/generic/MediaFallback"; const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, ""); @@ -173,9 +174,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) { {!readOnly && } {isS3 && tokenLoading ? ( -
-
-
+ ) : src ? (