mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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>
|
<title>%VITE_APP_NAME%</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||||
|
import { goToLink } from "@/components/generic/notificationDisplay";
|
||||||
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
||||||
|
|
||||||
const ROTATE_INTERVAL_MS = 6000;
|
const ROTATE_INTERVAL_MS = 6000;
|
||||||
@@ -17,9 +18,7 @@ function resolveClickAction(stickyAnnouncement) {
|
|||||||
if (!linkUrl) return null;
|
if (!linkUrl) return null;
|
||||||
return {
|
return {
|
||||||
label: stickyAnnouncement.data?.linkLabel || "Open Link",
|
label: stickyAnnouncement.data?.linkLabel || "Open Link",
|
||||||
go: (navigate) => (linkUrl.startsWith("/")
|
go: (navigate) => goToLink(linkUrl, navigate),
|
||||||
? navigate(linkUrl)
|
|
||||||
: window.open(linkUrl, "_blank", "noopener,noreferrer")),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export const DEFAULT_CONTENT = {
|
|||||||
"text": { body: "" },
|
"text": { body: "" },
|
||||||
"image": { asset_id: null, url: "", alt: "" },
|
"image": { asset_id: null, url: "", alt: "" },
|
||||||
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
|
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
|
||||||
"video": { asset_id: null, url: "", thumbnail_url: "" },
|
"video": { asset_id: null, url: "", thumbnail_url: "", duration_seconds: 0 },
|
||||||
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
|
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right", duration_seconds: 0 },
|
||||||
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "" },
|
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
|
||||||
"code": { language: "javascript", code: "" },
|
"code": { language: "javascript", code: "" },
|
||||||
"markdown": { body: "" },
|
"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
|
// artist — cleared on new pick so stale artist doesn't carry over
|
||||||
// thumbnail — cover art from asset.thumbnail_url
|
// thumbnail — cover art from asset.thumbnail_url
|
||||||
// tag — file extension badge e.g. "MP3"
|
// tag — file extension badge e.g. "MP3"
|
||||||
|
// duration_seconds — probed media length, read by duration.util.js on save
|
||||||
//
|
//
|
||||||
const handleSelect = (asset) => {
|
const handleSelect = (asset) => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
@@ -158,6 +159,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
artist: "",
|
artist: "",
|
||||||
thumbnail: asset.thumbnail_url ?? null,
|
thumbnail: asset.thumbnail_url ?? null,
|
||||||
tag: asset.extension?.toUpperCase() ?? "",
|
tag: asset.extension?.toUpperCase() ?? "",
|
||||||
|
duration_seconds: Number(asset.duration) || 0,
|
||||||
});
|
});
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
|
|||||||
@@ -8,124 +8,6 @@ import {
|
|||||||
Link2, List, ListOrdered, Quote, Minus, Eye, Pencil,
|
Link2, List, ListOrdered, Quote, Minus, Eye, Pencil,
|
||||||
} from "lucide-react";
|
} 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 ───────────────────────────────────────────────────────────
|
// ─── Toolbar button ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ToolbarBtn({ title, onClick, children, active }) {
|
function ToolbarBtn({ title, onClick, children, active }) {
|
||||||
@@ -248,9 +130,8 @@ export function MarkdownBlock({ content, onUpdate }) {
|
|||||||
{/* ── Edit / Preview ── */}
|
{/* ── Edit / Preview ── */}
|
||||||
{preview ? (
|
{preview ? (
|
||||||
<div className="min-h-[180px] px-3 py-3">
|
<div className="min-h-[180px] px-3 py-3">
|
||||||
<style>{MARKDOWN_STYLES}</style>
|
|
||||||
{body.trim() ? (
|
{body.trim() ? (
|
||||||
<div className="md-body text-sm">
|
<div className="typeset text-sm">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -45,248 +45,6 @@ const FORMAT_OPTIONS = [
|
|||||||
{ label: "Heading 3", val: "h3" },
|
{ 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 ─────────────────────────────────────────────────────
|
// ─── Table picker popover ─────────────────────────────────────────────────────
|
||||||
// A small grid that lets the user hover to choose rows × columns (up to 8×8),
|
// 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.
|
// then click to confirm. Rendered inline in the toolbar.
|
||||||
@@ -618,10 +376,8 @@ export function RichTextEditor({ blockId, value, onChange, readOnly = false }) {
|
|||||||
contentEditable
|
contentEditable
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
onInput={() => onChange(editorRef.current.innerHTML)}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Table size picker */}
|
{/* Table size picker */}
|
||||||
@@ -651,7 +407,7 @@ export function TextBlock({ content, onUpdate, blockId, readOnly = false }) {
|
|||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="wysiwyg-preview text-sm"
|
className="typeset text-sm"
|
||||||
dangerouslySetInnerHTML={{ __html: content.body ?? "" }}
|
dangerouslySetInnerHTML={{ __html: content.body ?? "" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false })
|
|||||||
storage_provider: asset.storage_provider ?? null,
|
storage_provider: asset.storage_provider ?? null,
|
||||||
url: asset.file_url,
|
url: asset.file_url,
|
||||||
thumbnail_url: asset.thumbnail_url ?? null,
|
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.
|
// Clean object — no ...content spread so stale data never carries over.
|
||||||
//
|
//
|
||||||
// Fields saved:
|
// Fields saved:
|
||||||
// asset_id — used by client for the secure token flow
|
// asset_id — used by client for the secure token flow
|
||||||
// url — used by admin player (direct src); ignored by client for S3
|
// url — used by admin player (direct src); ignored by client for S3
|
||||||
// thumbnail_url — poster image for the video player
|
// thumbnail_url — poster image for the video player
|
||||||
// title — display name (shown in any title-aware blocks)
|
// title — display name (shown in any title-aware blocks)
|
||||||
// tag — file extension badge e.g. "MP4"
|
// tag — file extension badge e.g. "MP4"
|
||||||
|
// duration_seconds — probed media length, read by duration.util.js on save
|
||||||
//
|
//
|
||||||
const handleSelect = (asset) => {
|
const handleSelect = (asset) => {
|
||||||
onUpdate({
|
onUpdate({
|
||||||
@@ -143,6 +144,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
|||||||
thumbnail_url: asset.thumbnail_url ?? null,
|
thumbnail_url: asset.thumbnail_url ?? null,
|
||||||
title: asset.display_name,
|
title: asset.display_name,
|
||||||
tag: asset.extension?.toUpperCase() ?? "",
|
tag: asset.extension?.toUpperCase() ?? "",
|
||||||
|
duration_seconds: Number(asset.duration) || 0,
|
||||||
});
|
});
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { MARKDOWN_STYLES } from "@/components/generic/Blocks/Admin/MarkdownBlock";
|
|
||||||
|
|
||||||
export function MarkdownBlock({ content }) {
|
export function MarkdownBlock({ content }) {
|
||||||
const body = content.body ?? "";
|
const body = content.body ?? "";
|
||||||
@@ -14,11 +13,8 @@ export function MarkdownBlock({ content }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="typeset">
|
||||||
<style>{MARKDOWN_STYLES}</style>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||||
<div className="md-body">
|
</div>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
|
||||||
|
|
||||||
export function TextBlock({ content }) {
|
export function TextBlock({ content }) {
|
||||||
if (!content.body) {
|
if (!content.body) {
|
||||||
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<style>{WYSIWYG_STYLES}</style>
|
className="typeset text-sm"
|
||||||
<div
|
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||||
className="wysiwyg-preview text-sm"
|
/>
|
||||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
|
||||||
import { ImageBlock } from "./ImageBlock";
|
import { ImageBlock } from "./ImageBlock";
|
||||||
|
|
||||||
export function TextImageBlock({ content }) {
|
export function TextImageBlock({ content }) {
|
||||||
const imgLeft = content.image_position === "left";
|
const imgLeft = content.image_position === "left";
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||||
<style>{WYSIWYG_STYLES}</style>
|
{imgLeft && <ImageBlock content={content} />}
|
||||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
<div
|
||||||
{imgLeft && <ImageBlock content={content} />}
|
className="typeset text-sm w-full"
|
||||||
<div
|
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||||
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>
|
||||||
{!imgLeft && <ImageBlock content={content} />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
|
||||||
import { VideoBlock } from "./VideoBlock";
|
import { VideoBlock } from "./VideoBlock";
|
||||||
|
|
||||||
export function TextVideoBlock({ content }) {
|
export function TextVideoBlock({ content }) {
|
||||||
const vidLeft = content.video_position === "left";
|
const vidLeft = content.video_position === "left";
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||||
<style>{WYSIWYG_STYLES}</style>
|
{vidLeft && <VideoBlock content={content} />}
|
||||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
<div
|
||||||
{vidLeft && <VideoBlock content={content} />}
|
className="typeset text-sm w-full"
|
||||||
<div
|
dangerouslySetInnerHTML={{
|
||||||
className="wysiwyg-preview text-sm w-full"
|
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
||||||
dangerouslySetInnerHTML={{
|
}}
|
||||||
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
/>
|
||||||
}}
|
{!vidLeft && <VideoBlock content={content} />}
|
||||||
/>
|
</div>
|
||||||
{!vidLeft && <VideoBlock content={content} />}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ import { cn } from "@/lib/utils";
|
|||||||
* // With custom click handler
|
* // With custom click handler
|
||||||
* <AppBreadcrumb
|
* <AppBreadcrumb
|
||||||
* items={[
|
* 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: "Settings", to: `/admin/${adminId}/settings` },
|
||||||
* { label: "Profile" },
|
* { 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 { Separator } from "@/components/ui/separator";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
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 }) {
|
export default function NotificationDetailDialog({ notification, onOpenChange }) {
|
||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
@@ -81,7 +81,12 @@ export default function NotificationDetailDialog({ notification, onOpenChange })
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{link && (
|
{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" /></>}
|
{navigating ? <Spinner className="size-4" /> : <>{link.label} <ArrowRight className="size-3.5" /></>}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { Spinner } from "@/components/ui/spinner";
|
|||||||
const FIELD_DISPLAY_MAP = {
|
const FIELD_DISPLAY_MAP = {
|
||||||
is_active: { true: "Active", false: "Inactive" },
|
is_active: { true: "Active", false: "Inactive" },
|
||||||
is_verified: { true: "Verified", false: "Not Verified" },
|
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);
|
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
|
||||||
|
|||||||
@@ -9,19 +9,10 @@ import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouse
|
|||||||
|
|
||||||
const ROTATE_INTERVAL_MS = 6000;
|
const ROTATE_INTERVAL_MS = 6000;
|
||||||
|
|
||||||
// Explicit link_url (from the admin "On Open" section) always wins, using the
|
// resolveNotificationLink already gives explicit link_url (the admin "On
|
||||||
// admin-authored button label when set. Falls back to the type-based resolver
|
// Open" section) precedence over the type-based fallbacks, for broadcasts
|
||||||
// for broadcasts sent before that field existed.
|
// sent before that field existed.
|
||||||
function resolveClickAction(stickyAnnouncement) {
|
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);
|
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
import { Filter, X } from "lucide-react";
|
import { Filter, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
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
|
* ActiveFilterPills
|
||||||
@@ -9,6 +45,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
* Props:
|
* Props:
|
||||||
* @param {Array} filters - Array of { id, value } (TanStack columnFilters shape)
|
* @param {Array} filters - Array of { id, value } (TanStack columnFilters shape)
|
||||||
* @param {Array} attributes - Array of { field, name } for display labels
|
* @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} onRemove - (id: string) => void — remove a single filter
|
||||||
* @param {Function} [onClearAll] - () => void — clear all filters
|
* @param {Function} [onClearAll] - () => void — clear all filters
|
||||||
* @param {boolean} [showClearButton]- Render the "Clear filters (n)" button instead of pills
|
* @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({
|
export function ActiveFilterPills({
|
||||||
filters = [],
|
filters = [],
|
||||||
attributes = [],
|
attributes = [],
|
||||||
|
fieldOptions = {},
|
||||||
onRemove,
|
onRemove,
|
||||||
onClearAll,
|
onClearAll,
|
||||||
showClearButton = false,
|
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"
|
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>
|
<span className="font-medium">{attr?.name ?? f.id}:</span>
|
||||||
{String(f.value)}
|
{resolveFilterDisplay(f, fieldOptions)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemove(f.id)}
|
onClick={() => onRemove(f.id)}
|
||||||
className="ml-0.5 hover:text-destructive transition-colors"
|
className="ml-0.5 hover:text-destructive transition-colors"
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export default function DataTable({
|
|||||||
const [filterState, setFilterState] = useState({
|
const [filterState, setFilterState] = useState({
|
||||||
open: false, column: null, attr: null, data: [],
|
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 !== "");
|
const activeFilters = columnFilters.filter((f) => f.value !== "");
|
||||||
|
|
||||||
@@ -108,6 +113,7 @@ export default function DataTable({
|
|||||||
|
|
||||||
const data = await onFetchFilterData(attr.field);
|
const data = await onFetchFilterData(attr.field);
|
||||||
setFilterState({ open: true, column, attr, data });
|
setFilterState({ open: true, column, attr, data });
|
||||||
|
setFieldOptions((prev) => ({ ...prev, [attr.field]: data }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -204,6 +210,7 @@ export default function DataTable({
|
|||||||
<ActiveFilterPills
|
<ActiveFilterPills
|
||||||
filters={activeFilters}
|
filters={activeFilters}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
|
fieldOptions={fieldOptions}
|
||||||
onClearAll={() => {
|
onClearAll={() => {
|
||||||
setColumnFilters([]);
|
setColumnFilters([]);
|
||||||
filtersRef.current = [];
|
filtersRef.current = [];
|
||||||
@@ -227,6 +234,7 @@ export default function DataTable({
|
|||||||
<ActiveFilterPills
|
<ActiveFilterPills
|
||||||
filters={activeFilters}
|
filters={activeFilters}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
|
fieldOptions={fieldOptions}
|
||||||
onRemove={(id) => {
|
onRemove={(id) => {
|
||||||
const next = columnFilters.filter((c) => c.id !== id);
|
const next = columnFilters.filter((c) => c.id !== id);
|
||||||
const newFilters = next.filter((f) => f.value !== "");
|
const newFilters = next.filter((f) => f.value !== "");
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ export function getTypeAccent(type) {
|
|||||||
return TYPE_ACCENT[type] ?? "bg-muted text-foreground";
|
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) {
|
export function timeAgo(dateStr) {
|
||||||
const diff = Date.now() - new Date(dateStr).getTime();
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
const m = Math.floor(diff / 60_000);
|
const m = Math.floor(diff / 60_000);
|
||||||
@@ -41,6 +59,21 @@ export function timeAgo(dateStr) {
|
|||||||
return `${Math.floor(h / 24)}d ago`;
|
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.
|
// Resolves a course uuid to its numeric course_id — client course pages route by course_id, not uuid.
|
||||||
async function goToCourse(navigate, courseUuid) {
|
async function goToCourse(navigate, courseUuid) {
|
||||||
try {
|
try {
|
||||||
@@ -61,6 +94,15 @@ async function goToCourse(navigate, courseUuid) {
|
|||||||
export function resolveNotificationLink(type, data) {
|
export function resolveNotificationLink(type, data) {
|
||||||
if (!data) return null;
|
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) {
|
switch (type) {
|
||||||
case "course":
|
case "course":
|
||||||
return data.courseUuid
|
return data.courseUuid
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function AssetsProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
toast(message);
|
toast(message);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export function LibraryProvider({ children }) {
|
|||||||
(field) =>
|
(field) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get(`${UNITS_BASE}/field-values`, { params: { field } });
|
const { data } = await api.get(`${UNITS_BASE}/field-values`, { params: { field } });
|
||||||
return data;
|
return data?.data ?? [];
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
);
|
);
|
||||||
@@ -411,7 +411,7 @@ export function LibraryProvider({ children }) {
|
|||||||
(field) =>
|
(field) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get(`${LESSONS_BASE}/field-values`, { params: { field } });
|
const { data } = await api.get(`${LESSONS_BASE}/field-values`, { params: { field } });
|
||||||
return data;
|
return data?.data ?? [];
|
||||||
}),
|
}),
|
||||||
[request],
|
[request],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -268,10 +268,10 @@ export const UserProvider = ({ children }) => {
|
|||||||
|
|
||||||
// ─── GET /api/admin/users/:id/activity ────────────────────────────────────
|
// ─── GET /api/admin/users/:id/activity ────────────────────────────────────
|
||||||
const fetchUserActivity = useCallback(
|
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);
|
setActivityLoading(true);
|
||||||
try {
|
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;
|
const d = res.data?.data;
|
||||||
setActivity(d?.activities ?? []);
|
setActivity(d?.activities ?? []);
|
||||||
setActivityPagination({
|
setActivityPagination({
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function UserGroupProvider({ children }) {
|
|||||||
(field) =>
|
(field) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.get(`${BASE}/groups/field-values`, { params: { field } });
|
const res = await api.get(`${BASE}/groups/field-values`, { params: { field } });
|
||||||
return res.data;
|
return res.data?.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export const StaffGroupProvider = ({ children }) => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return res.data;
|
return res.data?.data;
|
||||||
}),
|
}),
|
||||||
[membersRequest]
|
[membersRequest]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
const res = await api.get(`${BASE}/task-lists/field-values`, {
|
const res = await api.get(`${BASE}/task-lists/field-values`, {
|
||||||
params: { field, ...buildParams(args) },
|
params: { field, ...buildParams(args) },
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data?.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
@@ -257,7 +257,7 @@ export const StaffTaskProvider = ({ children }) => {
|
|||||||
const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/field-values`, {
|
const res = await api.get(`${BASE}/task-lists/${taskListId}/tasks/field-values`, {
|
||||||
params: { field, ...buildParams(args) },
|
params: { field, ...buildParams(args) },
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data?.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,5 +37,13 @@ export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries(
|
|||||||
ADVERTISEMENT_STATUSES.map((s) => [s.value, s])
|
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))
|
// Max number of CTAs allowed per advertisement (matches backend normalizeCtas slice(0,2))
|
||||||
export const MAX_CTAS = 2;
|
export const MAX_CTAS = 2;
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "./styles/typeset.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import api from "@/utils/api.util";
|
||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
|
|
||||||
import DataTable from "@/components/generic/Table/DataTable";
|
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 { buildSelectionActions } from "../../config/advertisements/archive/selection.config";
|
||||||
|
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
export default function ArchivedAdvertisementsTable() {
|
export default function ArchivedAdvertisementsTable() {
|
||||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
@@ -29,6 +32,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
advertisements, attributes, pagination, setPagination, loading,
|
advertisements, attributes, pagination, setPagination, loading,
|
||||||
@@ -57,6 +61,22 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
onDelete: (row) => setDeleteTarget(row),
|
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({
|
const toolbarActions = buildToolbarActions({
|
||||||
fetchAdvertisements: fetchArchivedAdvertisements,
|
fetchAdvertisements: fetchArchivedAdvertisements,
|
||||||
pagination,
|
pagination,
|
||||||
@@ -65,6 +85,7 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
getSort: () => tableRefsRef.current.getSort(),
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
onRemoveExpired: handleRemoveExpired,
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionActions = buildSelectionActions({
|
const selectionActions = buildSelectionActions({
|
||||||
@@ -77,8 +98,8 @@ export default function ArchivedAdvertisementsTable() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => buildDataColumns(attributes, rowActions),
|
() => buildDataColumns(attributes, rowActions, fmtDateTime),
|
||||||
[attributes]
|
[attributes, fmtDateTime]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRestoreSuccess = () => {
|
const handleRestoreSuccess = () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
to Client side */
|
to Client side */
|
||||||
|
|
||||||
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
|
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 { PhotoProvider, PhotoView } from "react-photo-view";
|
||||||
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
|
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
|
||||||
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
|
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
|
// PhotoProvider wraps ALL blocks so images across the whole lesson share
|
||||||
// one lightbox session — users can swipe between them naturally.
|
// 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 (
|
return (
|
||||||
<PhotoProvider
|
<PhotoProvider
|
||||||
speed={() => 300}
|
speed={() => 300}
|
||||||
@@ -176,8 +175,7 @@ export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<style>{WYSIWYG_STYLES}</style>
|
{showHeader && <LessonHeader lesson={lesson} />}
|
||||||
<LessonHeader lesson={lesson} />
|
|
||||||
{blocks.length === 0 ? (
|
{blocks.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
|
<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" />
|
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
|
||||||
|
|||||||
@@ -9,18 +9,28 @@ export const columnPinning = {
|
|||||||
left: [],
|
left: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const cellOverrides = {};
|
const DATE_TIME_FIELDS = ["start_date", "end_date", "createdAt", "updatedAt", "deletedAt"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the full column array for the Archived Advertisements table.
|
* Builds the full column array for the Archived Advertisements table.
|
||||||
*
|
*
|
||||||
* @param {Array} attributes Field definitions from the server (drives data columns)
|
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||||
* @param {Array} rowActions Row-level kebab action definitions
|
* @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
|
* @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);
|
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 [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// config/advertisements/archive/toolbar.config.jsx
|
// 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";
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,8 +11,9 @@ import { exportTableToExcel } from "@/utils/excel.util";
|
|||||||
* @param {Function} deps.getFilters
|
* @param {Function} deps.getFilters
|
||||||
* @param {Function} deps.getSort
|
* @param {Function} deps.getSort
|
||||||
* @param {Function} deps.getTableInstance
|
* @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 [
|
return [
|
||||||
{
|
{
|
||||||
key: "refresh",
|
key: "refresh",
|
||||||
@@ -38,5 +39,13 @@ export function buildToolbarActions({ fetchAdvertisements, pagination, exportCon
|
|||||||
tableInstance: table ?? getTableInstance(),
|
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 { useEffect, useState, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useUsers } from "@/contexts/AdminUserContext";
|
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 AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -9,9 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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 { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { DatePickerButton } from "@/components/generic/DatePickerButton";
|
||||||
|
|
||||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||||
import { timeAgo } from "@/utils/timestamp.util";
|
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 ──────────────────────────────────────────────────────────────────────
|
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||||
function initials(name, email) {
|
function initials(name, email) {
|
||||||
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
|
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
|
||||||
@@ -293,10 +241,10 @@ function ActivityRow({ row, onViewUser }) {
|
|||||||
{ts ? (
|
{ts ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left">
|
<TooltipContent side="left">
|
||||||
{fmtDateTime(ts)}
|
{timeAgo(ts)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { DatePickerButton } from "@/components/generic/DatePickerButton";
|
||||||
|
|
||||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||||
import { timeAgo } from "@/utils/timestamp.util";
|
import { timeAgo } from "@/utils/timestamp.util";
|
||||||
|
import { fmtISO } from "@/utils/datetime.util";
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
function toDateStr(d) {
|
||||||
|
if (!d) return undefined;
|
||||||
|
return fmtISO(d);
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserActivityPage() {
|
export default function UserActivityPage() {
|
||||||
const { userId } = useParams();
|
const { userId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -22,6 +29,8 @@ export default function UserActivityPage() {
|
|||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [action, setAction] = useState("all");
|
const [action, setAction] = useState("all");
|
||||||
|
const [from, setFrom] = useState(null);
|
||||||
|
const [to, setTo] = useState(null);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(p = 1) => {
|
(p = 1) => {
|
||||||
@@ -29,14 +38,16 @@ export default function UserActivityPage() {
|
|||||||
page: p,
|
page: p,
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
action: action === "all" ? undefined : action,
|
action: action === "all" ? undefined : action,
|
||||||
|
from: toDateStr(from),
|
||||||
|
to: toDateStr(to),
|
||||||
});
|
});
|
||||||
setPage(p);
|
setPage(p);
|
||||||
},
|
},
|
||||||
[fetchUserActivity, userId, action]
|
[fetchUserActivity, userId, action, from, to]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => { fetchUser(userId); }, [userId]);
|
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}`;
|
const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`;
|
||||||
|
|
||||||
@@ -76,7 +87,7 @@ export default function UserActivityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── Filter ──────────────────────────────────────────────── */}
|
{/* ─── 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]">
|
<div className="flex flex-col gap-1 min-w-[180px]">
|
||||||
<span className="text-xs text-muted-foreground">Filter by action</span>
|
<span className="text-xs text-muted-foreground">Filter by action</span>
|
||||||
<Select value={action} onValueChange={setAction}>
|
<Select value={action} onValueChange={setAction}>
|
||||||
@@ -91,10 +102,26 @@ export default function UserActivityPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{action !== "all" && (
|
|
||||||
<div className="flex items-end pt-5">
|
<div className="flex flex-col gap-1">
|
||||||
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setAction("all")}>
|
<span className="text-xs text-muted-foreground">From</span>
|
||||||
Clear
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -187,10 +214,10 @@ function ActivityItem({ row }) {
|
|||||||
{ts ? (
|
{ts ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
<span className="text-muted-foreground cursor-default">{fmtDateTime(ts)}</span>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left">
|
<TooltipContent side="left">
|
||||||
{fmtDateTime(ts)}
|
{timeAgo(ts)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
import { resolveAssetSrc } from "@/utils/media.util";
|
import { resolveAssetSrc } from "@/utils/media.util";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
@@ -453,6 +454,7 @@ function SummaryRow({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function StepReview({ data, selectedAsset, imageUrl }) {
|
function StepReview({ data, selectedAsset, imageUrl }) {
|
||||||
|
const { fmtDateTime } = useDateFormat();
|
||||||
const placementMeta = PLACEMENT_MAP[data.placement];
|
const placementMeta = PLACEMENT_MAP[data.placement];
|
||||||
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
|
const ctas = (data.ctas ?? []).filter((c) => c.label || c.link);
|
||||||
const hasLandingPage = !data.redirect_link && (data.landing_page?.title || data.landing_page?.body);
|
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">
|
<div className="border rounded-lg p-4 space-y-1">
|
||||||
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
<p className="text-sm font-medium mb-2">Scheduling & display</p>
|
||||||
<SummaryRow label="Start date" value={data.start_date} />
|
<SummaryRow label="Start date" value={data.start_date ? fmtDateTime(data.start_date) : null} />
|
||||||
<SummaryRow label="End date" value={data.end_date} />
|
<SummaryRow label="End date" value={data.end_date ? fmtDateTime(data.end_date) : null} />
|
||||||
<SummaryRow label="Order" value={data.order} />
|
<SummaryRow label="Order" value={data.order} />
|
||||||
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
|
<SummaryRow label="Status" value={data.is_active ? "Active" : "Draft"} />
|
||||||
</div>
|
</div>
|
||||||
@@ -693,7 +695,7 @@ export default function AddAdvertisement() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={stepIndex === 0 ? () => navigate(-1) : handleBack}
|
onClick={stepIndex === 0 ? () => navigate("/admin/advertisements") : handleBack}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="size-4" />
|
<ChevronLeft className="size-4" />
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ import {
|
|||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} 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 { PLACEMENT_MAP } from "@/data/placement.data";
|
||||||
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
import { TablePagination } from "@/components/generic/Table/TablePagination";
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const DEFAULT_PAGE_SIZE = 10;
|
||||||
|
|
||||||
export default function AdvertisementList() {
|
export default function AdvertisementList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -32,6 +32,7 @@ export default function AdvertisementList() {
|
|||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
const buildFilters = () => {
|
const buildFilters = () => {
|
||||||
const filters = [];
|
const filters = [];
|
||||||
@@ -45,13 +46,14 @@ export default function AdvertisementList() {
|
|||||||
// their state update with setPage(1) in the same handler so this only
|
// 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).
|
// ever fires once per change (no separate "reset page" effect racing it).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAdvertisements({ page, limit: PAGE_SIZE, filters: buildFilters() });
|
fetchAdvertisements({ page, limit: pageSize, filters: buildFilters() });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 handleTypeFilter = (v) => { setTypeFilter(v); setPage(1); };
|
||||||
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
|
const handleStatusFilter = (v) => { setStatusFilter(v); setPage(1); };
|
||||||
const runSearch = () => { setSearch(searchInput); setPage(1); };
|
const runSearch = () => { setSearch(searchInput); setPage(1); };
|
||||||
|
const handlePageSizeChange = (size) => { setPageSize(size); setPage(1); };
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
@@ -61,7 +63,6 @@ export default function AdvertisementList() {
|
|||||||
const total = pagination?.totalRecords ?? advertisements.length;
|
const total = pagination?.totalRecords ?? advertisements.length;
|
||||||
const activeCount = advertisements.filter((a) => a.status === "active").length;
|
const activeCount = advertisements.filter((a) => a.status === "active").length;
|
||||||
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
|
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
|
||||||
const expiredCount = advertisements.filter((a) => a.status === "expired").length;
|
|
||||||
|
|
||||||
async function handleArchive(advertisementId) {
|
async function handleArchive(advertisementId) {
|
||||||
await archiveAdvertisement(advertisementId);
|
await archiveAdvertisement(advertisementId);
|
||||||
@@ -95,11 +96,10 @@ export default function AdvertisementList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Stat cards ─────────────────────────────────────────────── */}
|
{/* ── 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="Total ads" value={total} />
|
||||||
<StatCard label="Active" value={activeCount} tone="success" />
|
<StatCard label="Active" value={activeCount} tone="success" />
|
||||||
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
|
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
|
||||||
<StatCard label="Expired" value={expiredCount} tone="muted" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||||
@@ -122,7 +122,7 @@ export default function AdvertisementList() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All statuses</SelectItem>
|
<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>
|
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -170,6 +170,7 @@ export default function AdvertisementList() {
|
|||||||
<TablePagination
|
<TablePagination
|
||||||
pagination={pagination}
|
pagination={pagination}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={handlePageSizeChange}
|
||||||
rowCount={advertisements.length}
|
rowCount={advertisements.length}
|
||||||
recordLabel="advertisement"
|
recordLabel="advertisement"
|
||||||
/>
|
/>
|
||||||
@@ -203,7 +204,7 @@ function StatCard({ label, value, tone = "default" }) {
|
|||||||
// ─── Advertisement card ─────────────────────────────────────────────────────
|
// ─── Advertisement card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||||
const { fmtDate } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||||
const placementMeta = PLACEMENT_MAP[ad.placement] ?? null;
|
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 previewSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
|
||||||
const isDimmed = ad.status === "expired" || ad.status === "archived";
|
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 (
|
return (
|
||||||
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
|
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export default function EditAdvertisement() {
|
|||||||
}, [advertisementId]);
|
}, [advertisementId]);
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
const onSubmit = async (values) => {
|
||||||
if (!isDirty) { bypassOnce(); return navigate(-1); }
|
if (!isDirty) { bypassOnce(); return navigate("/admin/advertisements"); }
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...values,
|
...values,
|
||||||
@@ -506,7 +506,7 @@ export default function EditAdvertisement() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ function resolveFileType(mimeType = "") {
|
|||||||
return "document";
|
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 ───────────────────────────────────────────────────────────────────
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -156,6 +161,10 @@ export default function AddAsset() {
|
|||||||
const fileType = file ? resolveFileType(file.type) : null;
|
const fileType = file ? resolveFileType(file.type) : null;
|
||||||
|
|
||||||
const setFile = (f) => {
|
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;
|
fileRef.current = f;
|
||||||
setValue("_file", f);
|
setValue("_file", f);
|
||||||
if (!watch("display_name")) setValue("display_name", f.name);
|
if (!watch("display_name")) setValue("display_name", f.name);
|
||||||
@@ -198,7 +207,7 @@ export default function AddAsset() {
|
|||||||
});
|
});
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
|
|
||||||
if (result) { bypassOnce(); navigate(-1); }
|
if (result) { bypassOnce(); navigate("/admin/assets"); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressLabel = {
|
const progressLabel = {
|
||||||
@@ -213,7 +222,7 @@ export default function AddAsset() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center 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("/admin/assets")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -380,7 +389,7 @@ export default function AddAsset() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate("/admin/assets")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export default function AddAssetsBulk() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center 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("/admin/assets")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -213,7 +213,7 @@ export default function AddAssetsBulk() {
|
|||||||
|
|
||||||
{/* ── Actions ── */}
|
{/* ── Actions ── */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)}>
|
<Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
|
||||||
Done
|
Done
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export default function EditAsset() {
|
|||||||
|
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
bypassOnce();
|
bypassOnce();
|
||||||
navigate(-1);
|
navigate("/admin/assets");
|
||||||
};
|
};
|
||||||
|
|
||||||
if (initializing) {
|
if (initializing) {
|
||||||
@@ -224,7 +224,7 @@ export default function EditAsset() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center 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("/admin/assets")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -334,7 +334,7 @@ export default function EditAsset() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate("/admin/assets")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function ViewAudioAsset() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
<p>Asset not found.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export default function ViewAudioAsset() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -113,8 +113,9 @@ export default function ViewAudioAsset() {
|
|||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
<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="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} />
|
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function ViewDocumentAsset() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
<p>Asset not found.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@ export default function ViewDocumentAsset() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -116,8 +116,9 @@ export default function ViewDocumentAsset() {
|
|||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
<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="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} />
|
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ViewImageAsset() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
<p>Asset not found.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ export default function ViewImageAsset() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -108,8 +108,9 @@ export default function ViewImageAsset() {
|
|||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
<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="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} />
|
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function ViewVideoAsset() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
<p>Asset not found.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ export default function ViewVideoAsset() {
|
|||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center gap-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -143,8 +143,9 @@ export default function ViewVideoAsset() {
|
|||||||
<MetaRow label="Owner ID" value={a.owner_id} />
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
<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="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} />
|
<MetaRow label="Modified" value={a.updatedAt ? fmtDateTime(a.updatedAt) : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function AddCategory() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -100,7 +100,7 @@ export default function AddCategory() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<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}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Create Category
|
Create Category
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default function EditCategory() {
|
|||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||||
|
|
||||||
const onSubmit = async (values) => {
|
const onSubmit = async (values) => {
|
||||||
if (!isDirty) { bypassOnce(); return navigate(-1); }
|
if (!isDirty) { bypassOnce(); return navigate("/admin/courses/categories"); }
|
||||||
const result = await updateCategory(id, values);
|
const result = await updateCategory(id, values);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
bypassOnce();
|
bypassOnce();
|
||||||
@@ -76,7 +76,7 @@ export default function EditCategory() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -111,7 +111,7 @@ export default function EditCategory() {
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<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}>
|
<Button type="submit" disabled={loading || !isDirty}>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export default function AddCourse() {
|
|||||||
>
|
>
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -653,7 +653,7 @@ export default function AddCourse() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
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" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
{currentStep === 0 ? "Cancel" : "Back"}
|
{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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -1121,7 +1121,7 @@ export default function EditCourse() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
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" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
{currentStep === 0 ? "Cancel" : "Back"}
|
{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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<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="w-full max-w-2xl mx-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -146,7 +146,7 @@ export default function AddLesson() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function EditLesson() {
|
|||||||
}, [courseId, unitId, lessonId]);
|
}, [courseId, unitId, lessonId]);
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
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, {
|
const result = await updateLesson(courseId, unitId, lessonId, {
|
||||||
...data,
|
...data,
|
||||||
objectives: data.objectives?.map((o, i) => ({
|
objectives: data.objectives?.map((o, i) => ({
|
||||||
@@ -74,7 +74,7 @@ export default function EditLesson() {
|
|||||||
});
|
});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
bypassOnce();
|
bypassOnce();
|
||||||
navigate(-1);
|
navigate(`/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/view`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +84,7 @@ export default function EditLesson() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl">
|
<div className="w-full max-w-2xl">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -163,7 +163,7 @@ export default function EditLesson() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !isDirty}>
|
<Button type="submit" disabled={loading || !isDirty}>
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export default function LessonPageBuilder() {
|
|||||||
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
|
const { fetchLesson, saveLessonPage, course, unit, lesson, lessonPage, loading } = useCourses();
|
||||||
const { user } = useAuth();
|
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 [blocks, setBlocks] = useState([]);
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
const [previewVisible, setPreviewVisible] = useState(true);
|
const [previewVisible, setPreviewVisible] = useState(true);
|
||||||
@@ -105,7 +111,7 @@ export default function LessonPageBuilder() {
|
|||||||
updatedBy: user?.user_id,
|
updatedBy: user?.user_id,
|
||||||
});
|
});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
navigate(-1);
|
navigate(pageViewPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
const editorStyle = previewVisible
|
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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -152,7 +158,7 @@ export default function LessonPageBuilder() {
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Preview
|
Preview
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>
|
<Button type="button" variant="outline" onClick={() => navigate(pageViewPath)} disabled={loading}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={loading}>
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function ViewLesson() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center 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(`/admin/courses/${courseId}/units/${unitId}/lessons`)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export default function ViewLessonPage() {
|
|||||||
const builderPath = unitId
|
const builderPath = unitId
|
||||||
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
? `/admin/courses/${courseId}/units/${unitId}/lessons/${lessonId}/page`
|
||||||
: `/admin/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 { fetchLesson, lesson, lessonPage } = useCourses();
|
||||||
const [initializing, setInitializing] = useState(true);
|
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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<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="w-full max-w-2xl mx-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -89,7 +89,7 @@ export default function AddUnit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export default function EditUnit() {
|
|||||||
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
const { bypassOnce, dialog: unsavedChangesDialog } = useUnsavedChangesGuard(isDirty);
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
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 });
|
const result = await updateUnit(courseId, unitId, { ...data, updatedBy: user?.user_id });
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
bypassOnce();
|
bypassOnce();
|
||||||
@@ -70,7 +70,7 @@ export default function EditUnit() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -101,7 +101,7 @@ export default function EditUnit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading || !isDirty}>
|
<Button type="submit" disabled={loading || !isDirty}>
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ export default function ModifyQuiz() {
|
|||||||
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
|
await bulkSyncQuizQuestions(courseId, unitId, quizId, questions, user?.user_id);
|
||||||
|
|
||||||
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
|
initialSnapshot.current = snapQuiz({ title, passingScore, isRequired, maxQuestions, shuffleQuestions, questions });
|
||||||
navigate(-1);
|
navigate(`${scopeBase}/view`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
@@ -435,7 +435,7 @@ export default function ModifyQuiz() {
|
|||||||
>
|
>
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<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="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export default function AddLibraryLesson() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (step === 0) navigate(-1);
|
if (step === 0) navigate(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons");
|
||||||
else setStep((s) => s - 1);
|
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="w-full max-w-3xl mx-auto space-y-6">
|
||||||
|
|
||||||
<div className="flex items-center 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(attachUnitId ? `/admin/units/${attachUnitId}/view` : "/admin/lessons")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function EditLibraryLesson() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -90,7 +90,7 @@ export default function EditLibraryLesson() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export default function AddLibraryUnit() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
if (step === 0) navigate(-1);
|
if (step === 0) navigate("/admin/units");
|
||||||
else setStep((s) => s - 1);
|
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="w-full max-w-3xl mx-auto space-y-6">
|
||||||
|
|
||||||
<div className="flex items-center 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("/admin/units")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function EditLibraryUnit() {
|
|||||||
|
|
||||||
<div className="w-full max-w-2xl mx-auto">
|
<div className="w-full max-w-2xl mx-auto">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -127,7 +127,7 @@ export default function EditLibraryUnit() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<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 { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -233,7 +240,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||||
show_in_sticky: values.show_in_sticky ?? false,
|
show_in_sticky: values.show_in_sticky ?? false,
|
||||||
show_in_notifications: values.show_in_notifications ?? true,
|
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,
|
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
||||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
@@ -554,7 +561,7 @@ export default function AddNotificationBroadcast() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/announcements")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<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 { TARGET_TYPE_OPTIONS, TARGET_TYPE_MAP } from "@/data/notificationBroadcast.data";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { TIER_COLOR_OPTIONS, getTierColor, shadeColor, getContrastText } from "@/utils/tierColors";
|
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 ─────────────────────────────────────────────────────────────────
|
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -266,7 +273,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
target_id: TARGET_TYPE_MAP[values.target_type]?.needsTarget ? values.target_id : null,
|
||||||
show_in_sticky: values.show_in_sticky ?? false,
|
show_in_sticky: values.show_in_sticky ?? false,
|
||||||
show_in_notifications: values.show_in_notifications ?? true,
|
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,
|
link_label: (values.show_in_sticky && link_mode === "link") ? (values.link_label?.trim() || null) : null,
|
||||||
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
color: values.show_in_sticky ? (values.color || "indigo") : "indigo",
|
||||||
start_date: values.start_date || null,
|
start_date: values.start_date || null,
|
||||||
@@ -590,7 +597,7 @@ export default function EditNotificationBroadcast() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/announcements")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<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">
|
<div className="flex items-center gap-3 my-6 w-full">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost" size="icon"
|
variant="ghost" size="icon"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate(`/admin/taskList/${taskListId}/tasks/${taskId}/completions`)}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export default function AddPlan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -403,7 +403,7 @@ export default function AddPlan() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate(-1)}
|
onClick={() => currentStep > 0 ? setCurrentStep((s) => s - 1) : navigate("/admin/tiers/plans")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{currentStep === 0 ? "Cancel" : "Back"}
|
{currentStep === 0 ? "Cancel" : "Back"}
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export default function EditPlan() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -348,7 +348,7 @@ export default function EditPlan() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<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}>
|
<Button type="submit" disabled={loading || impactLoading || courseConflicts > 0}>
|
||||||
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
Save Changes
|
Save Changes
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -374,7 +374,7 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3">
|
<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}>
|
<Button type="button" onClick={handleSave} disabled={loading}>
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
{isAdd ? "Create Category" : "Save Changes"}
|
{isAdd ? "Create Category" : "Save Changes"}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function PaymentPolicy() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -426,7 +426,7 @@ export default function PaymentPolicy() {
|
|||||||
|
|
||||||
{/* ── Save ─────────────────────────────────────────────────── */}
|
{/* ── Save ─────────────────────────────────────────────────── */}
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<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
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<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-start justify-between gap-3 mb-6">
|
||||||
<div className="flex items-center 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(`/admin/users/view/${userId}`)}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export default function ViewPayment() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -772,7 +772,7 @@ export default function ViewPlan() {
|
|||||||
>
|
>
|
||||||
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
<div className="lg:container lg:mx-auto lg:px-6 px-4">
|
||||||
<div className="flex items-center gap-3 py-3">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ export default function AddStaffUserPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
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" />
|
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||||
{step === 0 ? "Cancel" : "Back"}
|
{step === 0 ? "Cancel" : "Back"}
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ function LessonSkeleton() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Props:
|
* Props:
|
||||||
* lesson — { id, title, blocks[] }
|
* lesson — { id, title, blocks[] }
|
||||||
* loading — true while fetch is in-flight
|
* 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) {
|
if (!lesson && !loading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
|
<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}
|
lesson={lesson}
|
||||||
blocks={lesson.blocks ?? []}
|
blocks={lesson.blocks ?? []}
|
||||||
empty="No content blocks yet."
|
empty="No content blocks yet."
|
||||||
|
showHeader={showHeader}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PreviewChrome>
|
</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">
|
<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" />
|
<Megaphone className="size-8 text-muted-foreground" />
|
||||||
<p className="font-medium">This advertisement is no longer available.</p>
|
<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
|
<ArrowLeft className="size-4" /> Go back
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<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} />
|
<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
|
<ArrowLeft className="size-4" /> Back
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function CourseCheckout() {
|
|||||||
This course doesn't have an individual purchase option.
|
This course doesn't have an individual purchase option.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => navigate(-1)}>
|
<Button onClick={() => navigate(`/course/${courseId}`)}>
|
||||||
<ArrowLeft className="size-4" /> Go Back
|
<ArrowLeft className="size-4" /> Go Back
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
House, SendHorizonal, CheckCheck, Check, Hourglass,
|
House, SendHorizonal, CheckCheck, Check, Hourglass, Clock, Layers, BookOpen, Video,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
import { useLibrary } from "@/contexts/ClientLibraryContext";
|
||||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
import LockedContentPanel from "@/modules/client/components/LockedContentPanel";
|
||||||
|
import LessonBlock from "../components/LessonBlock.jsx";
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -22,54 +23,6 @@ function formatDuration(seconds = 0) {
|
|||||||
return `${m}min`;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Lesson Details ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LessonDetails = () => {
|
const LessonDetails = () => {
|
||||||
@@ -78,13 +31,22 @@ const LessonDetails = () => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
|
getLesson, lesson, lessonLoading, unitBlocked, unitBlockedInfo, resetLesson,
|
||||||
getUnitDetail, unitDetail, resetUnitDetail,
|
upsertLessonProgress,
|
||||||
} = useLibrary();
|
} = useLibrary();
|
||||||
const { tierMap, getTierCategories } = useClientTiers();
|
const { tierMap, getTierCategories } = useClientTiers();
|
||||||
|
|
||||||
const hasCompleted = lesson?.status === "completed";
|
const hasCompleted = lesson?.status === "completed";
|
||||||
const unit = lesson?.unit ?? null;
|
const unit = lesson?.unit ?? null;
|
||||||
const hasUnit = !!unit?.uuid;
|
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(() => {
|
useEffect(() => {
|
||||||
getTierCategories();
|
getTierCategories();
|
||||||
@@ -93,20 +55,20 @@ const LessonDetails = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [uuid]);
|
}, [uuid]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasUnit) return;
|
|
||||||
getUnitDetail(unit.uuid);
|
|
||||||
return () => resetUnitDetail();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [unit?.uuid]);
|
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/dashboard` },
|
||||||
{ label: "Lessons", to: `/lessons` },
|
{ label: "Lessons", to: `/lessons` },
|
||||||
|
...(hasCourse ? [{ label: unit.course.title, to: `/course/${unit.course.course_id}` }] : []),
|
||||||
...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []),
|
...(hasUnit ? [{ label: unit.title, to: `/units/${unit.uuid}` }] : []),
|
||||||
{ label: lesson?.title ?? "Lesson" },
|
{ 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 ────
|
// ── Deep-link to a lesson under a locked unit — inline blocked panel ────
|
||||||
if (unitBlocked) {
|
if (unitBlocked) {
|
||||||
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
|
return <LockedContentPanel course={unitBlockedInfo?.course} tierMap={tierMap} />;
|
||||||
@@ -126,24 +88,41 @@ const LessonDetails = () => {
|
|||||||
if (!hasUnit) return;
|
if (!hasUnit) return;
|
||||||
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
|
navigate(`/units/${unit.uuid}/read`, { state: { lessonId: lesson.lesson_id } });
|
||||||
};
|
};
|
||||||
const handleSelectSibling = (sibling) => {
|
const handleMarkComplete = () => {
|
||||||
if (sibling.uuid === uuid) return;
|
if (hasCompleted) return;
|
||||||
navigate(`/lessons/${sibling.uuid}`);
|
upsertLessonProgress(lesson.uuid, "completed", unit?.uuid);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col">
|
||||||
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
|
<PageMeta title={`${lesson.title} - STARR`} description={lesson.description} />
|
||||||
<div
|
<div
|
||||||
className="flex-1 flex flex-col lg:flex-row items-stretch"
|
className="flex-1 flex flex-col"
|
||||||
style={{ paddingTop: "var(--navbar-h)" }}
|
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">
|
<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} />
|
<AppBreadcrumb items={items} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 max-w-2xl">
|
<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>
|
<h1 className="font-bold xs:text-2xl lg:text-3xl">{lesson.title}</h1>
|
||||||
<p className="text-muted-foreground">{lesson.description ?? ""}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
{lesson.objectives?.length > 0 && (
|
{lesson.objectives?.length > 0 && (
|
||||||
@@ -160,30 +139,43 @@ const LessonDetails = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-fit">
|
{contentNotReady ? (
|
||||||
{!hasUnit ? (
|
<div className="w-fit flex items-center gap-2 rounded-lg border bg-muted px-4 py-2.5 text-sm text-muted-foreground">
|
||||||
<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" />
|
||||||
<Hourglass className="size-4 shrink-0" />
|
This lesson is currently being prepared. Please check back later.
|
||||||
This lesson isn't part of a unit yet. Check back later.
|
</div>
|
||||||
</div>
|
) : hasCourse ? (
|
||||||
) : (
|
<div className="w-fit">
|
||||||
<Button className="w-fit bg-blue-500" onClick={handleStart}>
|
<Button className="w-fit bg-blue-500" onClick={handleStart}>
|
||||||
{hasCompleted
|
{hasCompleted
|
||||||
? <><CheckCheck /> Start Again</>
|
? <><CheckCheck /> Start Again</>
|
||||||
: <><SendHorizonal /> Start Lesson</>
|
: <><SendHorizonal /> Start Lesson</>
|
||||||
}
|
}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{hasUnit && (
|
|
||||||
<UnitLessonsSidebar
|
|
||||||
unitDetail={unitDetail}
|
|
||||||
currentLessonUuid={uuid}
|
|
||||||
onSelect={handleSelectSibling}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</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="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
|
||||||
|
|
||||||
<div className="flex items-center 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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<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="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
|
||||||
|
|
||||||
<div className="flex items-center 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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||||
import { cn } from "@/lib/utils";
|
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 NotificationDetailDialog from "@/components/generic/NotificationDetailDialog";
|
||||||
import { toast } from "sonner";
|
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 justify-between gap-3">
|
||||||
<div className="flex items-center 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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
@@ -214,8 +214,8 @@ export default function Notifications() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="mt-0.5">
|
<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.5 w-4.5 text-muted-foreground" />
|
<NotificationIcon type={n.type} className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
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() {
|
export default function NotFound() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
navigate(-1)
|
navigate(DASHBOARD_BY_ROLE[user?.acc_type] ?? '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
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() {
|
export default function Unauthorized() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
navigate(-1)
|
navigate(DASHBOARD_BY_ROLE[user?.acc_type] ?? '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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",
|
"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 = "") {
|
export function fileExtension(filename = "") {
|
||||||
const dot = filename.lastIndexOf(".");
|
const dot = filename.lastIndexOf(".");
|
||||||
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : "";
|
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : "";
|
||||||
@@ -35,6 +40,13 @@ export function fileExtension(filename = "") {
|
|||||||
|
|
||||||
// { ok: true } or { ok: false, reason }
|
// { ok: true } or { ok: false, reason }
|
||||||
export function validateAssetFile(file) {
|
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 || "";
|
const mime = file.type || "";
|
||||||
|
|
||||||
if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {
|
if (mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/")) {
|
||||||
|
|||||||
+25
-14
@@ -75,6 +75,10 @@ export const BADGE_STYLES = {
|
|||||||
// ── Asset — is_public ─────────────────────────
|
// ── Asset — is_public ─────────────────────────
|
||||||
"true": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
"true": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||||
"false": "bg-zinc-100 text-zinc-600 border-zinc-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 }) {
|
function EnumBadge({ value }) {
|
||||||
@@ -94,24 +98,28 @@ export function formatDate(value) {
|
|||||||
catch { return 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) {
|
function renderCell(attr, value) {
|
||||||
if (value === null || value === undefined || value === "") {
|
if (value === null || value === undefined || value === "") {
|
||||||
return <span className="text-muted-foreground/40">-</span>;
|
return <span className="text-muted-foreground/40">-</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
switch (attr.type) {
|
switch (attr.type) {
|
||||||
case "enum":
|
case "enum": {
|
||||||
if (attr.field === "is_active")
|
const boolLabels = BOOLEAN_FIELD_LABELS[attr.field];
|
||||||
return <EnumBadge value={value ? "Active" : "Not Active"} />;
|
if (boolLabels) return <EnumBadge value={boolLabels[value ? 1 : 0]} />;
|
||||||
else if (attr.field === "is_verified")
|
return <EnumBadge value={value} />;
|
||||||
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 "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
|
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>;
|
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
|
||||||
default: return <span className="text-sm">{value}</span>;
|
default: return <span className="text-sm">{value}</span>;
|
||||||
@@ -131,13 +139,16 @@ export function ColumnFilter({ column, attr }) {
|
|||||||
// ENUM (AUTO APPLY)
|
// ENUM (AUTO APPLY)
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
if (attr.type === "enum") {
|
if (attr.type === "enum") {
|
||||||
|
const boolLabels = BOOLEAN_FIELD_LABELS[attr.field];
|
||||||
|
const choiceLabel = (c) => boolLabels ? boolLabels[c === "true" ? 1 : 0] : c;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={inputValue || "__all__"}
|
value={inputValue || "__all__"}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
const value = v === "__all__" ? "" : v;
|
const value = v === "__all__" ? "" : v;
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
column.setFilterValue(value);
|
column.setFilterValue(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs w-full">
|
<SelectTrigger className="h-7 text-xs w-full">
|
||||||
@@ -148,7 +159,7 @@ export function ColumnFilter({ column, attr }) {
|
|||||||
<SelectItem value="__all__">All</SelectItem>
|
<SelectItem value="__all__">All</SelectItem>
|
||||||
{attr.options?.choices?.map((c) => (
|
{attr.options?.choices?.map((c) => (
|
||||||
<SelectItem key={c} value={c}>
|
<SelectItem key={c} value={c}>
|
||||||
{c}
|
{choiceLabel(c)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user