Files
starr-philproperties/src/modules/admin/components/courses/LessonsPreview.jsx
T
2026-08-03 12:03:11 +08:00

235 lines
11 KiB
React

/* A similar strategy to BlockList.jsx so this will applies
to Client side */
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
import { PhotoProvider, PhotoView } from "react-photo-view";
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
import { TextImageBlock } from "@/components/generic/Blocks/Client/TextImageBlock";
import { ImageBlock } from "@/components/generic/Blocks/Client/ImageBlock";
import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
export function LessonHeader({ lesson }) {
if (!lesson) return null;
return (
<div className="space-y-3 pb-2 sm:space-y-4">
<div>
<h1 className="text-2xl font-bold leading-tight mb-1 sm:text-3xl sm:leading-[1.2]">
{lesson.title}
</h1>
{lesson.description && (
<p className="mt-1 leading-relaxed text-sm text-muted-foreground sm:text-base sm:leading-[1.75] sm:text-justify">
{lesson.description}
</p>
)}
</div>
{lesson.objectives?.length > 0 && (
<div className="rounded-lg border p-3 space-y-2 sm:p-4 sm:space-y-3">
<div className="flex items-center gap-2">
<div className="h-7 w-7 rounded-md bg-green-100 flex items-center justify-center shrink-0 sm:h-8 sm:w-8">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5 text-green-600 sm:h-4 sm:w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
</div>
<p className="text-xs font-semibold sm:text-sm">Objective</p>
</div>
<ul className="space-y-1 list-disc list-inside sm:space-y-1.5">
{lesson.objectives.map((o) => (
<li key={o.objective_id} className="text-xs text-muted-foreground sm:text-sm">
{o.text}
</li>
))}
</ul>
</div>
)}
</div>
);
}
// ─── Zoomable image wrapper ───────────────────────────────────────────────────
// Must be rendered inside a <PhotoProvider>. Shows a subtle zoom hint on hover.
export function ZoomableImage({ url, alt }) {
if (!url) return null;
return (
<PhotoView src={url}>
<div className="relative group cursor-zoom-in">
<img
src={url}
alt={alt ?? ""}
className="w-full rounded-md object-cover aspect-video"
draggable={false}
/>
{/* Zoom hint badge — fades in on hover */}
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div className="flex items-center gap-1 bg-secondary text-sm px-2 py-1 rounded-full border">
<ZoomIn className="size-4" />
<span>Zoom</span>
</div>
</div>
</div>
</PhotoView>
);
}
export function PreviewImage({ url, alt }) {
if (!url) {
return (
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<ImageIcon className="h-4 w-4" />
No image
</div>
);
}
return <ZoomableImage url={url} alt={alt} />;
}
export function PreviewVideo({ url, thumb }) {
if (!url) {
return (
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<VideoIcon className="h-4 w-4" />
No video
</div>
);
}
return (
<div className="relative rounded-md overflow-hidden aspect-video bg-muted">
{thumb ? (
<img src={thumb} alt="Video thumbnail" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<VideoIcon className="h-8 w-8 text-muted-foreground/40 sm:h-10 sm:w-10" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="h-8 w-8 rounded-full bg-black/50 flex items-center justify-center sm:h-10 sm:w-10">
<VideoIcon className="h-4 w-4 text-white sm:h-5 sm:w-5" />
</div>
</div>
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
<p className="text-white text-[9px] truncate sm:text-[10px]">{url}</p>
</div>
</div>
);
}
// onWatchProgress(percent, { blockId, blockType }) — only meaningful for video/audio
// blocks; optional, undefined in admin preview mode (only the client reader passes it,
// for watch_percent/watch_video/listen_audio tracking). PreviewBlock (not VideoBlock/
// AudioBlock themselves) attaches the block's own id/type to each call, since a lesson
// can have several blocks of the same type and watch_video/listen_audio need to know
// which specific one just reported progress.
export function PreviewBlock({ block, onWatchProgress, resumeMap, antiSkipEnabled }) {
const { id, type, content } = block;
const withBlockMeta = onWatchProgress
? (percent) => onWatchProgress(percent, { blockId: id, blockType: type })
: undefined;
const resumePercent = resumeMap?.[id];
switch (type) {
case "text":
return <TextBlock blockId={id} content={content} readOnly />;
case "image":
return <ImageBlock content={content} readOnly />;
case "text-image":
return <TextImageBlock blockId={id} content={content} readOnly />;
case "video":
return <VideoBlock content={content} readOnly onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} readOnly onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
case "audio":
return <AudioBlock content={content} onWatchProgress={withBlockMeta} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />;
case "code":
return <CodeBlock content={content} />;
case "markdown":
return <MarkdownBlock content={content} />;
default:
return null;
}
}
// ─── PreviewContent ───────────────────────────────────────────────────────────
// PhotoProvider wraps ALL blocks so images across the whole lesson share
// one lightbox session — users can swipe between them naturally.
// Only these completion-requirement types gate video/audio behind the anti-skip
// seek-cap — everything else (or no requirement configured) allows free seeking.
const WATCH_TYPE_REQUIREMENTS = ["watch_percent", "watch_video", "listen_audio"];
export function PreviewContent({ lesson, blocks, empty = "No content yet.", showHeader = true, onWatchProgress }) {
const resumeMap = lesson?.resume_positions;
const antiSkipEnabled = WATCH_TYPE_REQUIREMENTS.includes(lesson?.completion?.type);
return (
<PhotoProvider
speed={() => 300}
easing={(type) => (type === 2 ? "cubic-bezier(0.36, 0, 0.66, -0.56)" : "cubic-bezier(0.34, 1.56, 0.64, 1)")}
toolbarRender={({ onScale, scale, rotate, onRotate }) => (
<div className="flex items-center gap-3 px-2">
<button
onClick={() => onScale(scale + 0.5)}
className="text-white/80 hover:text-white transition-colors"
title="Zoom in"
>
<ZoomIn className="h-5 w-5" />
</button>
</div>
)}
>
{showHeader && <LessonHeader lesson={lesson} />}
{blocks.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
<p>{empty}</p>
</div>
) : (
<div className="space-y-4 sm:space-y-5">
{blocks.map((block) => (
<div key={block.id}>
<PreviewBlock block={block} onWatchProgress={onWatchProgress} resumeMap={resumeMap} antiSkipEnabled={antiSkipEnabled} />
</div>
))}
</div>
)}
</PhotoProvider>
);
}
export function PreviewChrome({ title, children, showChrome = true }) {
if (!showChrome) {
return <>{children}</>;
}
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-1.5 px-3 py-2 bg-muted/60 border-b">
<span className="hidden h-2.5 w-2.5 rounded-full bg-red-400 sm:inline-block" />
<span className="hidden h-2.5 w-2.5 rounded-full bg-yellow-400 sm:inline-block" />
<span className="hidden h-2.5 w-2.5 rounded-full bg-green-400 sm:inline-block" />
<div className="flex-1 sm:mx-3 h-5 rounded bg-background/60 border text-[10px]
flex items-center px-2 text-muted-foreground/60 truncate">
{title ?? "Lesson Preview"}
</div>
<Eye className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
</div>
<div className="p-3 sm:p-5">
{children}
</div>
</div>
);
}