mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
updated some of things
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
// components/generic/CMS/AddBlockMenu.jsx
|
||||
|
||||
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText, FileUp } from "lucide-react";
|
||||
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -60,12 +60,6 @@ const BLOCK_TYPES = [
|
||||
description: "Rich text written in Markdown",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
label: "Document",
|
||||
description: "Upload a PDF/PPTX only, auto-convert to Markdown",
|
||||
icon: <FileUp className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function AddBlockMenu({ onAdd }) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||
import { goToLink } from "@/components/generic/notificationDisplay";
|
||||
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
||||
|
||||
const ROTATE_INTERVAL_MS = 6000;
|
||||
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
|
||||
|
||||
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
|
||||
// explicit link_url from the "On Open" section — the type-based fallback
|
||||
@@ -24,47 +22,23 @@ function resolveClickAction(stickyAnnouncement) {
|
||||
|
||||
export default function AdminStickyAnnouncementBar() {
|
||||
const navigate = useNavigate();
|
||||
const { stickyAnnouncements, bannerImage, markSeen } = useAdminNotifications();
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const { stickyAnnouncements, markSeen } = useAdminNotifications();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const count = stickyAnnouncements.length;
|
||||
// Derived rather than clamped via effect — safe the instant the array
|
||||
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
|
||||
const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (count <= 1) return;
|
||||
const id = setInterval(() => {
|
||||
setActiveIndex((i) => (i + 1) % count);
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [count]);
|
||||
|
||||
const current = stickyAnnouncements[safeIndex];
|
||||
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
|
||||
// it (X on the bar) reveals whichever is next in the queue.
|
||||
const current = stickyAnnouncements[0];
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
if (!current) return;
|
||||
await markSeen(current.notification_id);
|
||||
}, [current, markSeen]);
|
||||
|
||||
// Opening the dialog must NOT mark it seen — markSeen removes the row from
|
||||
// stickyAnnouncements, which would unmount this component (dialog included)
|
||||
// before it ever shows.
|
||||
const onClickBanner = useCallback(() => {
|
||||
if (!current) return;
|
||||
setDetailsOpen(true);
|
||||
}, [current]);
|
||||
|
||||
// Closing the details dialog (X, Escape, overlay click — any reason)
|
||||
// dismisses whichever announcement was being viewed at the time. This is
|
||||
// the only dismiss path when multiple are active (no per-item X on the bar
|
||||
// itself — see the count > 1 branch below).
|
||||
const onDialogOpenChange = useCallback((open) => {
|
||||
setDetailsOpen(open);
|
||||
if (!open) void onDismiss();
|
||||
}, [onDismiss]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const swatch = getTierColor(current.color || "indigo").swatch;
|
||||
@@ -99,63 +73,38 @@ export default function AdminStickyAnnouncementBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{count > 1 && (
|
||||
<div className="absolute right-2 flex items-center gap-1.5">
|
||||
{stickyAnnouncements.map((a, i) => (
|
||||
<button
|
||||
key={a.notification_id}
|
||||
type="button"
|
||||
aria-label={`Show announcement ${i + 1}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveIndex(i);
|
||||
}}
|
||||
className="size-1.5 rounded-full transition-opacity"
|
||||
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss-X only makes sense for a single active announcement —
|
||||
with multiple, the dialog's own close button (shadcn Dialog)
|
||||
is the way to close/step away, no per-item dismiss from the bar. */}
|
||||
{count <= 1 && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
{/* Only way to dismiss a sticky alert — closing the details dialog
|
||||
no longer dismisses it. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnnouncementCarouselDialog
|
||||
<AnnouncementDetailsDialog
|
||||
open={detailsOpen}
|
||||
onOpenChange={onDialogOpenChange}
|
||||
announcements={stickyAnnouncements}
|
||||
activeIndex={safeIndex}
|
||||
onIndexChange={setActiveIndex}
|
||||
onOpenChange={setDetailsOpen}
|
||||
announcement={current}
|
||||
resolveClickAction={resolveClickAction}
|
||||
navigate={navigate}
|
||||
bannerImage={bannerImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
// Shared details view for the sticky announcement bar (client + admin
|
||||
// variants) — pages through every currently-active sticky announcement (max
|
||||
// 3, see notificationBroadcasts.controller.js's countActiveSticky) with a
|
||||
// segmented progress bar. bannerImage is ONE shared image for the whole set
|
||||
// (not per-announcement) — see NotificationBroadcastList.jsx's banner picker
|
||||
// and the sticky_banner_settings singleton.
|
||||
export default function AnnouncementCarouselDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
announcements,
|
||||
activeIndex,
|
||||
onIndexChange,
|
||||
resolveClickAction,
|
||||
navigate,
|
||||
bannerImage,
|
||||
}) {
|
||||
const current = announcements[activeIndex];
|
||||
if (!current) return null;
|
||||
|
||||
const clickAction = resolveClickAction(current);
|
||||
const imageSrc = resolveAssetSrc(bannerImage);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl p-0 overflow-hidden gap-0">
|
||||
<div className="grid sm:grid-cols-2">
|
||||
<div className="p-6 flex flex-col gap-3 min-w-0">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle className="text-lg">
|
||||
{current.title || "Announcement"}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<p className="whitespace-pre-wrap text-left text-foreground">
|
||||
{current.message || ""}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2 pt-4">
|
||||
{clickAction && (
|
||||
<Button
|
||||
className="self-start"
|
||||
onClick={() => clickAction.go(navigate)}
|
||||
>
|
||||
{clickAction.label}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{announcements.length > 1 && (
|
||||
<div className="flex gap-1.5">
|
||||
{announcements.map((a, i) => (
|
||||
<button
|
||||
key={a.notification_id}
|
||||
type="button"
|
||||
aria-label={`Show announcement ${i + 1}`}
|
||||
onClick={() => onIndexChange(i)}
|
||||
className={[
|
||||
"h-1.5 flex-1 rounded-full transition-colors",
|
||||
i === activeIndex ? "bg-foreground" : "bg-muted-foreground/25 hover:bg-muted-foreground/40",
|
||||
].join(" ")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No image</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
// Shared details view for the sticky announcement bar (client + admin
|
||||
// variants). Only one sticky alert is ever shown at a time — no carousel,
|
||||
// no autoplay. Optional per-alert layout image renders alongside the text
|
||||
// when present, single column when not.
|
||||
export default function AnnouncementDetailsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
announcement,
|
||||
resolveClickAction,
|
||||
navigate,
|
||||
}) {
|
||||
if (!announcement) return null;
|
||||
|
||||
const clickAction = resolveClickAction(announcement);
|
||||
const imageSrc = announcement.image ? resolveAssetSrc(announcement.image) : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={["p-0 overflow-hidden gap-0", imageSrc ? "sm:max-w-2xl" : "sm:max-w-md"].join(" ")}>
|
||||
<div className={imageSrc ? "grid sm:grid-cols-2" : ""}>
|
||||
<div className="p-6 flex flex-col gap-3 min-w-0">
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle className="text-lg">
|
||||
{announcement.title || "Alert"}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<p className="whitespace-pre-wrap text-left text-foreground">
|
||||
{announcement.message || ""}
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{clickAction && (
|
||||
<div className="mt-auto pt-4">
|
||||
<Button
|
||||
className="self-start"
|
||||
onClick={() => clickAction.go(navigate)}
|
||||
>
|
||||
{clickAction.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{imageSrc && (
|
||||
<div className="aspect-video sm:aspect-auto sm:h-96 bg-muted flex items-center justify-center overflow-hidden">
|
||||
<img src={imageSrc} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { formatPlayerTime } from "@/utils/format.util";
|
||||
|
||||
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
|
||||
const DEBOUNCE_MS = 400;
|
||||
@@ -26,6 +27,10 @@ const EXT_OPTIONS = {
|
||||
function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
||||
const directThumb = asset.thumbnail_url ?? asset.file_url;
|
||||
const thumb = streamSrc ?? directThumb;
|
||||
// duration comes straight off the asset row (ffprobe-derived at upload) —
|
||||
// shows regardless of whether a thumbnail image loaded, since a missing
|
||||
// preview shouldn't also mean losing the one other useful signal at a glance.
|
||||
const showDuration = (asset.file_type === "video" || asset.file_type === "audio") && !!asset.duration;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -37,7 +42,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
||||
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="aspect-video bg-muted w-full overflow-hidden">
|
||||
<div className="relative aspect-video bg-muted w-full overflow-hidden">
|
||||
{thumb ? (
|
||||
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
@@ -45,6 +50,11 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
||||
<span className="text-xs text-muted-foreground">No preview</span>
|
||||
</div>
|
||||
)}
|
||||
{showDuration && (
|
||||
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white tabular-nums">
|
||||
{formatPlayerTime(asset.duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<p className="text-xs font-medium truncate">{asset.display_name}</p>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
|
||||
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
|
||||
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
|
||||
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
|
||||
import { DocumentBlock } from "./Blocks/Admin/DocumentBlock";
|
||||
|
||||
// ─── Block renderer ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -42,8 +41,6 @@ function BlockContent({ block, onUpdate }) {
|
||||
return <CodeBlock content={content} onUpdate={onUpdate} />;
|
||||
case "markdown":
|
||||
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
|
||||
case "document":
|
||||
return <DocumentBlock content={content} onUpdate={onUpdate} />;
|
||||
default:
|
||||
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
|
||||
}
|
||||
@@ -60,7 +57,6 @@ export const DEFAULT_CONTENT = {
|
||||
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
|
||||
"code": { language: "javascript", code: "" },
|
||||
"markdown": { body: "" },
|
||||
"document": { source_asset_id: null, source_filename: null, source_ext: null, body: "" },
|
||||
};
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { FileUp, FileText, RotateCcw, Check, X, Eye, Pencil } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
import { MarkdownBlock } from "./MarkdownBlock";
|
||||
|
||||
// Only these two — docx/xlsx are visible in the generic document library but
|
||||
// out of scope for this block (see documentConversion.service.js on the backend).
|
||||
const ALLOWED_EXTENSIONS = ["pdf", "pptx"];
|
||||
|
||||
const STAGES = [
|
||||
{ phase: "compiling", label: "Compiling document" },
|
||||
{ phase: "validating", label: "Validating content" },
|
||||
{ phase: "generating", label: "Generating Markdown" },
|
||||
];
|
||||
|
||||
// ─── Stage progress ───────────────────────────────────────────────────────────
|
||||
|
||||
function StageProgress({ phase }) {
|
||||
const activeIndex = STAGES.findIndex((s) => s.phase === phase);
|
||||
return (
|
||||
<div className="flex flex-col gap-2 py-2">
|
||||
{STAGES.map((s, i) => {
|
||||
const state = activeIndex > i ? "done" : activeIndex === i ? "active" : "pending";
|
||||
return (
|
||||
<div key={s.phase} className="flex items-center gap-2 text-sm">
|
||||
{state === "done" ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
) : state === "active" ? (
|
||||
<Spinner className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 rounded-full border shrink-0" />
|
||||
)}
|
||||
<span className={cn(state === "pending" && "text-muted-foreground")}>{s.label}…</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── DocumentBlock (Admin) ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Pick (pdf/pptx only) -> Convert (compile/validate/automate, live stage
|
||||
// progress over SSE) -> Draft review (rendered Markdown + warnings, explicit
|
||||
// Insert/Discard). Nothing lands in `content` until "Insert into Lesson" is
|
||||
// clicked — the conversion result is a draft, never written automatically.
|
||||
// Once inserted, this hands off to MarkdownBlock's own editor for `body` so
|
||||
// admins can hand-edit the generated Markdown before saving the lesson.
|
||||
//
|
||||
export function DocumentBlock({ content, onUpdate, readOnly = false }) {
|
||||
const { convertAssetToMarkdown } = useAssets();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [phase, setPhase] = useState(null); // null | 'compiling' | 'validating' | 'generating'
|
||||
const [pendingAsset, setPendingAsset] = useState(null); // { asset_id, display_name, extension } — while converting
|
||||
const [draft, setDraft] = useState(null); // { markdown, warnings, stats } — awaiting Insert/Discard
|
||||
const [preview, setPreview] = useState(true);
|
||||
|
||||
const hasContent = !!content?.body?.trim();
|
||||
|
||||
const runConversion = async (asset) => {
|
||||
setPendingAsset(asset);
|
||||
setDraft(null);
|
||||
setPhase("compiling");
|
||||
|
||||
const result = await convertAssetToMarkdown(asset.asset_id, {
|
||||
onProgress: (data) => { if (data.phase && data.phase !== "done" && data.phase !== "error") setPhase(data.phase); },
|
||||
});
|
||||
|
||||
setPhase(null);
|
||||
if (result) {
|
||||
setDraft({
|
||||
markdown: result.markdown ?? "",
|
||||
warnings: result.warnings ?? [],
|
||||
stats: result.stats ?? null,
|
||||
asset,
|
||||
});
|
||||
} else {
|
||||
// convertAssetToMarkdown already toasted the backend's specific
|
||||
// error (e.g. "No readable text found…") — just reset to picking.
|
||||
setPendingAsset(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePick = (asset) => {
|
||||
runConversion({ asset_id: asset.asset_id, display_name: asset.display_name, extension: asset.extension });
|
||||
};
|
||||
|
||||
const insertDraft = () => {
|
||||
if (!draft) return;
|
||||
onUpdate({
|
||||
source_asset_id: draft.asset.asset_id,
|
||||
source_filename: draft.asset.display_name,
|
||||
source_ext: draft.asset.extension,
|
||||
body: draft.markdown,
|
||||
});
|
||||
setDraft(null);
|
||||
setPendingAsset(null);
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
setDraft(null);
|
||||
setPendingAsset(null);
|
||||
setPickerOpen(true);
|
||||
};
|
||||
|
||||
// ── Already inserted: behave like a normal editable Markdown block ────────
|
||||
if (hasContent && !draft) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!readOnly && (
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Document Import</Label>
|
||||
{content.source_filename && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="truncate max-w-[180px]">{content.source_filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Re-convert
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<MarkdownBlock content={content} onUpdate={onUpdate} readOnly={readOnly} />
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="document"
|
||||
allowedExtensions={ALLOWED_EXTENSIONS}
|
||||
onSelect={handlePick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Converting ──────────────────────────────────────────────────────────
|
||||
if (phase) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Document Import</Label>
|
||||
<div className="rounded-lg border p-4 space-y-1">
|
||||
<p className="text-sm text-muted-foreground truncate">{pendingAsset?.display_name}</p>
|
||||
<StageProgress phase={phase} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Draft review ────────────────────────────────────────────────────────
|
||||
if (draft) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Document Import — Draft</Label>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b bg-muted/40">
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Converted from <span className="font-medium">{draft.asset.display_name}</span> — review before inserting
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title={preview ? "View raw Markdown" : "Preview"}
|
||||
onClick={() => setPreview((p) => !p)}
|
||||
className="h-6 w-6 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-accent-foreground shrink-0"
|
||||
>
|
||||
{preview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{draft.warnings.length > 0 && (
|
||||
<div className="px-3 pt-2 space-y-1">
|
||||
{draft.warnings.map((w, i) => (
|
||||
<Alert key={i} className="py-1.5">
|
||||
<AlertDescription className="text-xs">{w}</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-[180px] max-h-[400px] overflow-y-auto px-3 py-3">
|
||||
{preview ? (
|
||||
<div className="typeset text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{draft.markdown}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">{draft.markdown}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-3 py-2 border-t bg-muted/40">
|
||||
<Button type="button" variant="outline" size="sm" onClick={discardDraft} className="gap-1.5">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
Discard / Pick another
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={insertDraft} className="gap-1.5">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Insert into Lesson
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty: pick a file ─────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{!readOnly && <Label>Document Import</Label>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={readOnly}
|
||||
className="w-full py-8 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<FileUp className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">Click to select a PDF or PPTX</p>
|
||||
<p className="text-xs text-muted-foreground/70">Text is extracted and converted to Markdown automatically — no images/OCR.</p>
|
||||
</button>
|
||||
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="document"
|
||||
allowedExtensions={ALLOWED_EXTENSIONS}
|
||||
onSelect={handlePick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { MarkdownBlock } from "./MarkdownBlock";
|
||||
|
||||
// The converted output is just Markdown by the time it reaches the client —
|
||||
// no document-specific rendering needed, just delegate straight through.
|
||||
export function DocumentBlock({ content }) {
|
||||
return <MarkdownBlock content={{ body: content?.body ?? "" }} />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// ─── components/DemoteAdminDialog.jsx ────────────────────────────────────────
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
/**
|
||||
* Confirms removing Administrator access from a single user.
|
||||
*
|
||||
* @param {Function} onDemoteAdmin (entity) => Promise
|
||||
*/
|
||||
export function DemoteAdminDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
entity,
|
||||
onDemoteAdmin,
|
||||
loading,
|
||||
onSuccess,
|
||||
}) {
|
||||
const displayName = entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user";
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const res = await onDemoteAdmin(entity);
|
||||
if (res) {
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Demote Administrator</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove Administrator access from{" "}
|
||||
<span className="font-medium text-foreground">{displayName}</span>?
|
||||
Their account will be returned to a standard User role, and they will
|
||||
be notified by email.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
|
||||
{loading && <Spinner className="size-4 mr-2" />}
|
||||
Demote
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// ─── components/MakeAdminDialog.jsx ──────────────────────────────────────────
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
/**
|
||||
* Confirms promoting a single user to Administrator.
|
||||
*
|
||||
* @param {Function} onMakeAdmin (entity) => Promise
|
||||
*/
|
||||
export function MakeAdminDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
entity,
|
||||
onMakeAdmin,
|
||||
loading,
|
||||
onSuccess,
|
||||
}) {
|
||||
const displayName = entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user";
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const res = await onMakeAdmin(entity);
|
||||
if (res) {
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Make Administrator</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to grant{" "}
|
||||
<span className="font-medium text-foreground">{displayName}</span>{" "}
|
||||
Administrator access? They will gain full access to all administrative
|
||||
features, and will be notified by email.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
|
||||
{loading && <Spinner className="size-4 mr-2" />}
|
||||
Make Admin
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export default function RequirementBuilder({ value = [], onChange, courses = [],
|
||||
{item.type === 'visit_link' && (
|
||||
<div className="grid grid-cols-2 gap-3 pl-7">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">URL *</Label>
|
||||
<Label className="text-xs">URL <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
placeholder="https://example.com"
|
||||
value={item.link_url}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
|
||||
import { getTierColor, getContrastText } from "@/utils/tierColors";
|
||||
import AnnouncementCarouselDialog from "@/components/generic/AnnouncementCarouselDialog";
|
||||
|
||||
const ROTATE_INTERVAL_MS = 6000;
|
||||
import AnnouncementDetailsDialog from "@/components/generic/AnnouncementDetailsDialog";
|
||||
|
||||
// resolveNotificationLink already gives explicit link_url (the admin "On
|
||||
// Open" section) precedence over the type-based fallbacks, for broadcasts
|
||||
@@ -18,48 +16,23 @@ function resolveClickAction(stickyAnnouncement) {
|
||||
|
||||
export default function StickyAnnouncementBar() {
|
||||
const navigate = useNavigate();
|
||||
const { stickyAnnouncements, bannerImage, markSeen } = useClientNotifications();
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const { stickyAnnouncements, markSeen } = useClientNotifications();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const count = stickyAnnouncements.length;
|
||||
// Derived rather than clamped via effect — safe the instant the array
|
||||
// shrinks (e.g. after a dismiss), no render with a stale out-of-range index.
|
||||
const safeIndex = count ? Math.min(activeIndex, count - 1) : 0;
|
||||
|
||||
// Auto-rotate through active announcements while more than one is live.
|
||||
useEffect(() => {
|
||||
if (count <= 1) return;
|
||||
const id = setInterval(() => {
|
||||
setActiveIndex((i) => (i + 1) % count);
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [count]);
|
||||
|
||||
const current = stickyAnnouncements[safeIndex];
|
||||
// Only one sticky alert shows at a time — no rotation/autoplay. Dismissing
|
||||
// it (X on the bar) reveals whichever is next in the queue.
|
||||
const current = stickyAnnouncements[0];
|
||||
|
||||
const onDismiss = useCallback(async () => {
|
||||
if (!current) return;
|
||||
await markSeen(current.notification_id);
|
||||
}, [current, markSeen]);
|
||||
|
||||
// Opening the dialog must NOT mark it seen — markSeen removes the row from
|
||||
// stickyAnnouncements, which would unmount this component (dialog included)
|
||||
// before it ever shows.
|
||||
const onClickBanner = useCallback(() => {
|
||||
if (!current) return;
|
||||
setDetailsOpen(true);
|
||||
}, [current]);
|
||||
|
||||
// Closing the details dialog (X, Escape, overlay click — any reason)
|
||||
// dismisses whichever announcement was being viewed at the time. This is
|
||||
// the only dismiss path when multiple are active (no per-item X on the bar
|
||||
// itself — see the count > 1 branch below).
|
||||
const onDialogOpenChange = useCallback((open) => {
|
||||
setDetailsOpen(open);
|
||||
if (!open) void onDismiss();
|
||||
}, [onDismiss]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const swatch = getTierColor(current.color || "indigo").swatch;
|
||||
@@ -94,63 +67,38 @@ export default function StickyAnnouncementBar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{count > 1 && (
|
||||
<div className="absolute right-2 flex items-center gap-1.5">
|
||||
{stickyAnnouncements.map((a, i) => (
|
||||
<button
|
||||
key={a.notification_id}
|
||||
type="button"
|
||||
aria-label={`Show announcement ${i + 1}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveIndex(i);
|
||||
}}
|
||||
className="size-1.5 rounded-full transition-opacity"
|
||||
style={{ backgroundColor: textColor, opacity: i === safeIndex ? 1 : 0.35 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss-X only makes sense for a single active announcement —
|
||||
with multiple, the dialog's own close button (shadcn Dialog)
|
||||
is the way to close/step away, no per-item dismiss from the bar. */}
|
||||
{count <= 1 && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
{/* Only way to dismiss a sticky alert — closing the details dialog
|
||||
no longer dismisses it. */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onDismiss();
|
||||
}}
|
||||
aria-label="Dismiss sticky announcement"
|
||||
title="Dismiss"
|
||||
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnnouncementCarouselDialog
|
||||
<AnnouncementDetailsDialog
|
||||
open={detailsOpen}
|
||||
onOpenChange={onDialogOpenChange}
|
||||
announcements={stickyAnnouncements}
|
||||
activeIndex={safeIndex}
|
||||
onIndexChange={setActiveIndex}
|
||||
onOpenChange={setDetailsOpen}
|
||||
announcement={current}
|
||||
resolveClickAction={resolveClickAction}
|
||||
navigate={navigate}
|
||||
bannerImage={bannerImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user