From b1805cfe4db8fbd17d4a4061eaa8532b7fe1108a Mon Sep 17 00:00:00 2001 From: Kenneth Obsequio Date: Mon, 13 Jul 2026 21:27:56 +0800 Subject: [PATCH] try to deploy Signed-off-by: Kenneth Obsequio --- index.html | 2 +- .../generic/AdminStickyAnnouncementBar.jsx | 5 +- src/components/generic/BlockList.jsx | 6 +- .../generic/Blocks/Admin/AudioBlock.jsx | 2 + .../generic/Blocks/Admin/MarkdownBlock.jsx | 121 +-------- .../generic/Blocks/Admin/TextBlock.jsx | 248 +----------------- .../generic/Blocks/Admin/TextVideoBlock.jsx | 1 + .../generic/Blocks/Admin/VideoBlock.jsx | 12 +- .../generic/Blocks/Client/MarkdownBlock.jsx | 10 +- .../generic/Blocks/Client/TextBlock.jsx | 13 +- .../generic/Blocks/Client/TextImageBlock.jsx | 20 +- .../generic/Blocks/Client/TextVideoBlock.jsx | 24 +- .../generic/Breadcrumb/AppBreadcrumb.jsx | 2 +- src/components/generic/DatePickerButton.jsx | 59 +++++ .../generic/NotificationDetailDialog.jsx | 9 +- src/components/generic/Sheet/FilterSheet.jsx | 3 + .../generic/StickyAnnouncementBar.jsx | 15 +- .../generic/Table/ActiveFilterPills.jsx | 41 ++- src/components/generic/Table/DataTable.jsx | 8 + .../generic/notificationDisplay.jsx | 42 +++ src/contexts/AdminAssetsContext.jsx | 2 +- src/contexts/AdminLibraryContext.jsx | 4 +- src/contexts/AdminUserContext.jsx | 4 +- src/contexts/AdminUserGroupContext.jsx | 2 +- src/contexts/StaffGroupContext.jsx | 2 +- src/contexts/StaffTaskContext.jsx | 4 +- src/data/advertisement.data.js | 8 + src/index.css | 1 + .../ArchivedAdvertisementsTable.jsx | 25 +- .../components/courses/LessonsPreview.jsx | 6 +- .../advertisements/archive/columns.config.jsx | 14 +- .../advertisements/archive/toolbar.config.jsx | 13 +- .../admin/pages/activity/ActivityFeed.jsx | 60 +---- .../admin/pages/activity/UserActivityPage.jsx | 45 +++- .../pages/advertisements/AddAdvertisement.jsx | 8 +- .../advertisements/AdvertisementList.jsx | 21 +- .../advertisements/EditAdvertisement.jsx | 4 +- src/modules/admin/pages/assets/AddAsset.jsx | 15 +- .../admin/pages/assets/AddAssetsBulk.jsx | 4 +- src/modules/admin/pages/assets/EditAsset.jsx | 6 +- .../admin/pages/assets/ViewAudioAsset.jsx | 7 +- .../admin/pages/assets/ViewDocumentAsset.jsx | 7 +- .../admin/pages/assets/ViewImageAsset.jsx | 7 +- .../admin/pages/assets/ViewVideoAsset.jsx | 7 +- .../admin/pages/categories/AddCategory.jsx | 4 +- .../admin/pages/categories/EditCategory.jsx | 6 +- src/modules/admin/pages/courses/AddCourse.jsx | 4 +- .../admin/pages/courses/CourseAssessment.jsx | 2 +- .../admin/pages/courses/EditCourse.jsx | 4 +- .../admin/pages/courses/ViewAssessment.jsx | 2 +- .../admin/pages/courses/ViewCourse.jsx | 2 +- .../admin/pages/courses/lessons/AddLesson.jsx | 4 +- .../pages/courses/lessons/EditLesson.jsx | 8 +- .../courses/lessons/LessonPageBuilder.jsx | 12 +- .../pages/courses/lessons/ViewLesson.jsx | 2 +- .../pages/courses/lessons/ViewLessonPage.jsx | 5 +- .../admin/pages/courses/units/AddUnit.jsx | 4 +- .../admin/pages/courses/units/EditUnit.jsx | 6 +- .../admin/pages/courses/units/ModifyQuiz.jsx | 4 +- .../pages/courses/units/ViewUnitQuiz.jsx | 2 +- .../library/lessons/AddLibraryLesson.jsx | 4 +- .../library/lessons/EditLibraryLesson.jsx | 4 +- .../pages/library/units/AddLibraryUnit.jsx | 4 +- .../pages/library/units/EditLibraryUnit.jsx | 4 +- .../AddNotificationBroadcast.jsx | 11 +- .../EditNotificationBroadcast.jsx | 11 +- .../task_list/task/ViewTaskCompletion.jsx | 2 +- src/modules/admin/pages/tiers/AddPlan.jsx | 4 +- src/modules/admin/pages/tiers/EditPlan.jsx | 4 +- .../admin/pages/tiers/EditTierCategory.jsx | 4 +- .../admin/pages/tiers/PaymentPolicy.jsx | 4 +- .../admin/pages/tiers/UserTierList.jsx | 2 +- src/modules/admin/pages/tiers/ViewPayment.jsx | 2 +- src/modules/admin/pages/tiers/ViewPlan.jsx | 2 +- .../admin/pages/users/AddStaffUser.jsx | 2 +- src/modules/client/components/LessonBlock.jsx | 10 +- .../client/pages/AdvertisementLandingPage.jsx | 4 +- src/modules/client/pages/CourseCheckout.jsx | 2 +- src/modules/client/pages/LessonDetails.jsx | 150 +++++------ src/modules/client/pages/MyAchievements.jsx | 2 +- src/modules/client/pages/MyCertificates.jsx | 2 +- src/modules/client/pages/Notifications.jsx | 8 +- src/modules/public/pages/NotFound.jsx | 10 +- src/modules/public/pages/Unauthorized.jsx | 10 +- src/styles/typeset.css | 213 +++++++++++++++ src/utils/assetUpload.util.js | 12 + src/utils/table.util.jsx | 39 ++- 87 files changed, 798 insertions(+), 718 deletions(-) create mode 100644 src/components/generic/DatePickerButton.jsx create mode 100644 src/styles/typeset.css diff --git a/index.html b/index.html index 16f3f16..f4b2ba2 100644 --- a/index.html +++ b/index.html @@ -6,7 +6,7 @@ - + %VITE_APP_NAME% diff --git a/src/components/generic/AdminStickyAnnouncementBar.jsx b/src/components/generic/AdminStickyAnnouncementBar.jsx index 8eac8c4..419ac61 100644 --- a/src/components/generic/AdminStickyAnnouncementBar.jsx +++ b/src/components/generic/AdminStickyAnnouncementBar.jsx @@ -4,6 +4,7 @@ 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; @@ -17,9 +18,7 @@ function resolveClickAction(stickyAnnouncement) { if (!linkUrl) return null; return { label: stickyAnnouncement.data?.linkLabel || "Open Link", - go: (navigate) => (linkUrl.startsWith("/") - ? navigate(linkUrl) - : window.open(linkUrl, "_blank", "noopener,noreferrer")), + go: (navigate) => goToLink(linkUrl, navigate), }; } diff --git a/src/components/generic/BlockList.jsx b/src/components/generic/BlockList.jsx index 9653a51..82b39c8 100644 --- a/src/components/generic/BlockList.jsx +++ b/src/components/generic/BlockList.jsx @@ -52,9 +52,9 @@ export const DEFAULT_CONTENT = { "text": { body: "" }, "image": { asset_id: null, url: "", alt: "" }, "text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" }, - "video": { asset_id: null, url: "", thumbnail_url: "" }, - "text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" }, - "audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "" }, + "video": { asset_id: null, url: "", thumbnail_url: "", duration_seconds: 0 }, + "text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right", duration_seconds: 0 }, + "audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 }, "code": { language: "javascript", code: "" }, "markdown": { body: "" }, }; diff --git a/src/components/generic/Blocks/Admin/AudioBlock.jsx b/src/components/generic/Blocks/Admin/AudioBlock.jsx index fbe93fb..903c488 100644 --- a/src/components/generic/Blocks/Admin/AudioBlock.jsx +++ b/src/components/generic/Blocks/Admin/AudioBlock.jsx @@ -147,6 +147,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) { // artist — cleared on new pick so stale artist doesn't carry over // thumbnail — cover art from asset.thumbnail_url // tag — file extension badge e.g. "MP3" + // duration_seconds — probed media length, read by duration.util.js on save // const handleSelect = (asset) => { onUpdate({ @@ -158,6 +159,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) { artist: "", thumbnail: asset.thumbnail_url ?? null, tag: asset.extension?.toUpperCase() ?? "", + duration_seconds: Number(asset.duration) || 0, }); setPlaying(false); setCurrentTime(0); diff --git a/src/components/generic/Blocks/Admin/MarkdownBlock.jsx b/src/components/generic/Blocks/Admin/MarkdownBlock.jsx index 2bf0e8a..c202210 100644 --- a/src/components/generic/Blocks/Admin/MarkdownBlock.jsx +++ b/src/components/generic/Blocks/Admin/MarkdownBlock.jsx @@ -8,124 +8,6 @@ import { Link2, List, ListOrdered, Quote, Minus, Eye, Pencil, } from "lucide-react"; -// ─── Shared markdown styles ─────────────────────────────────────────────────── -// Exported so Client/MarkdownBlock can import and inject the same rules. - -export const MARKDOWN_STYLES = ` - .md-body { line-height: 1.7; font-size: 1rem; } - - .md-body h1 { font-size: 1.75rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; } - .md-body h2 { font-size: 1.375rem; font-weight: 600; margin: 1.1rem 0 0.45rem; line-height: 1.25; } - .md-body h3 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; } - .md-body h4 { font-size: 1rem; font-weight: 600; margin: 0.9rem 0 0.35rem; } - - .md-body p { margin: 0 0 0.85rem; line-height: 1.75; } - - .md-body ul { list-style: disc; padding-left: 1.4rem; margin: 0.4rem 0 0.85rem; } - .md-body ol { list-style: decimal; padding-left: 1.4rem; margin: 0.4rem 0 0.85rem; } - .md-body li { margin-bottom: 0.3rem; line-height: 1.7; } - - /* Task list checkboxes */ - .md-body input[type="checkbox"] { margin-right: 0.4rem; accent-color: hsl(var(--primary)); } - - .md-body a { color: hsl(var(--primary)); text-decoration: underline; } - - .md-body strong { font-weight: 700; } - .md-body em { font-style: italic; } - .md-body del { text-decoration: line-through; opacity: 0.7; } - - /* Inline code */ - .md-body :not(pre) > code { - font-family: 'Fira Code', 'Cascadia Code', 'Courier New', monospace; - font-size: 0.875em; - background: hsl(var(--muted)); - color: hsl(var(--foreground)); - padding: 0.1rem 0.35rem; - border-radius: 0.25rem; - border: 1px solid hsl(var(--border)); - word-break: break-word; - } - - /* Code block */ - .md-body pre { - background: hsl(220 13% 12%); - color: hsl(220 14% 88%); - border-radius: 0.5rem; - padding: 0.875rem 1rem; - overflow-x: auto; - margin: 0.75rem 0; - border: 1px solid hsl(220 13% 22%); - -webkit-overflow-scrolling: touch; - } - .md-body pre code { - background: none; - border: none; - padding: 0; - font-size: 0.875rem; - color: inherit; - white-space: pre; - word-break: normal; - font-family: 'Fira Code', 'Cascadia Code', 'Courier New', monospace; - } - - /* Blockquote */ - .md-body blockquote { - border-left: 3px solid hsl(var(--primary)); - margin: 0.75rem 0; - padding: 0.4rem 0 0.4rem 1rem; - color: hsl(var(--muted-foreground)); - font-style: italic; - } - .md-body blockquote p { margin-bottom: 0; } - - /* Horizontal rule */ - .md-body hr { - border: none; - border-top: 1px solid hsl(var(--border)); - margin: 1.25rem 0; - } - - /* Tables (GFM) — mirrors WYSIWYG table technique: display:block + box-shadow borders */ - .md-body table { - display: block; - width: 100%; - max-width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - border-collapse: separate; - border-spacing: 0; - margin: 0.75rem 0; - font-size: 0.875rem; - border: 1.5px solid #cbd5e1; - border-radius: 0.375rem; - } - .md-body th { - background: #f1f5f9; - color: #1e293b; - font-weight: 600; - text-align: left; - padding: 0.45rem 0.75rem; - white-space: nowrap; - box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1; - } - .md-body td { - padding: 0.4rem 0.75rem; - vertical-align: top; - word-break: break-word; - min-width: 4rem; - box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1; - } - .md-body tbody tr:nth-child(even) td { background: #f8fafc; } - - @media (min-width: 1024px) { - .md-body { font-size: 1.05rem; } - .md-body h1 { font-size: 2rem; } - .md-body h2 { font-size: 1.5rem; } - .md-body h3 { font-size: 1.25rem; } - .md-body table { display: table; } - } -`; - // ─── Toolbar button ─────────────────────────────────────────────────────────── function ToolbarBtn({ title, onClick, children, active }) { @@ -248,9 +130,8 @@ export function MarkdownBlock({ content, onUpdate }) { {/* ── Edit / Preview ── */} {preview ? (
- {body.trim() ? ( -
+
{body}
) : ( diff --git a/src/components/generic/Blocks/Admin/TextBlock.jsx b/src/components/generic/Blocks/Admin/TextBlock.jsx index 8800ece..a257d76 100644 --- a/src/components/generic/Blocks/Admin/TextBlock.jsx +++ b/src/components/generic/Blocks/Admin/TextBlock.jsx @@ -45,248 +45,6 @@ const FORMAT_OPTIONS = [ { label: "Heading 3", val: "h3" }, ]; -// ─── Shared styles ──────────────────────────────────────────────────────────── - -export const WYSIWYG_STYLES = ` - /* ── Base (mobile) ────────────────────────────────────────────────────── */ - - .wysiwyg-editor { line-height: 1.6; } - - .wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.375rem; font-weight: 700; margin: 1rem 0 0.4rem; line-height: 1.2; } - .wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; } - .wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.4; } - - .wysiwyg-editor p, - .wysiwyg-preview p { - margin: 0 0 0.75rem 0; - text-align: left; - line-height: 1.65; - font-size: 0.9375rem; - } - - .wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; } - .wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; } - - .wysiwyg-editor li, .wysiwyg-preview li { - margin-bottom: 0.3rem; - line-height: 1.65; - font-size: 0.9375rem; - } - - .wysiwyg-editor a, .wysiwyg-preview a { color: hsl(var(--primary)); text-decoration: underline; } - - /* Inline code */ - .wysiwyg-editor code, .wysiwyg-preview code { - font-family: 'Fira Code', 'Cascadia Code', 'Courier New', Courier, monospace; - font-size: 0.875em; - background: hsl(var(--muted)); - color: hsl(var(--foreground)); - padding: 0.1rem 0.35rem; - border-radius: 0.25rem; - border: 1px solid hsl(var(--border)); - white-space: pre-wrap; - word-break: break-word; - } - - /* Code block */ - .wysiwyg-editor pre, .wysiwyg-preview pre { - background: hsl(220 13% 12%); - color: hsl(220 14% 88%); - border-radius: 0.5rem; - padding: 0.875rem 1rem; - overflow-x: auto; - margin: 0.75rem 0; - border: 1px solid hsl(220 13% 22%); - -webkit-overflow-scrolling: touch; - } - - .wysiwyg-editor pre code, .wysiwyg-preview pre code { - background: none; - border: none; - padding: 0; - font-size: 0.875rem; - color: inherit; - white-space: pre; - word-break: normal; - } - - .wysiwyg-editor a.doc-link, - .wysiwyg-preview a.doc-link { - display: inline-flex; - align-items: center; - gap: 0.25rem; - padding: 0.1rem 0.4rem; - border-radius: 0.375rem; - background: hsl(var(--muted)); - color: hsl(var(--foreground)); - font-size: 0.75rem; - text-decoration: none; - border: 1px solid hsl(var(--border)); - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .wysiwyg-editor a.doc-link::before, - .wysiwyg-preview a.doc-link::before { - content: "📄"; - font-size: 0.7rem; - flex-shrink: 0; - } - - /* Table — scrollable on mobile */ - .wysiwyg-editor table, - .wysiwyg-preview table { - display: block; - width: 100%; - max-width: 100%; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - border-collapse: separate; - border-spacing: 0; - margin: 0.6rem 0; - font-size: 0.75rem; - table-layout: auto; - border: 1.5px solid #cbd5e1; - border-radius: 0.375rem; - } - - .wysiwyg-editor th, - .wysiwyg-preview th { - background: #f1f5f9; - color: #1e293b; - font-weight: 600; - text-align: left; - padding: 0.35rem 0.5rem; - white-space: nowrap; - box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1; - } - - .wysiwyg-editor td, - .wysiwyg-preview td { - padding: 0.35rem 0.5rem; - vertical-align: top; - word-break: break-word; - min-width: 4rem; - box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1; - } - - .wysiwyg-preview tr:nth-child(even) td { background: #f8fafc; } - - .wysiwyg-editor td:focus, - .wysiwyg-editor th:focus { - outline: 2px solid hsl(var(--ring)); - outline-offset: -2px; - background: hsl(var(--accent) / 0.2); - } - - - /* ── Phone (≥ 320px) ─────────────────────────────────────────────────── */ - - @media (min-width: 320px) { - .wysiwyg-editor { line-height: 1.7; } - - .wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; } - .wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; } - .wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; } - - .wysiwyg-editor p, - .wysiwyg-preview p { - font-size: 1rem; - line-height: 1.7; - text-align: justify; - margin: 0 0 0.8rem 0; - } - - .wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; } - .wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; } - - .wysiwyg-editor li, .wysiwyg-preview li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; } - - .wysiwyg-editor a.doc-link, - .wysiwyg-preview a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; } - - .wysiwyg-editor table, - .wysiwyg-preview table { font-size: 0.8125rem; margin: 0.7rem 0; } - - .wysiwyg-editor th, - .wysiwyg-preview th { padding: 0.45rem 0.65rem; } - - .wysiwyg-editor td, - .wysiwyg-preview td { padding: 0.4rem 0.65rem; min-width: 5rem; } - } - - /* ── Tablet (≥ 640px) ─────────────────────────────────────────────────── */ - - - @media (min-width: 640px) { - .wysiwyg-editor { line-height: 1.7; } - - .wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; } - .wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; } - .wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; } - - .wysiwyg-editor p, - .wysiwyg-preview p { - font-size: 1rem; - line-height: 1.7; - text-align: justify; - margin: 0 0 0.8rem 0; - } - - .wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; } - .wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; } - - .wysiwyg-editor li, .wysiwyg-preview li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; } - - .wysiwyg-editor a.doc-link, - .wysiwyg-preview a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; } - - .wysiwyg-editor table, - .wysiwyg-preview table { font-size: 0.8125rem; margin: 0.7rem 0; } - - .wysiwyg-editor th, - .wysiwyg-preview th { padding: 0.45rem 0.65rem; } - - .wysiwyg-editor td, - .wysiwyg-preview td { padding: 0.4rem 0.65rem; min-width: 5rem; } - } - - - /* ── Desktop (≥ 1024px) ───────────────────────────────────────────────── */ - - @media (min-width: 1024px) { - .wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; margin: 1.25rem 0 0.5rem; } - .wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; margin: 1.25rem 0 0.5rem; } - .wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; } - - .wysiwyg-editor p, - .wysiwyg-preview p { font-size: 1.08rem; line-height: 1.75; margin: 0 0 0.85rem 0; } - - .wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; } - .wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; } - - .wysiwyg-editor li, .wysiwyg-preview li { font-size: 1.08em; line-height: 1.75; margin-bottom: 0.4rem; } - - .wysiwyg-editor a.doc-link, - .wysiwyg-preview a.doc-link { font-size: 0.8125rem; padding: 0.1rem 0.5rem; gap: 0.3rem; } - - .wysiwyg-editor table, - .wysiwyg-preview table { - display: table; - font-size: 0.875rem; - margin: 0.75rem 0; - table-layout: fixed; - } - - .wysiwyg-editor th, - .wysiwyg-preview th { padding: 0.5rem 0.75rem; } - - .wysiwyg-editor td, - .wysiwyg-preview td { padding: 0.45rem 0.75rem; min-width: 2rem; } - } -`; - // ─── Table picker popover ───────────────────────────────────────────────────── // A small grid that lets the user hover to choose rows × columns (up to 8×8), // then click to confirm. Rendered inline in the toolbar. @@ -618,10 +376,8 @@ export function RichTextEditor({ blockId, value, onChange, readOnly = false }) { contentEditable suppressContentEditableWarning onInput={() => onChange(editorRef.current.innerHTML)} - className="wysiwyg-editor min-h-[120px] px-3 m-0 py-0 text-sm focus:outline-none" + className="typeset min-h-[120px] px-3 m-0 py-0 text-sm focus:outline-none" /> - -
{/* Table size picker */} @@ -651,7 +407,7 @@ export function TextBlock({ content, onUpdate, blockId, readOnly = false }) { if (readOnly) { return (
); diff --git a/src/components/generic/Blocks/Admin/TextVideoBlock.jsx b/src/components/generic/Blocks/Admin/TextVideoBlock.jsx index 59db657..a885546 100644 --- a/src/components/generic/Blocks/Admin/TextVideoBlock.jsx +++ b/src/components/generic/Blocks/Admin/TextVideoBlock.jsx @@ -115,6 +115,7 @@ export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) storage_provider: asset.storage_provider ?? null, url: asset.file_url, thumbnail_url: asset.thumbnail_url ?? null, + duration_seconds: Number(asset.duration) || 0, })} /> )} diff --git a/src/components/generic/Blocks/Admin/VideoBlock.jsx b/src/components/generic/Blocks/Admin/VideoBlock.jsx index cae1d1d..1ca5e04 100644 --- a/src/components/generic/Blocks/Admin/VideoBlock.jsx +++ b/src/components/generic/Blocks/Admin/VideoBlock.jsx @@ -129,11 +129,12 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { // Clean object — no ...content spread so stale data never carries over. // // Fields saved: - // asset_id — used by client for the secure token flow - // url — used by admin player (direct src); ignored by client for S3 - // thumbnail_url — poster image for the video player - // title — display name (shown in any title-aware blocks) - // tag — file extension badge e.g. "MP4" + // asset_id — used by client for the secure token flow + // url — used by admin player (direct src); ignored by client for S3 + // thumbnail_url — poster image for the video player + // title — display name (shown in any title-aware blocks) + // tag — file extension badge e.g. "MP4" + // duration_seconds — probed media length, read by duration.util.js on save // const handleSelect = (asset) => { onUpdate({ @@ -143,6 +144,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) { thumbnail_url: asset.thumbnail_url ?? null, title: asset.display_name, tag: asset.extension?.toUpperCase() ?? "", + duration_seconds: Number(asset.duration) || 0, }); setPlaying(false); setProgress(0); diff --git a/src/components/generic/Blocks/Client/MarkdownBlock.jsx b/src/components/generic/Blocks/Client/MarkdownBlock.jsx index d1609e0..78a6229 100644 --- a/src/components/generic/Blocks/Client/MarkdownBlock.jsx +++ b/src/components/generic/Blocks/Client/MarkdownBlock.jsx @@ -1,6 +1,5 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { MARKDOWN_STYLES } from "@/components/generic/Blocks/Admin/MarkdownBlock"; export function MarkdownBlock({ content }) { const body = content.body ?? ""; @@ -14,11 +13,8 @@ export function MarkdownBlock({ content }) { } return ( - <> - -
- {body} -
- +
+ {body} +
); } diff --git a/src/components/generic/Blocks/Client/TextBlock.jsx b/src/components/generic/Blocks/Client/TextBlock.jsx index f458467..34b8247 100644 --- a/src/components/generic/Blocks/Client/TextBlock.jsx +++ b/src/components/generic/Blocks/Client/TextBlock.jsx @@ -1,16 +1,11 @@ -import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock"; - export function TextBlock({ content }) { if (!content.body) { return
Empty text block
; } return ( - <> - -
- +
); } \ No newline at end of file diff --git a/src/components/generic/Blocks/Client/TextImageBlock.jsx b/src/components/generic/Blocks/Client/TextImageBlock.jsx index 342d85d..f4966c8 100644 --- a/src/components/generic/Blocks/Client/TextImageBlock.jsx +++ b/src/components/generic/Blocks/Client/TextImageBlock.jsx @@ -1,19 +1,15 @@ -import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock"; import { ImageBlock } from "./ImageBlock"; export function TextImageBlock({ content }) { const imgLeft = content.image_position === "left"; return ( - <> - -
- {imgLeft && } -
Empty text

" }} - /> - {!imgLeft && } -
- +
+ {imgLeft && } +
Empty text

" }} + /> + {!imgLeft && } +
); } \ No newline at end of file diff --git a/src/components/generic/Blocks/Client/TextVideoBlock.jsx b/src/components/generic/Blocks/Client/TextVideoBlock.jsx index 756ff4a..3873b0b 100644 --- a/src/components/generic/Blocks/Client/TextVideoBlock.jsx +++ b/src/components/generic/Blocks/Client/TextVideoBlock.jsx @@ -1,21 +1,17 @@ -import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock"; import { VideoBlock } from "./VideoBlock"; export function TextVideoBlock({ content }) { const vidLeft = content.video_position === "left"; return ( - <> - -
- {vidLeft && } -
Empty text

", - }} - /> - {!vidLeft && } -
- +
+ {vidLeft && } +
Empty text

", + }} + /> + {!vidLeft && } +
); } \ No newline at end of file diff --git a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx index 5103bdc..30c3b2b 100644 --- a/src/components/generic/Breadcrumb/AppBreadcrumb.jsx +++ b/src/components/generic/Breadcrumb/AppBreadcrumb.jsx @@ -49,7 +49,7 @@ import { cn } from "@/lib/utils"; * // With custom click handler * , onClick: (e, navigate) => navigate(-1) }, + * { label: "Home", icon: , onClick: (e, navigate) => navigate("/admin") }, * { label: "Settings", to: `/admin/${adminId}/settings` }, * { label: "Profile" }, * ]} diff --git a/src/components/generic/DatePickerButton.jsx b/src/components/generic/DatePickerButton.jsx new file mode 100644 index 0000000..39f1e1a --- /dev/null +++ b/src/components/generic/DatePickerButton.jsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +import { CalendarIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { useDateFormat } from "@/hooks/useDateFormat"; + +// Single-date picker button used by "From"/"To" range filters (Activity Feed, +// User Activity tab). Extracted so both pages share one implementation. +export function DatePickerButton({ value, onChange, placeholder, disabled }) { + const [open, setOpen] = useState(false); + const { fmtDate } = useDateFormat(); + + const label = value ? fmtDate(value) : placeholder; + + return ( + + + + + + { onChange(d ?? null); setOpen(false); }} + disabled={disabled} + initialFocus + /> +
+ + {value && ( + + )} +
+
+
+ ); +} diff --git a/src/components/generic/NotificationDetailDialog.jsx b/src/components/generic/NotificationDetailDialog.jsx index 808bf11..e21d73f 100644 --- a/src/components/generic/NotificationDetailDialog.jsx +++ b/src/components/generic/NotificationDetailDialog.jsx @@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { useDateFormat } from "@/hooks/useDateFormat"; -import { NotificationIcon, getTypeAccent, resolveNotificationLink } from "@/components/generic/notificationDisplay"; +import { NotificationIcon, getTypeAccent, getTypeButtonClasses, resolveNotificationLink } from "@/components/generic/notificationDisplay"; export default function NotificationDetailDialog({ notification, onOpenChange }) { const { fmtDateTime } = useDateFormat(); @@ -81,7 +81,12 @@ export default function NotificationDetailDialog({ notification, onOpenChange }) )} {link && ( - )} diff --git a/src/components/generic/Sheet/FilterSheet.jsx b/src/components/generic/Sheet/FilterSheet.jsx index 571ae27..c4628b1 100644 --- a/src/components/generic/Sheet/FilterSheet.jsx +++ b/src/components/generic/Sheet/FilterSheet.jsx @@ -10,6 +10,9 @@ import { Spinner } from "@/components/ui/spinner"; const FIELD_DISPLAY_MAP = { is_active: { true: "Active", false: "Inactive" }, is_verified: { true: "Verified", false: "Not Verified" }, + is_banned: { true: "Banned", false: "Not Banned" }, + is_public: { true: "Public", false: "Private" }, + is_required: { true: "Yes", false: "No" }, }; const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP); diff --git a/src/components/generic/StickyAnnouncementBar.jsx b/src/components/generic/StickyAnnouncementBar.jsx index e2950aa..56810c8 100644 --- a/src/components/generic/StickyAnnouncementBar.jsx +++ b/src/components/generic/StickyAnnouncementBar.jsx @@ -9,19 +9,10 @@ import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouse const ROTATE_INTERVAL_MS = 6000; -// Explicit link_url (from the admin "On Open" section) always wins, using the -// admin-authored button label when set. Falls back to the type-based resolver -// for broadcasts sent before that field existed. +// resolveNotificationLink already gives explicit link_url (the admin "On +// Open" section) precedence over the type-based fallbacks, for broadcasts +// sent before that field existed. function resolveClickAction(stickyAnnouncement) { - const explicitUrl = stickyAnnouncement.data?.linkUrl || null; - if (explicitUrl) { - return { - label: stickyAnnouncement.data?.linkLabel || "Open Link", - go: (navigate) => (explicitUrl.startsWith("/") - ? navigate(explicitUrl) - : window.open(explicitUrl, "_blank", "noopener,noreferrer")), - }; - } return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data); } diff --git a/src/components/generic/Table/ActiveFilterPills.jsx b/src/components/generic/Table/ActiveFilterPills.jsx index 1aca886..cab087f 100644 --- a/src/components/generic/Table/ActiveFilterPills.jsx +++ b/src/components/generic/Table/ActiveFilterPills.jsx @@ -1,5 +1,41 @@ import { Filter, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { BOOLEAN_FIELD_LABELS } from "@/utils/table.util"; + +// Resolves a filter's raw value(s) into what should actually be shown on the +// pill. Handles every shape ColumnFilter/FilterSheet can produce: +// - date range: { from, to } +// - boolean columns: "true" / "false" (or an array of those) +// - id-backed columns (e.g. createdBy/updatedBy): raw ids — resolved via +// fieldOptions[field], the { value, label } picklist DataTable cached +// from the last time that column's filter sheet was opened +// - everything else (free text, plain enum values): shown as-is +function resolveFilterDisplay(filter, fieldOptions) { + const raw = filter.value; + + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + const { from, to } = raw; + if (from && to) return `${from} – ${to}`; + if (from) return `From ${from}`; + if (to) return `Until ${to}`; + return ""; + } + + const values = Array.isArray(raw) ? raw : [raw]; + const boolLabels = BOOLEAN_FIELD_LABELS[filter.id]; + const options = fieldOptions?.[filter.id]; + + const labels = values.map((v) => { + if (boolLabels) return boolLabels[v === "true" ? 1 : 0]; + if (Array.isArray(options)) { + const match = options.find((o) => String(o?.value ?? o) === String(v)); + if (match !== undefined) return match?.label ?? match?.value ?? match; + } + return v; + }); + + return labels.join(", "); +} /** * ActiveFilterPills @@ -9,6 +45,8 @@ import { Button } from "@/components/ui/button"; * Props: * @param {Array} filters - Array of { id, value } (TanStack columnFilters shape) * @param {Array} attributes - Array of { field, name } for display labels + * @param {Object} [fieldOptions] - { [field]: [{ value, label }] } picklist cache, + * used to resolve id-backed filters (createdBy, etc.) * @param {Function} onRemove - (id: string) => void — remove a single filter * @param {Function} [onClearAll] - () => void — clear all filters * @param {boolean} [showClearButton]- Render the "Clear filters (n)" button instead of pills @@ -17,6 +55,7 @@ import { Button } from "@/components/ui/button"; export function ActiveFilterPills({ filters = [], attributes = [], + fieldOptions = {}, onRemove, onClearAll, showClearButton = false, @@ -50,7 +89,7 @@ export function ActiveFilterPills({ className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary border border-primary/20 rounded-full px-2.5 py-0.5" > {attr?.name ?? f.id}: - {String(f.value)} + {resolveFilterDisplay(f, fieldOptions)}
)} > - - + {showHeader && } {blocks.length === 0 ? (
diff --git a/src/modules/admin/config/advertisements/archive/columns.config.jsx b/src/modules/admin/config/advertisements/archive/columns.config.jsx index ad04146..dd5be08 100644 --- a/src/modules/admin/config/advertisements/archive/columns.config.jsx +++ b/src/modules/admin/config/advertisements/archive/columns.config.jsx @@ -9,18 +9,28 @@ export const columnPinning = { left: [], }; -const cellOverrides = {}; +const DATE_TIME_FIELDS = ["start_date", "end_date", "createdAt", "updatedAt", "deletedAt"]; /** * Builds the full column array for the Archived Advertisements table. * * @param {Array} attributes Field definitions from the server (drives data columns) * @param {Array} rowActions Row-level kebab action definitions + * @param {Function} [fmtDateTime] - date+time formatter (from useDateFormat), falls back to a plain locale string * @returns {Array} TanStack column definitions */ -export function buildDataColumns(attributes, rowActions) { +export function buildDataColumns(attributes, rowActions, fmtDateTime = (v) => v ? new Date(v).toLocaleString() : "—") { const visibleAttributes = attributes.filter((a) => !a.hidden); + // Ad scheduling is time-sensitive — the default date-only cell renderer + // drops the time-of-day, so these fields get an explicit date+time cell. + const cellOverrides = Object.fromEntries( + DATE_TIME_FIELDS.map((field) => [ + field, + (info) => {fmtDateTime(info.getValue())}, + ]) + ); + return [ buildSelectionColumn(), ...buildColumns(visibleAttributes, { cellOverrides }), diff --git a/src/modules/admin/config/advertisements/archive/toolbar.config.jsx b/src/modules/admin/config/advertisements/archive/toolbar.config.jsx index 3a90e27..87f1554 100644 --- a/src/modules/admin/config/advertisements/archive/toolbar.config.jsx +++ b/src/modules/admin/config/advertisements/archive/toolbar.config.jsx @@ -1,5 +1,5 @@ // config/advertisements/archive/toolbar.config.jsx -import { RefreshCw, Download } from "lucide-react"; +import { RefreshCw, Download, Ban } from "lucide-react"; import { exportTableToExcel } from "@/utils/excel.util"; /** @@ -11,8 +11,9 @@ import { exportTableToExcel } from "@/utils/excel.util"; * @param {Function} deps.getFilters * @param {Function} deps.getSort * @param {Function} deps.getTableInstance + * @param {Function} [deps.onRemoveExpired] - triggers the "Remove Expired" bulk-purge flow */ -export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) { +export function buildToolbarActions({ fetchAdvertisements, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance, onRemoveExpired }) { return [ { key: "refresh", @@ -38,5 +39,13 @@ export function buildToolbarActions({ fetchAdvertisements, pagination, exportCon tableInstance: table ?? getTableInstance(), }), }, + ...(onRemoveExpired ? [{ + key: "remove-expired", + type: "button", + icon: , + label: "Remove Expired", + className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive", + onClick: onRemoveExpired, + }] : []), ]; } diff --git a/src/modules/admin/pages/activity/ActivityFeed.jsx b/src/modules/admin/pages/activity/ActivityFeed.jsx index 46e3b43..8e5b951 100644 --- a/src/modules/admin/pages/activity/ActivityFeed.jsx +++ b/src/modules/admin/pages/activity/ActivityFeed.jsx @@ -1,7 +1,7 @@ import { useEffect, useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useUsers } from "@/contexts/AdminUserContext"; -import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink, CalendarIcon } from "lucide-react"; +import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink } from "lucide-react"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import { Button } from "@/components/ui/button"; @@ -9,9 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Calendar } from "@/components/ui/calendar"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { DatePickerButton } from "@/components/generic/DatePickerButton"; import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data"; import { timeAgo } from "@/utils/timestamp.util"; @@ -195,57 +194,6 @@ export default function ActivityFeed() { ); } -// ─── DatePickerButton ───────────────────────────────────────────────────────── -function DatePickerButton({ value, onChange, placeholder, disabled }) { - const [open, setOpen] = useState(false); - const { fmtDate } = useDateFormat(); - - const label = value ? fmtDate(value) : placeholder; - - return ( - - - - - - { onChange(d ?? null); setOpen(false); }} - disabled={disabled} - initialFocus - /> -
- - {value && ( - - )} -
-
-
- ); -} - // ─── Row ────────────────────────────────────────────────────────────────────── function initials(name, email) { if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase(); @@ -293,10 +241,10 @@ function ActivityRow({ row, onViewUser }) { {ts ? ( - {timeAgo(ts)} + {fmtDateTime(ts)} - {fmtDateTime(ts)} + {timeAgo(ts)} ) : ( diff --git a/src/modules/admin/pages/activity/UserActivityPage.jsx b/src/modules/admin/pages/activity/UserActivityPage.jsx index 9994626..660a91e 100644 --- a/src/modules/admin/pages/activity/UserActivityPage.jsx +++ b/src/modules/admin/pages/activity/UserActivityPage.jsx @@ -8,13 +8,20 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DatePickerButton } from "@/components/generic/DatePickerButton"; import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data"; import { timeAgo } from "@/utils/timestamp.util"; +import { fmtISO } from "@/utils/datetime.util"; import { useDateFormat } from "@/hooks/useDateFormat"; const LIMIT = 20; +function toDateStr(d) { + if (!d) return undefined; + return fmtISO(d); +} + export default function UserActivityPage() { const { userId } = useParams(); const navigate = useNavigate(); @@ -22,6 +29,8 @@ export default function UserActivityPage() { const [page, setPage] = useState(1); const [action, setAction] = useState("all"); + const [from, setFrom] = useState(null); + const [to, setTo] = useState(null); const load = useCallback( (p = 1) => { @@ -29,14 +38,16 @@ export default function UserActivityPage() { page: p, limit: LIMIT, action: action === "all" ? undefined : action, + from: toDateStr(from), + to: toDateStr(to), }); setPage(p); }, - [fetchUserActivity, userId, action] + [fetchUserActivity, userId, action, from, to] ); useEffect(() => { fetchUser(userId); }, [userId]); - useEffect(() => { load(1); }, [action, userId]); + useEffect(() => { load(1); }, [action, from, to, userId]); const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`; @@ -76,7 +87,7 @@ export default function UserActivityPage() {
{/* ─── Filter ──────────────────────────────────────────────── */} -
+
Filter by action
- {action !== "all" && ( -
-
)} @@ -187,10 +214,10 @@ function ActivityItem({ row }) { {ts ? ( - {timeAgo(ts)} + {fmtDateTime(ts)} - {fmtDateTime(ts)} + {timeAgo(ts)} ) : ( diff --git a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx index 6646dda..1c1fb95 100644 --- a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx @@ -13,6 +13,7 @@ import { import { useAdvertisements } from "@/contexts/AdminAdvertisementContext"; import { useAuth } from "@/contexts/AuthContext"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; +import { useDateFormat } from "@/hooks/useDateFormat"; import { resolveAssetSrc } from "@/utils/media.util"; import { cn } from "@/lib/utils"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; @@ -453,6 +454,7 @@ function SummaryRow({ label, value }) { } function StepReview({ data, selectedAsset, imageUrl }) { + const { fmtDateTime } = useDateFormat(); const placementMeta = PLACEMENT_MAP[data.placement]; const ctas = (data.ctas ?? []).filter((c) => c.label || c.link); const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body); @@ -519,8 +521,8 @@ function StepReview({ data, selectedAsset, imageUrl }) {

Scheduling & display

- - + +
@@ -693,7 +695,7 @@ export default function AddAdvertisement() {
{/* ── Stat cards ─────────────────────────────────────────────── */} -
+
-
{/* ── Filters ────────────────────────────────────────────────── */} @@ -122,7 +122,7 @@ export default function AdvertisementList() { All statuses - {ADVERTISEMENT_STATUSES.map((s) => ( + {ADVERTISEMENT_FILTERABLE_STATUSES.map((s) => ( {s.label} ))} @@ -170,6 +170,7 @@ export default function AdvertisementList() { @@ -203,7 +204,7 @@ function StatCard({ label, value, tone = "default" }) { // ─── Advertisement card ───────────────────────────────────────────────────── function AdvertisementCard({ ad, onView, onEdit, onArchive }) { - const { fmtDate } = useDateFormat(); + const { fmtDateTime } = useDateFormat(); const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {}; const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {}; const placementMeta = PLACEMENT_MAP[ad.placement] ?? null; @@ -212,7 +213,7 @@ function AdvertisementCard({ ad, onView, onEdit, onArchive }) { const previewSrc = resolveAssetSrc(ad.image) || ad.image_url || null; const isDimmed = ad.status === "expired" || ad.status === "archived"; - const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDate); + const dateRange = formatDateRange(ad.start_date, ad.end_date, fmtDateTime); return (
diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx index aa7bb48..8fde34c 100644 --- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx @@ -213,7 +213,7 @@ export default function EditAdvertisement() { }, [advertisementId]); const onSubmit = async (values) => { - if (!isDirty) { bypassOnce(); return navigate(-1); } + if (!isDirty) { bypassOnce(); return navigate("/admin/advertisements"); } const payload = { ...values, @@ -506,7 +506,7 @@ export default function EditAdvertisement() {
-
@@ -380,7 +389,7 @@ export default function AddAsset() {
@@ -213,7 +213,7 @@ export default function AddAssetsBulk() { {/* ── Actions ── */}
-
diff --git a/src/modules/admin/pages/assets/EditAsset.jsx b/src/modules/admin/pages/assets/EditAsset.jsx index e1c3b02..f2a4a4a 100644 --- a/src/modules/admin/pages/assets/EditAsset.jsx +++ b/src/modules/admin/pages/assets/EditAsset.jsx @@ -200,7 +200,7 @@ export default function EditAsset() { if (!result) return; bypassOnce(); - navigate(-1); + navigate("/admin/assets"); }; if (initializing) { @@ -224,7 +224,7 @@ export default function EditAsset() { {/* ── Header ── */}
-
@@ -334,7 +334,7 @@ export default function EditAsset() { +
); } @@ -64,7 +64,7 @@ export default function ViewAudioAsset() { {/* ── Header ── */}
-
@@ -113,8 +113,9 @@ export default function ViewAudioAsset() {

Timestamps

- + +
diff --git a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx index 894a1fa..7705c15 100644 --- a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx +++ b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx @@ -42,7 +42,7 @@ export default function ViewDocumentAsset() { return (

Asset not found.

- +
); } @@ -55,7 +55,7 @@ export default function ViewDocumentAsset() { {/* ── Header ── */}
-
@@ -116,8 +116,9 @@ export default function ViewDocumentAsset() {

Timestamps

- + +
diff --git a/src/modules/admin/pages/assets/ViewImageAsset.jsx b/src/modules/admin/pages/assets/ViewImageAsset.jsx index b352762..463b717 100644 --- a/src/modules/admin/pages/assets/ViewImageAsset.jsx +++ b/src/modules/admin/pages/assets/ViewImageAsset.jsx @@ -40,7 +40,7 @@ export default function ViewImageAsset() { return (

Asset not found.

- +
); } @@ -52,7 +52,7 @@ export default function ViewImageAsset() { {/* ── Header ── */}
-
@@ -108,8 +108,9 @@ export default function ViewImageAsset() {

Timestamps

- + +
diff --git a/src/modules/admin/pages/assets/ViewVideoAsset.jsx b/src/modules/admin/pages/assets/ViewVideoAsset.jsx index 5388a9c..f6a10bb 100644 --- a/src/modules/admin/pages/assets/ViewVideoAsset.jsx +++ b/src/modules/admin/pages/assets/ViewVideoAsset.jsx @@ -49,7 +49,7 @@ export default function ViewVideoAsset() { return (

Asset not found.

- +
); } @@ -61,7 +61,7 @@ export default function ViewVideoAsset() { {/* ── Header ── */}
-
@@ -143,8 +143,9 @@ export default function ViewVideoAsset() {

Timestamps

- + +
diff --git a/src/modules/admin/pages/categories/AddCategory.jsx b/src/modules/admin/pages/categories/AddCategory.jsx index e9ebe04..d51584d 100644 --- a/src/modules/admin/pages/categories/AddCategory.jsx +++ b/src/modules/admin/pages/categories/AddCategory.jsx @@ -65,7 +65,7 @@ export default function AddCategory() {
-
@@ -100,7 +100,7 @@ export default function AddCategory() {
- +
-
@@ -111,7 +111,7 @@ export default function EditCategory() {
- +
@@ -653,7 +653,7 @@ export default function AddCourse() {
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx index 40b63e0..dbb60f8 100644 --- a/src/modules/admin/pages/courses/EditCourse.jsx +++ b/src/modules/admin/pages/courses/EditCourse.jsx @@ -478,7 +478,7 @@ export default function EditCourse() { >
-
@@ -1121,7 +1121,7 @@ export default function EditCourse() {
diff --git a/src/modules/admin/pages/courses/ViewCourse.jsx b/src/modules/admin/pages/courses/ViewCourse.jsx index df37421..9d3150d 100644 --- a/src/modules/admin/pages/courses/ViewCourse.jsx +++ b/src/modules/admin/pages/courses/ViewCourse.jsx @@ -346,7 +346,7 @@ export default function ViewCourse() { >
-
diff --git a/src/modules/admin/pages/courses/lessons/AddLesson.jsx b/src/modules/admin/pages/courses/lessons/AddLesson.jsx index e3966e8..dc1db62 100644 --- a/src/modules/admin/pages/courses/lessons/AddLesson.jsx +++ b/src/modules/admin/pages/courses/lessons/AddLesson.jsx @@ -67,7 +67,7 @@ export default function AddLesson() {
-
@@ -146,7 +146,7 @@ export default function AddLesson() {
-
@@ -163,7 +163,7 @@ export default function EditLesson() {
-
@@ -152,7 +158,7 @@ export default function LessonPageBuilder() { Preview -
diff --git a/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx b/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx index cc5b1a2..5c16400 100644 --- a/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx +++ b/src/modules/admin/pages/courses/lessons/ViewLessonPage.jsx @@ -16,6 +16,9 @@ export default function ViewLessonPage() { const builderPath = unitId ? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page` : `/admin/lessons/${lessonId}/page`; + const viewLessonPath = unitId + ? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view` + : `/admin/lessons/${lessonId}/view`; const { fetchLesson, lesson, lessonPage } = useCourses(); const [initializing, setInitializing] = useState(true); @@ -39,7 +42,7 @@ export default function ViewLessonPage() { >
-
diff --git a/src/modules/admin/pages/courses/units/AddUnit.jsx b/src/modules/admin/pages/courses/units/AddUnit.jsx index 9df1e43..98c8513 100644 --- a/src/modules/admin/pages/courses/units/AddUnit.jsx +++ b/src/modules/admin/pages/courses/units/AddUnit.jsx @@ -58,7 +58,7 @@ export default function AddUnit() {
-
@@ -89,7 +89,7 @@ export default function AddUnit() {
-
@@ -101,7 +101,7 @@ export default function EditUnit() {
-
diff --git a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx index de57f3c..f31e691 100644 --- a/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx +++ b/src/modules/admin/pages/courses/units/ViewUnitQuiz.jsx @@ -281,7 +281,7 @@ export default function ViewUnitQuiz() { >
-
diff --git a/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx index d09c70a..9d57f3d 100644 --- a/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx +++ b/src/modules/admin/pages/library/lessons/AddLibraryLesson.jsx @@ -215,7 +215,7 @@ export default function AddLibraryLesson() { }; const handleBack = () => { - if (step === 0) navigate(-1); + if (step === 0) navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons"); else setStep((s) => s - 1); }; @@ -250,7 +250,7 @@ export default function AddLibraryLesson() {
-
diff --git a/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx b/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx index 3386715..46c998c 100644 --- a/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx +++ b/src/modules/admin/pages/library/lessons/EditLibraryLesson.jsx @@ -62,7 +62,7 @@ export default function EditLibraryLesson() {
-
@@ -90,7 +90,7 @@ export default function EditLibraryLesson() {
-
diff --git a/src/modules/admin/pages/library/units/EditLibraryUnit.jsx b/src/modules/admin/pages/library/units/EditLibraryUnit.jsx index 07a1a08..5d331a6 100644 --- a/src/modules/admin/pages/library/units/EditLibraryUnit.jsx +++ b/src/modules/admin/pages/library/units/EditLibraryUnit.jsx @@ -76,7 +76,7 @@ export default function EditLibraryUnit() {
-
@@ -127,7 +127,7 @@ export default function EditLibraryUnit() {
-
-
@@ -403,7 +403,7 @@ export default function AddPlan() {
-
@@ -348,7 +348,7 @@ export default function EditPlan() { )}
- +
-
@@ -374,7 +374,7 @@ function EditTierCategoryInner({ isAdd }) { {/* Actions */}
- +
-
@@ -426,7 +426,7 @@ export default function PaymentPolicy() { {/* ── Save ─────────────────────────────────────────────────── */}
-
diff --git a/src/modules/admin/pages/tiers/ViewPayment.jsx b/src/modules/admin/pages/tiers/ViewPayment.jsx index 3025dc3..419021e 100644 --- a/src/modules/admin/pages/tiers/ViewPayment.jsx +++ b/src/modules/admin/pages/tiers/ViewPayment.jsx @@ -144,7 +144,7 @@ export default function ViewPayment() {
-
diff --git a/src/modules/admin/pages/tiers/ViewPlan.jsx b/src/modules/admin/pages/tiers/ViewPlan.jsx index 80b1365..9a3ec3a 100644 --- a/src/modules/admin/pages/tiers/ViewPlan.jsx +++ b/src/modules/admin/pages/tiers/ViewPlan.jsx @@ -772,7 +772,7 @@ export default function ViewPlan() { >
-
diff --git a/src/modules/admin/pages/users/AddStaffUser.jsx b/src/modules/admin/pages/users/AddStaffUser.jsx index 899a184..550db64 100644 --- a/src/modules/admin/pages/users/AddStaffUser.jsx +++ b/src/modules/admin/pages/users/AddStaffUser.jsx @@ -459,7 +459,7 @@ export default function AddStaffUserPage() {
@@ -61,7 +61,7 @@ export default function AdvertisementLandingPage() {
- diff --git a/src/modules/client/pages/CourseCheckout.jsx b/src/modules/client/pages/CourseCheckout.jsx index def2dc5..92c5f26 100644 --- a/src/modules/client/pages/CourseCheckout.jsx +++ b/src/modules/client/pages/CourseCheckout.jsx @@ -108,7 +108,7 @@ export default function CourseCheckout() { This course doesn't have an individual purchase option.

- diff --git a/src/modules/client/pages/LessonDetails.jsx b/src/modules/client/pages/LessonDetails.jsx index 4f355c9..9b6ec78 100644 --- a/src/modules/client/pages/LessonDetails.jsx +++ b/src/modules/client/pages/LessonDetails.jsx @@ -1,16 +1,17 @@ import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import { useParams, useNavigate } from "react-router-dom"; import { - House, SendHorizonal, CheckCheck, Check, Hourglass, + House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Layers, BookOpen, Video, } from "lucide-react"; -import { cn } from "@/lib/utils"; import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useEffect } from "react"; import { useLibrary } from "@/contexts/ClientLibraryContext"; import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { PageMeta } from "@/contexts/MetadataContext"; import LockedContentPanel from "@/modules/client/components/LockedContentPanel"; +import LessonBlock from "../components/LessonBlock.jsx"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -22,54 +23,6 @@ function formatDuration(seconds = 0) { return `${m}min`; } -// ─── Sibling lessons sidebar (unit context) ──────────────────────────────────── - -const UnitLessonsSidebar = ({ unitDetail, currentLessonUuid, onSelect }) => { - if (!unitDetail) return null; - const lessons = unitDetail.lessons ?? []; - - return ( - - ); -}; - // ─── Lesson Details ───────────────────────────────────────────────────────── const LessonDetails = () => { @@ -78,13 +31,22 @@ const LessonDetails = () => { const { getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson, - getUnitDetail, unitDetail, resetUnitDetail, + upsertLessonProgress, } = useLibrary(); const { tierMap, getTierCategories } = useClientTiers(); const hasCompleted = lesson?.status === "completed"; const unit = lesson?.unit ?? null; const hasUnit = !!unit?.uuid; + const hasCourse = !!unit?.course; + // duration_seconds is derived from authored lesson content (see duration.util.js) — + // zero means no lesson content has been built yet, so it isn't ready to view. + const contentNotReady = !lesson?.duration_seconds + || (hasUnit && !unit?.duration_seconds) + || (hasCourse && !unit.course.duration_seconds); + + const blockTypes = new Set((lesson?.blocks ?? []).map((b) => b.type)); + const isVideoLesson = blockTypes.has("video") || blockTypes.has("text-video"); useEffect(() => { getTierCategories(); @@ -93,20 +55,20 @@ const LessonDetails = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [uuid]); - useEffect(() => { - if (!hasUnit) return; - getUnitDetail(unit.uuid); - return () => resetUnitDetail(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [unit?.uuid]); - const items = [ { label: "Home", icon: , to: `/dashboard` }, { label: "Lessons", to: `/lessons` }, + ...(hasCourse ? [{ label: unit.course.title, to: `/course/${unit.course.course_id}` }] : []), ...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []), { label: lesson?.title ?? "Lesson" }, ]; + const badge = hasCourse + ? { label: "Unit lesson · Part of a course", icon: BookOpen } + : hasUnit + ? { label: "Unit lesson", icon: Layers } + : { label: "Standalone lesson", icon: Clock }; + // ── Deep-link to a lesson under a locked unit — inline blocked panel ──── if (unitBlocked) { return ; @@ -126,24 +88,41 @@ const LessonDetails = () => { if (!hasUnit) return; navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } }); }; - const handleSelectSibling = (sibling) => { - if (sibling.uuid === uuid) return; - navigate(`/lessons/${sibling.uuid}`); + const handleMarkComplete = () => { + if (hasCompleted) return; + upsertLessonProgress(lesson.uuid, "completed", unit?.uuid); }; return (
+ + + {badge.label} +

{lesson.title}

{lesson.description ?? ""}

+ {!hasCourse && ( +
+ {formatDuration(lesson.duration_seconds) && ( + <> + {formatDuration(lesson.duration_seconds)} + · + + )} + + +
+ )}
{lesson.objectives?.length > 0 && ( @@ -160,30 +139,43 @@ const LessonDetails = () => {
)} -
- {!hasUnit ? ( -
- - This lesson isn't part of a unit yet. Check back later. -
- ) : ( + {contentNotReady ? ( +
+ + This lesson is currently being prepared. Please check back later. +
+ ) : hasCourse ? ( +
- )} -
+
+ ) : ( + <> +
+ +
+
+

+ {hasUnit + ? `This lesson is part of the "${unit.title}" unit — progress is tracked on its own.` + : "This lesson isn't part of a course — progress is tracked on its own." + } +

+ +
+ + )}
- - {hasUnit && ( - - )}
); diff --git a/src/modules/client/pages/MyAchievements.jsx b/src/modules/client/pages/MyAchievements.jsx index d52a200..c3020bc 100644 --- a/src/modules/client/pages/MyAchievements.jsx +++ b/src/modules/client/pages/MyAchievements.jsx @@ -26,7 +26,7 @@ export default function MyAchievements() {
-
diff --git a/src/modules/client/pages/MyCertificates.jsx b/src/modules/client/pages/MyCertificates.jsx index 8351e7b..cabb1c0 100644 --- a/src/modules/client/pages/MyCertificates.jsx +++ b/src/modules/client/pages/MyCertificates.jsx @@ -120,7 +120,7 @@ export default function MyCertificates() {
-
diff --git a/src/modules/client/pages/Notifications.jsx b/src/modules/client/pages/Notifications.jsx index 0d85a36..0445416 100644 --- a/src/modules/client/pages/Notifications.jsx +++ b/src/modules/client/pages/Notifications.jsx @@ -25,7 +25,7 @@ import { } from "@/components/ui/alert-dialog"; import { useClientNotifications } from "@/contexts/ClientNotificationContext"; import { cn } from "@/lib/utils"; -import { NotificationIcon, timeAgo } from "@/components/generic/notificationDisplay"; +import { NotificationIcon, getTypeAccent, timeAgo } from "@/components/generic/notificationDisplay"; import NotificationDetailDialog from "@/components/generic/NotificationDetailDialog"; import { toast } from "sonner"; @@ -154,7 +154,7 @@ export default function Notifications() {
-
@@ -214,8 +214,8 @@ export default function Notifications() { )} >
-
- +
+
diff --git a/src/modules/public/pages/NotFound.jsx b/src/modules/public/pages/NotFound.jsx index c62ef53..a1b103f 100644 --- a/src/modules/public/pages/NotFound.jsx +++ b/src/modules/public/pages/NotFound.jsx @@ -1,10 +1,18 @@ import { useNavigate } from 'react-router-dom' +import { useAuth } from '@/contexts/AuthContext' + +const DASHBOARD_BY_ROLE = { + admin: '/admin', + user: '/dashboard', + staff: '/staff', +} export default function NotFound() { const navigate = useNavigate() + const { user } = useAuth() const goHome = () => { - navigate(-1) + navigate(DASHBOARD_BY_ROLE[user?.acc_type] ?? '/') } return ( diff --git a/src/modules/public/pages/Unauthorized.jsx b/src/modules/public/pages/Unauthorized.jsx index 9eb3bb6..f936bd4 100644 --- a/src/modules/public/pages/Unauthorized.jsx +++ b/src/modules/public/pages/Unauthorized.jsx @@ -1,10 +1,18 @@ import { useNavigate } from 'react-router-dom' +import { useAuth } from '@/contexts/AuthContext' + +const DASHBOARD_BY_ROLE = { + admin: '/admin', + user: '/dashboard', + staff: '/staff', +} export default function Unauthorized() { const navigate = useNavigate() + const { user } = useAuth() const goHome = () => { - navigate(-1) + navigate(DASHBOARD_BY_ROLE[user?.acc_type] ?? '/') } return ( diff --git a/src/styles/typeset.css b/src/styles/typeset.css new file mode 100644 index 0000000..900f572 --- /dev/null +++ b/src/styles/typeset.css @@ -0,0 +1,213 @@ +/** + * typeset.css + * + * Shared typography for rendered lesson content. Every rich-text surface — + * the WYSIWYG block (editor + read-only render) and the Markdown block + * (editor preview + read-only render) — wraps its output in `.typeset` so + * both produce identical headings/lists/tables/code regardless of which + * editor authored the content. Loaded once globally (see index.css) instead + * of being re-injected per block instance. + * + * Reuses the app's existing theme tokens (--foreground, --primary, --muted, + * --border, --ring) so light/dark theming stays automatic. + */ + +.typeset { + line-height: 1.7; +} + +.typeset h1 { font-size: 1.375rem; font-weight: 700; margin: 1rem 0 0.4rem; line-height: 1.2; } +.typeset h2 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; } +.typeset h3 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.4; } +.typeset h4 { font-size: 1rem; font-weight: 600; margin: 0.9rem 0 0.35rem; } + +.typeset p { + margin: 0 0 0.75rem 0; + text-align: left; + line-height: 1.65; + font-size: 0.9375rem; +} + +.typeset ul { list-style: disc; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; } +.typeset ol { list-style: decimal; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; } +.typeset li { margin-bottom: 0.3rem; line-height: 1.65; font-size: 0.9375rem; } + +.typeset a { color: hsl(var(--primary)); text-decoration: underline; } + +.typeset strong, .typeset b { font-weight: 700; } +.typeset em, .typeset i { font-style: italic; } +.typeset del { text-decoration: line-through; opacity: 0.7; } + +/* Task-list checkboxes (GFM) */ +.typeset input[type="checkbox"] { margin-right: 0.4rem; accent-color: hsl(var(--primary)); } + +/* Inline code */ +.typeset :not(pre) > code { + font-family: 'Fira Code', 'Cascadia Code', 'Courier New', Courier, monospace; + font-size: 0.875em; + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; + border: 1px solid hsl(var(--border)); + word-break: break-word; +} + +/* Code block */ +.typeset pre { + background: hsl(220 13% 12%); + color: hsl(220 14% 88%); + border-radius: 0.5rem; + padding: 0.875rem 1rem; + overflow-x: auto; + margin: 0.75rem 0; + border: 1px solid hsl(220 13% 22%); + -webkit-overflow-scrolling: touch; +} + +.typeset pre code { + background: none; + border: none; + padding: 0; + font-size: 0.875rem; + color: inherit; + white-space: pre; + word-break: normal; + font-family: 'Fira Code', 'Cascadia Code', 'Courier New', Courier, monospace; +} + +/* Blockquote */ +.typeset blockquote { + border-left: 3px solid hsl(var(--primary)); + margin: 0.75rem 0; + padding: 0.4rem 0 0.4rem 1rem; + color: hsl(var(--muted-foreground)); + font-style: italic; +} +.typeset blockquote p { margin-bottom: 0; } + +/* Horizontal rule */ +.typeset hr { + border: none; + border-top: 1px solid hsl(var(--border)); + margin: 1.25rem 0; +} + +/* Document-link badge — inserted by the WYSIWYG editor's "embed document" tool */ +.typeset a.doc-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.1rem 0.4rem; + border-radius: 0.375rem; + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + font-size: 0.75rem; + text-decoration: none; + border: 1px solid hsl(var(--border)); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.typeset a.doc-link::before { + content: "📄"; + font-size: 0.7rem; + flex-shrink: 0; +} + +/* Table — scrollable on mobile */ +.typeset table { + display: block; + width: 100%; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + border-collapse: separate; + border-spacing: 0; + margin: 0.6rem 0; + font-size: 0.75rem; + table-layout: auto; + border: 1.5px solid #cbd5e1; + border-radius: 0.375rem; +} + +.typeset th { + background: #f1f5f9; + color: #1e293b; + font-weight: 600; + text-align: left; + padding: 0.35rem 0.5rem; + white-space: nowrap; + box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1; +} + +.typeset td { + padding: 0.35rem 0.5rem; + vertical-align: top; + word-break: break-word; + min-width: 4rem; + box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1; +} + +/* Zebra-striping only on read-only renders, not while a table is being edited */ +.typeset:not([contenteditable]) tbody tr:nth-child(even) td { background: #f8fafc; } + +/* Table-cell focus ring, only relevant inside the contentEditable surface */ +.typeset[contenteditable] td:focus, +.typeset[contenteditable] th:focus { + outline: 2px solid hsl(var(--ring)); + outline-offset: -2px; + background: hsl(var(--accent) / 0.2); +} + +/* ── ≥320px ─────────────────────────────────────────────────────────────── */ + +@media (min-width: 320px) { + .typeset h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; } + .typeset h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; } + .typeset h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; } + + .typeset p { + font-size: 1rem; + line-height: 1.7; + text-align: justify; + margin: 0 0 0.8rem 0; + } + + .typeset ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; } + .typeset ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; } + .typeset li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; } + + .typeset a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; } + + .typeset table { font-size: 0.8125rem; margin: 0.7rem 0; } + .typeset th { padding: 0.45rem 0.65rem; } + .typeset td { padding: 0.4rem 0.65rem; min-width: 5rem; } +} + +/* ── Desktop (≥1024px) ──────────────────────────────────────────────────── */ + +@media (min-width: 1024px) { + .typeset h1 { font-size: 1.875rem; margin: 1.25rem 0 0.5rem; } + .typeset h2 { font-size: 1.5rem; margin: 1.25rem 0 0.5rem; } + .typeset h3 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; } + + .typeset p { font-size: 1.08rem; line-height: 1.75; margin: 0 0 0.85rem 0; } + + .typeset ul { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; } + .typeset ol { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; } + .typeset li { font-size: 1.08em; line-height: 1.75; margin-bottom: 0.4rem; } + + .typeset a.doc-link { font-size: 0.8125rem; padding: 0.1rem 0.5rem; gap: 0.3rem; } + + .typeset table { + display: table; + font-size: 0.875rem; + margin: 0.75rem 0; + table-layout: fixed; + } + + .typeset th { padding: 0.5rem 0.75rem; } + .typeset td { padding: 0.45rem 0.75rem; min-width: 2rem; } +} diff --git a/src/utils/assetUpload.util.js b/src/utils/assetUpload.util.js index 7d726a3..416af64 100644 --- a/src/utils/assetUpload.util.js +++ b/src/utils/assetUpload.util.js @@ -28,6 +28,11 @@ const ALLOWED_DOCUMENT_EXTENSIONS = new Set([ "zip", "json", "rtf", "txt", "csv", "md", ]); +// Matches asset_upload.middleware.js on the backend — rejecting an oversized +// file here means the queue shows a clear "invalid" reason instantly instead +// of a job that uploads for a while and then fails with a generic error. +export const MAX_ASSET_FILE_SIZE = 500 * 1024 * 1024; // 500 MB + export function fileExtension(filename = "") { const dot = filename.lastIndexOf("."); return dot > 0 ? filename.slice(dot + 1).toLowerCase() : ""; @@ -35,6 +40,13 @@ export function fileExtension(filename = "") { // { ok: true } or { ok: false, reason } export function validateAssetFile(file) { + if (file.size > MAX_ASSET_FILE_SIZE) { + return { + ok: false, + reason: `File exceeds the ${MAX_ASSET_FILE_SIZE / (1024 * 1024)} MB size limit.`, + }; + } + const mime = file.type || ""; if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) { diff --git a/src/utils/table.util.jsx b/src/utils/table.util.jsx index c8f0a7f..037ae10 100644 --- a/src/utils/table.util.jsx +++ b/src/utils/table.util.jsx @@ -75,6 +75,10 @@ export const BADGE_STYLES = { // ── Asset — is_public ───────────────────────── "true": "bg-emerald-100 text-emerald-700 border-emerald-200", "false": "bg-zinc-100 text-zinc-600 border-zinc-200", + + // ── Generic boolean labels (BOOLEAN_FIELD_LABELS) ───────── + "Yes": "bg-emerald-100 text-emerald-700 border-emerald-200", + "No": "bg-zinc-100 text-zinc-600 border-zinc-200", }; function EnumBadge({ value }) { @@ -94,24 +98,28 @@ export function formatDate(value) { catch { return value; } } +// Human-readable labels for boolean-backed enum fields — [falseLabel, trueLabel]. +// Shared by renderCell (table cells) and ColumnFilter (filter dropdown), so a +// "true"/"false" boolean column never surfaces its raw value to the admin. +export const BOOLEAN_FIELD_LABELS = { + is_active: ["Not Active", "Active"], + is_verified: ["Not Verified", "Verified"], + is_public: ["Private", "Public"], + is_banned: ["Not Banned", "Banned"], + is_required: ["No", "Yes"], +}; + function renderCell(attr, value) { if (value === null || value === undefined || value === "") { return -; } - switch (attr.type) { - case "enum": - if (attr.field === "is_active") - return ; - else if (attr.field === "is_verified") - return ; - else if (attr.field === "is_public") - return ; - else if (attr.field === "is_banned") - return ; - else - return ; + case "enum": { + const boolLabels = BOOLEAN_FIELD_LABELS[attr.field]; + if (boolLabels) return ; + return ; + } case "date": return {formatDate(value)}; case "number": return {value}; default: return {value}; @@ -131,13 +139,16 @@ export function ColumnFilter({ column, attr }) { // ENUM (AUTO APPLY) // ───────────────────────────────────────────── if (attr.type === "enum") { + const boolLabels = BOOLEAN_FIELD_LABELS[attr.field]; + const choiceLabel = (c) => boolLabels ? boolLabels[c === "true" ? 1 : 0] : c; + return (