+1
-1
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -129,11 +129,12 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
// Clean object — no ...content spread so stale data never carries over.
|
||||
//
|
||||
// Fields saved:
|
||||
// asset_id — used by client for the secure token flow
|
||||
// url — used by admin player (direct src); ignored by client for S3
|
||||
// thumbnail_url — poster image for the video player
|
||||
// title — display name (shown in any title-aware blocks)
|
||||
// tag — file extension badge e.g. "MP4"
|
||||
// asset_id — used by client for the secure token flow
|
||||
// url — used by admin player (direct src); ignored by client for S3
|
||||
// thumbnail_url — poster image for the video player
|
||||
// title — display name (shown in any title-aware blocks)
|
||||
// tag — file extension badge e.g. "MP4"
|
||||
// duration_seconds — probed media length, read by duration.util.js on save
|
||||
//
|
||||
const handleSelect = (asset) => {
|
||||
onUpdate({
|
||||
@@ -143,6 +144,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
thumbnail_url: asset.thumbnail_url ?? null,
|
||||
title: asset.display_name,
|
||||
tag: asset.extension?.toUpperCase() ?? "",
|
||||
duration_seconds: Number(asset.duration) || 0,
|
||||
});
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
|
||||
@@ -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">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
<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"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
</>
|
||||
<div
|
||||
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"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!imgLeft && <ImageBlock content={content} />}
|
||||
</div>
|
||||
</>
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{imgLeft && <ImageBlock content={content} />}
|
||||
<div
|
||||
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"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
||||
}}
|
||||
/>
|
||||
{!vidLeft && <VideoBlock content={content} />}
|
||||
</div>
|
||||
</>
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{vidLeft && <VideoBlock content={content} />}
|
||||
<div
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ export const StaffGroupProvider = ({ children }) => {
|
||||
},
|
||||
}
|
||||
);
|
||||
return res.data;
|
||||
return res.data?.data;
|
||||
}),
|
||||
[membersRequest]
|
||||
);
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -18,10 +18,13 @@ function LessonSkeleton() {
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* lesson — { id, title, blocks[] }
|
||||
* loading — true while fetch is in-flight
|
||||
* 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,30 +139,43 @@ 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">
|
||||
<Hourglass className="size-4 shrink-0" />
|
||||
This lesson isn't part of a unit yet. Check back later.
|
||||
</div>
|
||||
) : (
|
||||
{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 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>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{hasUnit && (
|
||||
<UnitLessonsSidebar
|
||||
unitDetail={unitDetail}
|
||||
currentLessonUuid={uuid}
|
||||
onSelect={handleSelectSibling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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/")) {
|
||||
|
||||
+25
-14
@@ -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
|
||||
return <EnumBadge value={value} />;
|
||||
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,13 +139,16 @@ export function ColumnFilter({ column, attr }) {
|
||||
// ENUM (AUTO APPLY)
|
||||
// ─────────────────────────────────────────────
|
||||
if (attr.type === "enum") {
|
||||
const boolLabels = BOOLEAN_FIELD_LABELS[attr.field];
|
||||
const choiceLabel = (c) => boolLabels ? boolLabels[c === "true" ? 1 : 0] : c;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={inputValue || "__all__"}
|
||||
onValueChange={(v) => {
|
||||
const value = v === "__all__" ? "" : v;
|
||||
setInputValue(value);
|
||||
column.setFilterValue(value);
|
||||
column.setFilterValue(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs w-full">
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user