try to deploy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-13 21:27:56 +08:00
parent bfc4b9fc67
commit b1805cfe4d
87 changed files with 798 additions and 718 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&family=Fira+Code:wght@400..600&display=swap" rel="stylesheet">
<title>%VITE_APP_NAME%</title>
</head>
<body>
@@ -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),
};
}
+3 -3
View File
@@ -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: "" },
};
@@ -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);
@@ -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 ? (
<div className="min-h-[180px] px-3 py-3">
<style>{MARKDOWN_STYLES}</style>
{body.trim() ? (
<div className="md-body text-sm">
<div className="typeset text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
) : (
@@ -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"
/>
<style>{WYSIWYG_STYLES}</style>
</div>
{/* Table size picker */}
@@ -651,7 +407,7 @@ export function TextBlock({ content, onUpdate, blockId, readOnly = false }) {
if (readOnly) {
return (
<div
className="wysiwyg-preview text-sm"
className="typeset text-sm"
dangerouslySetInnerHTML={{ __html: content.body ?? "" }}
/>
);
@@ -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,
})}
/>
)}
@@ -134,6 +134,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
// 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);
@@ -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 (
<>
<style>{MARKDOWN_STYLES}</style>
<div className="md-body">
<div className="typeset">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
</>
);
}
@@ -1,16 +1,11 @@
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
export function TextBlock({ content }) {
if (!content.body) {
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
}
return (
<>
<style>{WYSIWYG_STYLES}</style>
<div
className="wysiwyg-preview text-sm"
className="typeset text-sm"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
</>
);
}
@@ -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 (
<>
<style>{WYSIWYG_STYLES}</style>
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{imgLeft && <ImageBlock content={content} />}
<div
className="wysiwyg-preview text-sm w-full"
className="typeset text-sm w-full"
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
/>
{!imgLeft && <ImageBlock content={content} />}
</div>
</>
);
}
@@ -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 (
<>
<style>{WYSIWYG_STYLES}</style>
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{vidLeft && <VideoBlock content={content} />}
<div
className="wysiwyg-preview text-sm w-full"
className="typeset text-sm w-full"
dangerouslySetInnerHTML={{
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
}}
/>
{!vidLeft && <VideoBlock content={content} />}
</div>
</>
);
}
@@ -49,7 +49,7 @@ import { cn } from "@/lib/utils";
* // With custom click handler
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, onClick: (e, navigate) => navigate(-1) },
* { label: "Home", icon: <House className="size-4" />, onClick: (e, navigate) => navigate("/admin") },
* { label: "Settings", to: `/admin/${adminId}/settings` },
* { label: "Profile" },
* ]}
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`h-8 text-sm w-[150px] justify-start font-normal gap-2 ${!value ? "text-muted-foreground" : ""}`}
>
<CalendarIcon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={(d) => { onChange(d ?? null); setOpen(false); }}
disabled={disabled}
initialFocus
/>
<div className="border-t px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => { onChange(new Date()); setOpen(false); }}
>
Today
</Button>
{value && (
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs text-muted-foreground"
onClick={() => { onChange(null); setOpen(false); }}
>
Clear
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -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 && (
<Button className="w-full gap-1.5" onClick={handleGo} disabled={navigating}>
<Button
variant="outline"
className={`w-full gap-1.5 ${getTypeButtonClasses(notification?.type)}`}
onClick={handleGo}
disabled={navigating}
>
{navigating ? <Spinner className="size-4" /> : <>{link.label} <ArrowRight className="size-3.5" /></>}
</Button>
)}
@@ -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);
@@ -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);
}
@@ -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"
>
<span className="font-medium">{attr?.name ?? f.id}:</span>
{String(f.value)}
{resolveFilterDisplay(f, fieldOptions)}
<button
onClick={() => onRemove(f.id)}
className="ml-0.5 hover:text-destructive transition-colors"
@@ -47,6 +47,11 @@ export default function DataTable({
const [filterState, setFilterState] = useState({
open: false, column: null, attr: null, data: [],
});
// Caches the { value, label } picklist fetched per field (e.g. createdBy's
// [{value: 56, label: "Obsequio, Russell..."}]) so ActiveFilterPills can
// show a name instead of a raw id once a filter is applied — the sheet
// itself only holds this data while open.
const [fieldOptions, setFieldOptions] = useState({});
const activeFilters = columnFilters.filter((f) => f.value !== "");
@@ -108,6 +113,7 @@ export default function DataTable({
const data = await onFetchFilterData(attr.field);
setFilterState({ open: true, column, attr, data });
setFieldOptions((prev) => ({ ...prev, [attr.field]: data }));
};
const table = useReactTable({
@@ -204,6 +210,7 @@ export default function DataTable({
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
fieldOptions={fieldOptions}
onClearAll={() => {
setColumnFilters([]);
filtersRef.current = [];
@@ -227,6 +234,7 @@ export default function DataTable({
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
fieldOptions={fieldOptions}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
@@ -31,6 +31,24 @@ export function getTypeAccent(type) {
return TYPE_ACCENT[type] ?? "bg-muted text-foreground";
}
// Outline-button variant of TYPE_ACCENT — same per-type color family, tuned
// for a bordered CTA (e.g. "View task list" / "Open Link") instead of a
// filled icon badge, so the action button reads as the same type as the
// notification it belongs to rather than a generic button for every type.
const TYPE_BUTTON_CLASSES = {
achievement: "border-yellow-300 text-yellow-700 hover:bg-yellow-50 dark:border-yellow-800 dark:text-yellow-400 dark:hover:bg-yellow-950/40",
course: "border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-950/40",
milestone: "border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/40",
task: "border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950/40",
announcement: "border-indigo-300 text-indigo-700 hover:bg-indigo-50 dark:border-indigo-800 dark:text-indigo-400 dark:hover:bg-indigo-950/40",
tier_expired: "border-amber-300 text-amber-700 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-400 dark:hover:bg-amber-950/40",
assessment: "border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-950/40",
};
export function getTypeButtonClasses(type) {
return TYPE_BUTTON_CLASSES[type] ?? "";
}
export function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
@@ -41,6 +59,21 @@ export function timeAgo(dateStr) {
return `${Math.floor(h / 24)}d ago`;
}
// Admin-authored links only ever get typed as either an internal path
// ("/course/123") or a bare/https URL — a host typed without a scheme
// (e.g. "example.com") isn't "/"-prefixed, so it would otherwise fall
// through to window.open() and resolve as a broken relative path instead of
// navigating off-site. Prepend https:// whenever no scheme is present.
export function normalizeExternalUrl(url) {
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url; // already has a scheme (https:, http:, mailto:, tel:, ...)
return `https://${url}`;
}
export function goToLink(url, navigate) {
if (url.startsWith("/")) navigate(url);
else window.open(normalizeExternalUrl(url), "_blank", "noopener,noreferrer");
}
// Resolves a course uuid to its numeric course_id — client course pages route by course_id, not uuid.
async function goToCourse(navigate, courseUuid) {
try {
@@ -61,6 +94,15 @@ async function goToCourse(navigate, courseUuid) {
export function resolveNotificationLink(type, data) {
if (!data) return null;
// An explicit admin-authored link (the broadcast form's "On Open" section)
// always wins over the type-based fallbacks below, same precedence the
// sticky banner uses — otherwise a notification with a configured button
// silently loses it whenever it's delivered as a regular notification
// instead of a sticky one.
if (data.linkUrl) {
return { label: data.linkLabel || "Open Link", go: (navigate) => goToLink(data.linkUrl, navigate) };
}
switch (type) {
case "course":
return data.courseUuid
+1 -1
View File
@@ -114,7 +114,7 @@ export function AssetsProvider({ children }) {
try {
return await fn();
} catch (err) {
const message = err?.response?.data?.message ?? "Something went wrong.";
const message = err?.response?.data?.message || err.message || "Something went wrong.";
toast(message);
return null;
} finally {
+2 -2
View File
@@ -232,7 +232,7 @@ export function LibraryProvider({ children }) {
(field) =>
request(async () => {
const { data } = await api.get(`${UNITS_BASE}/field-values`, { params: { field } });
return data;
return data?.data ?? [];
}),
[request],
);
@@ -411,7 +411,7 @@ export function LibraryProvider({ children }) {
(field) =>
request(async () => {
const { data } = await api.get(`${LESSONS_BASE}/field-values`, { params: { field } });
return data;
return data?.data ?? [];
}),
[request],
);
+2 -2
View File
@@ -268,10 +268,10 @@ export const UserProvider = ({ children }) => {
// ─── GET /api/admin/users/:id/activity ────────────────────────────────────
const fetchUserActivity = useCallback(
async (userId, { page = 1, limit = 20, action = undefined } = {}) => {
async (userId, { page = 1, limit = 20, action = undefined, from = undefined, to = undefined } = {}) => {
setActivityLoading(true);
try {
const res = await api.get(`${BASE}/users/${userId}/activity`, { params: { page, limit, action } });
const res = await api.get(`${BASE}/users/${userId}/activity`, { params: { page, limit, action, from, to } });
const d = res.data?.data;
setActivity(d?.activities ?? []);
setActivityPagination({
+1 -1
View File
@@ -110,7 +110,7 @@ export function UserGroupProvider({ children }) {
(field) =>
request(async () => {
const res = await api.get(`${BASE}/groups/field-values`, { params: { field } });
return res.data;
return res.data?.data;
}),
[request]
);
+1 -1
View File
@@ -150,7 +150,7 @@ export const StaffGroupProvider = ({ children }) => {
},
}
);
return res.data;
return res.data?.data;
}),
[membersRequest]
);
+2 -2
View File
@@ -110,7 +110,7 @@ export const StaffTaskProvider = ({ children }) => {
const res = await api.get(`${BASE}/task-lists/field-values`, {
params: { field, ...buildParams(args) },
});
return res.data;
return res.data?.data;
}),
[request]
);
@@ -257,7 +257,7 @@ export const StaffTaskProvider = ({ children }) => {
const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/field-values`, {
params: { field, ...buildParams(args) },
});
return res.data;
return res.data?.data;
}),
[request]
);
+8
View File
@@ -37,5 +37,13 @@ export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries(
ADVERTISEMENT_STATUSES.map((s) => [s.value, s])
);
// Filterable subset for the main (non-archived) list. "expired" is excluded —
// the expire_advertisements cron sweeps any ad past its end_date into the
// Archived table within a minute, so this list will practically never hold
// one; offering it as a filter here just returns an empty result.
export const ADVERTISEMENT_FILTERABLE_STATUSES = ADVERTISEMENT_STATUSES.filter(
(s) => s.value !== "expired"
);
// Max number of CTAs allowed per advertisement (matches backend normalizeCtas slice(0,2))
export const MAX_CTAS = 2;
+1
View File
@@ -15,6 +15,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "./styles/typeset.css";
@custom-variant dark (&:is(.dark *));
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import api from "@/utils/api.util";
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
import DataTable from "@/components/generic/Table/DataTable";
@@ -14,6 +16,7 @@ import { buildRowActions } from "../../config/advertisements/arc
import { buildSelectionActions } from "../../config/advertisements/archive/selection.config";
import { getTimestamp } from "@/utils/timestamp.util";
import { useDateFormat } from "@/hooks/useDateFormat";
export default function ArchivedAdvertisementsTable() {
const [restoreTarget, setRestoreTarget] = useState(null);
@@ -29,6 +32,7 @@ export default function ArchivedAdvertisementsTable() {
});
const navigate = useNavigate();
const { fmtDateTime } = useDateFormat();
const {
advertisements, attributes, pagination, setPagination, loading,
@@ -57,6 +61,22 @@ export default function ArchivedAdvertisementsTable() {
onDelete: (row) => setDeleteTarget(row),
});
// "Remove Expired" — the expireAdvertisements cron already auto-archives
// (soft-deletes) any ad past its end_date and stamps status: 'expired' on
// it before doing so, so this only ever targets naturally-expired ads,
// never ones an admin manually archived while still active/scheduled.
const handleRemoveExpired = async () => {
const { data } = await api.get("/admin/advertisements/archived", {
params: { page: 1, limit: 1000, filters: JSON.stringify([{ id: "status", value: "expired" }]) },
});
const ids = (data?.data?.data ?? []).map((a) => a.advertisement_id);
if (!ids.length) {
toast("No expired advertisements to remove.");
return;
}
setDeleteIds(ids);
};
const toolbarActions = buildToolbarActions({
fetchAdvertisements: fetchArchivedAdvertisements,
pagination,
@@ -65,6 +85,7 @@ export default function ArchivedAdvertisementsTable() {
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
onRemoveExpired: handleRemoveExpired,
});
const selectionActions = buildSelectionActions({
@@ -77,8 +98,8 @@ export default function ArchivedAdvertisementsTable() {
});
const columns = useMemo(
() => buildDataColumns(attributes, rowActions),
[attributes]
() => buildDataColumns(attributes, rowActions, fmtDateTime),
[attributes, fmtDateTime]
);
const handleRestoreSuccess = () => {
@@ -2,7 +2,6 @@
to Client side */
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
@@ -159,7 +158,7 @@ export function PreviewBlock({ block }) {
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true }) {
return (
<PhotoProvider
speed={() => 300}
@@ -176,8 +175,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
</div>
)}
>
<style>{WYSIWYG_STYLES}</style>
<LessonHeader lesson={lesson} />
{showHeader && <LessonHeader lesson={lesson} />}
{blocks.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
@@ -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) => <span className="text-xs text-muted-foreground">{fmtDateTime(info.getValue())}</span>,
])
);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
@@ -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: <Ban className="h-3.5 w-3.5" />,
label: "Remove Expired",
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
onClick: onRemoveExpired,
}] : []),
];
}
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`h-8 text-sm w-[150px] justify-start font-normal gap-2 ${!value ? "text-muted-foreground" : ""}`}
>
<CalendarIcon className="size-3.5 shrink-0" />
<span className="truncate">{label}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={value}
onSelect={(d) => { onChange(d ?? null); setOpen(false); }}
disabled={disabled}
initialFocus
/>
<div className="border-t px-3 py-2 flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 h-7 text-xs"
onClick={() => { onChange(new Date()); setOpen(false); }}
>
Today
</Button>
{value && (
<Button
variant="ghost"
size="sm"
className="flex-1 h-7 text-xs text-muted-foreground"
onClick={() => { onChange(null); setOpen(false); }}
>
Clear
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
// ─── 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 ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
</TooltipTrigger>
<TooltipContent side="left">
{fmtDateTime(ts)}
{timeAgo(ts)}
</TooltipContent>
</Tooltip>
) : (
@@ -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() {
</div>
{/* ─── Filter ──────────────────────────────────────────────── */}
<div className="flex items-center gap-3 bg-card border rounded-lg p-4">
<div className="flex flex-wrap items-end gap-3 bg-card border rounded-lg p-4">
<div className="flex flex-col gap-1 min-w-[180px]">
<span className="text-xs text-muted-foreground">Filter by action</span>
<Select value={action} onValueChange={setAction}>
@@ -91,10 +102,26 @@ export default function UserActivityPage() {
</SelectContent>
</Select>
</div>
{action !== "all" && (
<div className="flex items-end pt-5">
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setAction("all")}>
Clear
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">From</span>
<DatePickerButton value={from} onChange={setFrom} placeholder="Start date" />
</div>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">To</span>
<DatePickerButton value={to} onChange={setTo} placeholder="End date" disabled={from ? { before: from } : undefined} />
</div>
{(action !== "all" || from || to) && (
<div className="flex items-end">
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => { setAction("all"); setFrom(null); setTo(null); }}
>
Clear filters
</Button>
</div>
)}
@@ -187,10 +214,10 @@ function ActivityItem({ row }) {
{ts ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
</TooltipTrigger>
<TooltipContent side="left">
{fmtDateTime(ts)}
{timeAgo(ts)}
</TooltipContent>
</Tooltip>
) : (
@@ -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 }) {
<div className="border rounded-lg p-4 space-y-1">
<p className="text-sm font-medium mb-2">Scheduling & display</p>
<SummaryRow label="Start date" value={data.start_date} />
<SummaryRow label="End date" value={data.end_date} />
<SummaryRow label="Start date" value={data.start_date ? fmtDateTime(data.start_date) : null} />
<SummaryRow label="End date" value={data.end_date ? fmtDateTime(data.end_date) : null} />
<SummaryRow label="Order" value={data.order} />
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
</div>
@@ -693,7 +695,7 @@ export default function AddAdvertisement() {
<Button
type="button"
variant="outline"
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
onClick={stepIndex === 0 ? () => navigate("/admin/advertisements") : handleBack}
disabled={loading}
>
<ChevronLeft className="size-4" />
@@ -17,11 +17,11 @@ import {
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_FILTERABLE_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
import { PLACEMENT_MAP } from "@/data/placement.data";
import { TablePagination } from "@/components/generic/Table/TablePagination";
const PAGE_SIZE = 24;
const DEFAULT_PAGE_SIZE = 10;
export default function AdvertisementList() {
const navigate = useNavigate();
@@ -32,6 +32,7 @@ export default function AdvertisementList() {
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const buildFilters = () => {
const filters = [];
@@ -45,13 +46,14 @@ export default function AdvertisementList() {
// their state update with setPage(1) in the same handler so this only
// ever fires once per change (no separate "reset page" effect racing it).
useEffect(() => {
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
fetchAdvertisements({ page, limit: pageSize, filters: buildFilters() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typeFilter, statusFilter, search, page]);
}, [typeFilter, statusFilter, search, page, pageSize]);
const handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
const runSearch = () => { setSearch(searchInput); setPage(1); };
const handlePageSizeChange = (size) => { setPageSize(size); setPage(1); };
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -61,7 +63,6 @@ export default function AdvertisementList() {
const total = pagination?.totalRecords ?? advertisements.length;
const activeCount = advertisements.filter((a) => a.status === "active").length;
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
const expiredCount = advertisements.filter((a) => a.status === "expired").length;
async function handleArchive(advertisementId) {
await archiveAdvertisement(advertisementId);
@@ -95,11 +96,10 @@ export default function AdvertisementList() {
</div>
{/* ── Stat cards ─────────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<StatCard label="Total ads" value={total} />
<StatCard label="Active" value={activeCount} tone="success" />
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
<StatCard label="Expired" value={expiredCount} tone="muted" />
</div>
{/* ── Filters ────────────────────────────────────────────────── */}
@@ -122,7 +122,7 @@ export default function AdvertisementList() {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{ADVERTISEMENT_STATUSES.map((s) => (
{ADVERTISEMENT_FILTERABLE_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
@@ -170,6 +170,7 @@ export default function AdvertisementList() {
<TablePagination
pagination={pagination}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
rowCount={advertisements.length}
recordLabel="advertisement"
/>
@@ -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 (
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
@@ -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() {
</SectionCard>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate("/admin/advertisements")} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
+12 -3
View File
@@ -34,6 +34,11 @@ function resolveFileType(mimeType = "") {
return "document";
}
// Matches asset_upload.middleware.js on the backend — checked here too so an
// oversized file is rejected instantly instead of only after a full upload
// attempt round-trips to the server.
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
// ─── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({
@@ -156,6 +161,10 @@ export default function AddAsset() {
const fileType = file ? resolveFileType(file.type) : null;
const setFile = (f) => {
if (f.size > MAX_FILE_SIZE) {
setError("_file", { message: `File exceeds the ${MAX_FILE_SIZE / (1024 * 1024)} MB size limit.` });
return;
}
fileRef.current = f;
setValue("_file", f);
if (!watch("display_name")) setValue("display_name", f.name);
@@ -198,7 +207,7 @@ export default function AddAsset() {
});
setProgress(null);
if (result) { bypassOnce(); navigate(-1); }
if (result) { bypassOnce(); navigate("/admin/assets"); }
};
const progressLabel = {
@@ -213,7 +222,7 @@ export default function AddAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -380,7 +389,7 @@ export default function AddAsset() {
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
Cancel
@@ -152,7 +152,7 @@ export default function AddAssetsBulk() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -213,7 +213,7 @@ export default function AddAssetsBulk() {
{/* ── Actions ── */}
<div className="flex justify-end">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
<Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
Done
</Button>
</div>
+3 -3
View File
@@ -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 ── */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -334,7 +334,7 @@ export default function EditAsset() {
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
Cancel
@@ -44,7 +44,7 @@ export default function ViewAudioAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -64,7 +64,7 @@ export default function ViewAudioAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -113,8 +113,9 @@ export default function ViewAudioAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -42,7 +42,7 @@ export default function ViewDocumentAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -55,7 +55,7 @@ export default function ViewDocumentAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -116,8 +116,9 @@ export default function ViewDocumentAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -40,7 +40,7 @@ export default function ViewImageAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -52,7 +52,7 @@ export default function ViewImageAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -108,8 +108,9 @@ export default function ViewImageAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -49,7 +49,7 @@ export default function ViewVideoAsset() {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
}
@@ -61,7 +61,7 @@ export default function ViewVideoAsset() {
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/assets")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -143,8 +143,9 @@ export default function ViewVideoAsset() {
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created By" value={a.creator?.full_name ?? a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? fmtDateTime(a.createdAt) : null} />
<MetaRow label="Modified By" value={a.updater?.full_name ?? a.updatedBy} />
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
</div>
@@ -65,7 +65,7 @@ export default function AddCategory() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses/categories")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -100,7 +100,7 @@ export default function AddCategory() {
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/courses/categories")} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Create Category
@@ -55,7 +55,7 @@ export default function EditCategory() {
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (values) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate("/admin/courses/categories"); }
const result = await updateCategory(id, values);
if (!result) return;
bypassOnce();
@@ -76,7 +76,7 @@ export default function EditCategory() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses/categories")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -111,7 +111,7 @@ export default function EditCategory() {
</SectionCard>
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/courses/categories")} disabled={loading}>Cancel</Button>
<Button type="submit" disabled={loading || !isDirty}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
@@ -272,7 +272,7 @@ export default function AddCourse() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -653,7 +653,7 @@ export default function AddCourse() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/courses")}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
@@ -463,7 +463,7 @@ export default function CourseAssessment() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -478,7 +478,7 @@ export default function EditCourse() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -1121,7 +1121,7 @@ export default function EditCourse() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(`/admin/courses/${courseId}/view`)}
>
<ArrowLeft className="h-4 w-4 mr-2" />
{currentStep === 0 ? "Cancel" : "Back"}
@@ -382,7 +382,7 @@ export default function ViewAssessment() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -346,7 +346,7 @@ export default function ViewCourse() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/courses")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -67,7 +67,7 @@ export default function AddLesson() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -146,7 +146,7 @@ export default function AddLesson() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -62,7 +62,7 @@ export default function EditLesson() {
}, [courseId, unitId, lessonId]);
const onSubmit = async (data) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`); }
const result = await updateLesson(courseId, unitId, lessonId, {
...data,
objectives: data.objectives?.map((o, i) => ({
@@ -74,7 +74,7 @@ export default function EditLesson() {
});
if (!result) return;
bypassOnce();
navigate(-1);
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`);
};
return (
@@ -84,7 +84,7 @@ export default function EditLesson() {
<div className="w-full max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -163,7 +163,7 @@ export default function EditLesson() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !isDirty}>
@@ -28,6 +28,12 @@ export default function LessonPageBuilder() {
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
const { user } = useAuth();
// Junction revamp — this builder runs course-scoped AND from the standalone
// Lesson Library (/admin/lessons/:lessonId/page, no :courseId/:unitId params).
const pageViewPath = unitId
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page/view`
: `/admin/lessons/${lessonId}/page/view`;
const [blocks, setBlocks] = useState([]);
const [initializing, setInitializing] = useState(true);
const [previewVisible, setPreviewVisible] = useState(true);
@@ -105,7 +111,7 @@ export default function LessonPageBuilder() {
updatedBy: user?.user_id,
});
if (!result) return;
navigate(-1);
navigate(pageViewPath);
};
const editorStyle = previewVisible
@@ -124,7 +130,7 @@ export default function LessonPageBuilder() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(pageViewPath)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -152,7 +158,7 @@ export default function LessonPageBuilder() {
<Eye className="h-4 w-4" />
Preview
</Button>
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(pageViewPath)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSave} disabled={loading}>
@@ -51,7 +51,7 @@ export default function ViewLesson() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -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() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(viewLessonPath)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -58,7 +58,7 @@ export default function AddUnit() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -89,7 +89,7 @@ export default function AddUnit() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -56,7 +56,7 @@ export default function EditUnit() {
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
const onSubmit = async (data) => {
if (!isDirty) { bypassOnce(); return navigate(-1); }
if (!isDirty) { bypassOnce(); return navigate(`/admin/courses/${courseId}/units/${unitId}/view`); }
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
if (!result) return;
bypassOnce();
@@ -70,7 +70,7 @@ export default function EditUnit() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -101,7 +101,7 @@ export default function EditUnit() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/courses/${courseId}/units/${unitId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !isDirty}>
@@ -419,7 +419,7 @@ export default function ModifyQuiz() {
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
navigate(-1);
navigate(`${scopeBase}/view`);
};
// ── Render ─────────────────────────────────────────────────────────────────
@@ -435,7 +435,7 @@ export default function ModifyQuiz() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`${scopeBase}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -281,7 +281,7 @@ export default function ViewUnitQuiz() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`${scopeBase}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -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() {
<div className="w-full max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -62,7 +62,7 @@ export default function EditLibraryLesson() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/lessons/${lessonId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -90,7 +90,7 @@ export default function EditLibraryLesson() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -407,7 +407,7 @@ export default function AddLibraryUnit() {
};
const handleBack = () => {
if (step === 0) navigate(-1);
if (step === 0) navigate("/admin/units");
else setStep((s) => s - 1);
};
@@ -446,7 +446,7 @@ export default function AddLibraryUnit() {
<div className="w-full max-w-3xl mx-auto space-y-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/units")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -76,7 +76,7 @@ export default function EditLibraryUnit() {
<div className="w-full max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/units/${unitId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -127,7 +127,7 @@ export default function EditLibraryUnit() {
</div>
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
<Button type="button" variant="outline" onClick={() => navigate(`/admin/units/${unitId}/view`)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
@@ -25,6 +25,13 @@ import { DateTimePicker } from "@/components/ui/date-time-picker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
// Internal paths ("/course/123") pass through untouched — everything else
// gets a scheme so the saved URL always matches what goToLink() will open,
// instead of relying on client-side normalization to paper over a bare
// "example.com" the admin typed.
const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : normalizeExternalUrl(raw));
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -233,7 +240,7 @@ export default function AddNotificationBroadcast() {
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
start_date: values.start_date || null,
@@ -554,7 +561,7 @@ export default function AddNotificationBroadcast() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/announcements")}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
@@ -25,6 +25,13 @@ import { DateTimePicker } from "@/components/ui/date-time-picker";
import { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
import { normalizeExternalUrl } from "@/components/generic/notificationDisplay";
// Internal paths ("/course/123") pass through untouched — everything else
// gets a scheme so the saved URL always matches what goToLink() will open,
// instead of relying on client-side normalization to paper over a bare
// "example.com" the admin typed.
const normalizeLinkUrl = (raw) => (!raw ? null : raw.startsWith("/") ? raw : normalizeExternalUrl(raw));
// ─── Schema ─────────────────────────────────────────────────────────────────
@@ -266,7 +273,7 @@ export default function EditNotificationBroadcast() {
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
show_in_sticky: values.show_in_sticky ?? false,
show_in_notifications: values.show_in_notifications ?? true,
link_url: (values.show_in_sticky && link_mode === "link") ? values.link_url.trim() : null,
link_url: (values.show_in_sticky && link_mode === "link") ? normalizeLinkUrl(values.link_url.trim()) : null,
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
start_date: values.start_date || null,
@@ -590,7 +597,7 @@ export default function EditNotificationBroadcast() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/announcements")}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
@@ -115,7 +115,7 @@ export default function ViewTaskCompletion() {
<div className="flex items-center gap-3 my-6 w-full">
<Button
variant="ghost" size="icon"
onClick={() => navigate(-1)}
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions`)}
className="shrink-0"
>
<ArrowLeft className="size-4" />
+2 -2
View File
@@ -233,7 +233,7 @@ export default function AddPlan() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/plans")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -403,7 +403,7 @@ export default function AddPlan() {
<Button
type="button"
variant="outline"
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/tiers/plans")}
disabled={loading}
>
{currentStep === 0 ? "Cancel" : "Back"}
+2 -2
View File
@@ -198,7 +198,7 @@ export default function EditPlan() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/plans")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -348,7 +348,7 @@ export default function EditPlan() {
)}
<div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/plans")} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
@@ -225,7 +225,7 @@ function EditTierCategoryInner({ isAdd }) {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/categories")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -374,7 +374,7 @@ function EditTierCategoryInner({ isAdd }) {
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
<Button type="button" variant="outline" onClick={() => navigate("/admin/tiers/categories")} disabled={loading}>Cancel</Button>
<Button type="button" onClick={handleSave} disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
{isAdd ? "Create Category" : "Save Changes"}
@@ -158,7 +158,7 @@ export default function PaymentPolicy() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/tiers/plans/${planId}/view`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -426,7 +426,7 @@ export default function PaymentPolicy() {
{/* ── Save ─────────────────────────────────────────────────── */}
<div className="flex justify-end gap-3 pt-1">
<Button variant="outline" onClick={() => navigate(-1)} disabled={saving}>
<Button variant="outline" onClick={() => navigate(`/admin/tiers/plans/${planId}/view`)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
@@ -126,7 +126,7 @@ export default function UserTierList() {
<div className="flex items-start justify-between gap-3 mb-6">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users/view/${userId}`)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -144,7 +144,7 @@ export default function ViewPayment() {
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/payments")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
+1 -1
View File
@@ -772,7 +772,7 @@ export default function ViewPlan() {
>
<div className="lg:container lg:mx-auto lg:px-6 px-4">
<div className="flex items-center gap-3 py-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/admin/tiers/plans")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
@@ -459,7 +459,7 @@ export default function AddStaffUserPage() {
<Button
type="button"
variant="outline"
onClick={step === 0 ? () => navigate(-1) : () => setStep(0)}
onClick={step === 0 ? () => navigate("/admin/users") : () => setStep(0)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
{step === 0 ? "Cancel" : "Back"}
@@ -20,8 +20,11 @@ function LessonSkeleton() {
* Props:
* lesson — { id, title, blocks[] }
* loading — true while fetch is in-flight
* showHeader — render the title/description/objectives block. Set false
* when the caller already renders its own lesson header
* above (e.g. LessonDetails), to avoid showing it twice.
*/
const LessonBlock = ({ lesson, loading = false }) => {
const LessonBlock = ({ lesson, loading = false, showHeader = true }) => {
if (!lesson && !loading) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
@@ -41,6 +44,7 @@ const LessonBlock = ({ lesson, loading = false }) => {
lesson={lesson}
blocks={lesson.blocks ?? []}
empty="No content blocks yet."
showHeader={showHeader}
/>
</div>
</PreviewChrome>
@@ -46,7 +46,7 @@ export default function AdvertisementLandingPage() {
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 flex flex-col items-center text-center gap-3 py-16">
<Megaphone className="size-8 text-muted-foreground" />
<p className="font-medium">This advertisement is no longer available.</p>
<Button variant="outline" onClick={() => navigate(-1)}>
<Button variant="outline" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="size-4" /> Go back
</Button>
</div>
@@ -61,7 +61,7 @@ export default function AdvertisementLandingPage() {
<div className="my-20 lg:container lg:mx-auto max-w-3xl px-4 space-y-6 pb-16">
<PageMeta title={page.title ? `${page.title} - STARR` : undefined} description={page.description} />
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate(-1)}>
<Button variant="ghost" size="sm" className="w-fit -ml-2" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="size-4" /> Back
</Button>
+1 -1
View File
@@ -108,7 +108,7 @@ export default function CourseCheckout() {
This course doesn't have an individual purchase option.
</p>
</div>
<Button onClick={() => navigate(-1)}>
<Button onClick={() => navigate(`/course/${courseId}`)}>
<ArrowLeft className="size-4" /> Go Back
</Button>
</CardContent>
+68 -76
View File
@@ -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 (
<aside className="lg:w-80 shrink-0 bg-muted xs:px-4 xs:py-8 lg:px-4 lg:py-8 flex flex-col gap-3">
<span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{unitDetail.title}</span>
<div className="flex flex-col gap-1">
{lessons.map((l) => {
const isCurrent = l.uuid === currentLessonUuid;
const completed = l.status === "completed";
return (
<div
key={l.lesson_id}
onClick={() => onSelect(l)}
className={cn(
"flex items-center justify-between gap-2 py-2.5 px-3 rounded-lg cursor-pointer text-sm transition-colors",
isCurrent
? "bg-background border shadow-sm font-medium text-card-foreground"
: "hover:bg-background/60"
)}
>
<span className={cn(
"truncate",
!isCurrent && !completed && "text-muted-foreground"
)}>
{l.title}
</span>
{completed ? (
<Check className="size-4 text-emerald-500 shrink-0" />
) : formatDuration(l.duration_seconds) && (
<span className={cn(
"text-xs shrink-0",
isCurrent ? "text-blue-600 dark:text-blue-400" : "text-muted-foreground"
)}>
{formatDuration(l.duration_seconds)}
</span>
)}
</div>
);
})}
</div>
</aside>
);
};
// ─── 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: <House className="size-4" />, 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 <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
@@ -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 (
<div className="flex-1 flex flex-col">
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
<div
className="flex-1 flex flex-col lg:flex-row items-stretch"
className="flex-1 flex flex-col"
style={{ paddingTop: "var(--navbar-h)" }}
>
<div className="flex flex-col gap-6 flex-1 min-w-0 xs:px-4 xs:py-8 lg:px-16 lg:py-10">
<AppBreadcrumb items={items} />
<div className="flex flex-col gap-3 max-w-2xl">
<Badge variant="secondary" className="w-fit uppercase tracking-wide gap-1.5 px-2.5 py-1">
<badge.icon className="size-3" />
{badge.label}
</Badge>
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
{!hasCourse && (
<div className="flex items-center gap-2 text-sm text-muted-foreground [&_svg]:size-4">
{formatDuration(lesson.duration_seconds) && (
<>
<span className="flex items-center gap-1"><Clock /> {formatDuration(lesson.duration_seconds)}</span>
<span>·</span>
</>
)}
<span className="flex items-center gap-1">
<Video /> {isVideoLesson ? "Video lesson" : "Reading lesson"}
</span>
</div>
)}
</div>
{lesson.objectives?.length > 0 && (
@@ -160,31 +139,44 @@ const LessonDetails = () => {
</div>
)}
<div className="w-fit">
{!hasUnit ? (
<div className="flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
{contentNotReady ? (
<div className="w-fit flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
<Hourglass className="size-4 shrink-0" />
This lesson isn't part of a unit yet. Check back later.
This lesson is currently being prepared. Please check back later.
</div>
) : (
) : hasCourse ? (
<div className="w-fit">
<Button className="w-fit bg-blue-500" onClick={handleStart}>
{hasCompleted
? <><CheckCheck /> Start Again</>
: <><SendHorizonal /> Start Lesson</>
}
</Button>
</div>
) : (
<>
<div className="w-full">
<LessonBlock lesson={lesson} loading={lessonLoading} showHeader={false} />
</div>
<div className="flex items-center justify-between gap-4 max-w-2xl">
<p className="text-sm text-muted-foreground">
{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."
}
</p>
<Button
className="shrink-0 bg-blue-500"
disabled={hasCompleted}
onClick={handleMarkComplete}
>
{hasCompleted ? <><CheckCheck /> Completed</> : "Mark as Complete"}
</Button>
</div>
</>
)}
</div>
</div>
{hasUnit && (
<UnitLessonsSidebar
unitDetail={unitDetail}
currentLessonUuid={uuid}
onSelect={handleSelectSibling}
/>
)}
</div>
</div>
);
};
+1 -1
View File
@@ -26,7 +26,7 @@ export default function MyAchievements() {
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
+1 -1
View File
@@ -120,7 +120,7 @@ export default function MyCertificates() {
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
+4 -4
View File
@@ -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() {
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate("/dashboard")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
@@ -214,8 +214,8 @@ export default function Notifications() {
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4.5 w-4.5 text-muted-foreground" />
<div className={cn("mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full", getTypeAccent(n.type))}>
<NotificationIcon type={n.type} className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
+9 -1
View File
@@ -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 (
+9 -1
View File
@@ -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 (
+213
View File
@@ -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; }
}
+12
View File
@@ -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/")) {
+23 -12
View File
@@ -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 <span className="text-muted-foreground/40">-</span>;
}
switch (attr.type) {
case "enum":
if (attr.field === "is_active")
return <EnumBadge value={value ? "Active" : "Not Active"} />;
else if (attr.field === "is_verified")
return <EnumBadge value={value ? "Verified" : "Not Verified"} />;
else if (attr.field === "is_public")
return <EnumBadge value={value ? "Public" : "Private"} />;
else if (attr.field === "is_banned")
return <EnumBadge value={value ? "Banned" : "Not Banned"} />;
else
case "enum": {
const boolLabels = BOOLEAN_FIELD_LABELS[attr.field];
if (boolLabels) return <EnumBadge value={boolLabels[value ? 1 : 0]} />;
return <EnumBadge value={value} />;
}
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
default: return <span className="text-sm">{value}</span>;
@@ -131,6 +139,9 @@ 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 (
<Select
value={inputValue || "__all__"}
@@ -148,7 +159,7 @@ export function ColumnFilter({ column, attr }) {
<SelectItem value="__all__">All</SelectItem>
{attr.options?.choices?.map((c) => (
<SelectItem key={c} value={c}>
{c}
{choiceLabel(c)}
</SelectItem>
))}
</SelectContent>