wip: course layout and func to context

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-05-16 11:05:04 +08:00
parent dcf5f43fd5
commit 2b1955608d
38 changed files with 3086 additions and 9 deletions
+75
View File
@@ -0,0 +1,75 @@
// components/generic/CMS/AddBlockMenu.jsx
import { Plus, Type, Image, ImagePlay, Video, VideoIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const BLOCK_TYPES = [
{
type: "text",
label: "Text",
description: "A rich text block",
icon: <Type className="h-4 w-4" />,
},
{
type: "image",
label: "Image",
description: "A single image",
icon: <Image className="h-4 w-4" />,
},
{
type: "text-image",
label: "Text + Image",
description: "Text beside an image",
icon: <ImagePlay className="h-4 w-4" />,
},
{
type: "video",
label: "Video",
description: "A single video",
icon: <VideoIcon className="h-4 w-4" />,
},
{
type: "text-video",
label: "Text + Video",
description: "Text beside a video",
icon: <Video className="h-4 w-4" />,
},
];
export function AddBlockMenu({ onAdd }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" className="w-full border-dashed gap-2">
<Plus className="h-4 w-4" />
Add Block
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuLabel>Choose a block type</DropdownMenuLabel>
<DropdownMenuSeparator />
{BLOCK_TYPES.map(({ type, label, description, icon }) => (
<DropdownMenuItem
key={type}
onClick={() => onAdd(type)}
className="flex items-start gap-3 py-2 cursor-pointer"
>
<span className="mt-0.5 text-muted-foreground">{icon}</span>
<div className="flex flex-col">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from "react";
import { Search, CheckCircle2 } from "lucide-react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { useAssets } from "@/contexts/AdminAssetsContext";
// ─── Empty ────────────────────────────────────────────────────────────────────
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">
No {fileType} assets found.
</p>
</div>
);
}
// ─── Asset Card ───────────────────────────────────────────────────────────────
function AssetCard({ asset, selected, onSelect }) {
const thumb = asset.thumbnail_url ?? asset.file_url;
return (
<button
type="button"
onClick={() => onSelect(asset)}
className={[
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
"hover:border-primary/60 hover:shadow-sm",
selected
? "border-primary ring-2 ring-primary/20"
: "border-border",
].join(" ")}
>
{/* Thumbnail */}
<div className="aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<img
src={thumb}
alt={asset.display_name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-xs text-muted-foreground">No preview</span>
</div>
)}
</div>
{/* Name */}
<div className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p>
</div>
{/* Selected checkmark */}
{selected && (
<div className="absolute top-1.5 right-1.5">
<CheckCircle2 className="h-5 w-5 text-primary fill-white" />
</div>
)}
</button>
);
}
// ─── Sheet ────────────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const LIMIT = 12;
// ── Fetch on open / search / page change ──────────────────────────────────
useEffect(() => {
if (!open) return;
fetchAssets({
page,
limit: LIMIT,
filters: [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(search ? [{ id: "display_name", value: [search] }] : []),
],
});
}, [open, page, search, fileType]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
if (!open) {
setSearch("");
setPage(1);
setSelected(null);
}
}, [open]);
const handleSelect = (asset) => {
setSelected(asset.asset_id);
onSelect(asset);
onOpenChange(false);
};
const label = fileType
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Assets";
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
{/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle>
<SheetDescription>
Click an asset to attach it.
</SheetDescription>
</SheetHeader>
{/* ── Search ── */}
<div className="px-6 py-3 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-9"
/>
</div>
</div>
{/* ── Grid ── */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{loading ? (
<div className="flex items-center justify-center h-48">
<Spinner className="h-5 w-5" />
</div>
) : !assets.length ? (
<EmptyState fileType={fileType ?? "asset"} />
) : (
<div className="grid grid-cols-2 gap-3">
{assets.map((asset) => (
<AssetCard
key={asset.asset_id}
asset={asset}
selected={selected === asset.asset_id}
onSelect={handleSelect}
/>
))}
</div>
)}
</div>
{/* ── Pagination ── */}
{pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t text-sm">
<span className="text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
disabled={!pagination.hasPrevPage || loading}
onClick={() => setPage((p) => p - 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
Prev
</button>
<button
type="button"
disabled={!pagination.hasNextPage || loading}
onClick={() => setPage((p) => p + 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
Next
</button>
</div>
</div>
)}
</SheetContent>
</Sheet>
);
}
+66
View File
@@ -0,0 +1,66 @@
// components/generic/CMS/BlockList.jsx
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/TextBlock";
import { ImageBlock } from "./Blocks/ImageBlock";
import { TextImageBlock } from "./Blocks/TextImageBlock";
import { VideoBlock } from "./Blocks/VideoBlock";
import { TextVideoBlock } from "./Blocks/TextVideoBlock";
// ─── Block renderer ───────────────────────────────────────────────────────────
function BlockContent({ type, content, onUpdate }) {
switch (type) {
case "text": return <TextBlock content={content} onUpdate={onUpdate} />;
case "image": return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image": return <TextImageBlock content={content} onUpdate={onUpdate} />;
case "video": return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video": return <TextVideoBlock content={content} onUpdate={onUpdate} />;
default: return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
}
// ─── Default content per type ─────────────────────────────────────────────────
export const DEFAULT_CONTENT = {
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
};
// ─── List ─────────────────────────────────────────────────────────────────────
export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
if (!blocks.length) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 py-16 gap-2">
<p className="text-sm text-muted-foreground">No blocks yet.</p>
<p className="text-xs text-muted-foreground">Use the button below to add your first block.</p>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{blocks.map((block, index) => (
<BlockWrapper
key={block.id}
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
type={block.type}
content={block.content}
onUpdate={(updated) => onUpdate(block.id, updated)}
/>
</BlockWrapper>
))}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
// components/generic/CMS/BlockWrapper.jsx
import { GripVertical, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
const BLOCK_LABELS = {
"text": "Text",
"image": "Image",
"text-image": "Text + Image",
"video": "Video",
"text-video": "Text + Video",
};
export function BlockWrapper({
type,
index,
total,
onMoveUp,
onMoveDown,
onDelete,
children,
}) {
return (
<div className="group relative rounded-lg border bg-card transition-shadow hover:shadow-sm">
{/* ── Top toolbar ── */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 rounded-t-lg">
{/* Left — drag handle + block type */}
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<Badge variant="secondary" className="text-xs">
{BLOCK_LABELS[type] ?? type}
</Badge>
<span className="text-xs text-muted-foreground">
Block {index + 1}
</span>
</div>
{/* Right — move + delete */}
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={index === 0}
onClick={onMoveUp}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={index === total - 1}
onClick={onMoveDown}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* ── Block content ── */}
<div className="p-4">
{children}
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { AssetPickerSheet } from "../AssetPickerSheet";
function MediaPlaceholder({ onClick }) {
return (
<button
type="button"
onClick={onClick}
className="w-full aspect-video 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"
>
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
Click to select an image
</p>
</button>
);
}
export function ImageBlock({ content, onUpdate }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
<div className="space-y-3">
<Label>Image</Label>
{content.url ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
<img
src={content.url}
alt={content.alt ?? ""}
className="w-full aspect-video object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<p className="text-white text-sm font-medium">Change Image</p>
</div>
</div>
) : (
<MediaPlaceholder onClick={() => setPickerOpen(true)} />
)}
{content.url && (
<div className="space-y-1.5">
<Label>Alt Text</Label>
<Input
placeholder="Describe the image..."
value={content.alt ?? ""}
onChange={(e) => onUpdate({ ...content, alt: e.target.value })}
/>
</div>
)}
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
})}
/>
</div>
);
}
@@ -0,0 +1,18 @@
// components/generic/CMS/blocks/TextBlock.jsx
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
export function TextBlock({ content, onUpdate }) {
return (
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={4}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
/>
</div>
);
}
@@ -0,0 +1,111 @@
// components/generic/CMS/blocks/TextImageBlock.jsx
import { useState } from "react";
import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AssetPickerSheet } from "../AssetPickerSheet";
export function TextImageBlock({ content, onUpdate }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
<div className="space-y-4">
{/* ── Layout ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Image Position</Label>
<Select
value={content.image_position ?? "right"}
onValueChange={(v) => onUpdate({ ...content, image_position: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className={[
"grid gap-4",
"grid-cols-1 md:grid-cols-2",
].join(" ")}>
{/* ── Text side ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={5}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
/>
</div>
{/* ── Image side ── */}
<div className={[
"space-y-1.5",
content.image_position === "left" ? "md:order-first" : "",
].join(" ")}>
<Label>Image</Label>
{content.url ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
<img
src={content.url}
alt={content.alt ?? ""}
className="w-full aspect-video object-cover"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<p className="text-white text-sm font-medium">Change Image</p>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full aspect-video 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"
>
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select an image</p>
</button>
)}
{content.url && (
<div className="space-y-1.5">
<Label>Alt Text</Label>
<Input
placeholder="Describe the image..."
value={content.alt ?? ""}
onChange={(e) => onUpdate({ ...content, alt: e.target.value })}
/>
</div>
)}
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
})}
/>
</div>
);
}
@@ -0,0 +1,108 @@
import { useState } from "react";
import { VideoIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { AssetPickerSheet } from "../AssetPickerSheet";
export function TextVideoBlock({ content, onUpdate }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
return (
<div className="space-y-4">
{/* ── Layout ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Video Position</Label>
<Select
value={content.video_position ?? "right"}
onValueChange={(v) => onUpdate({ ...content, video_position: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<Textarea
placeholder="Enter text content..."
rows={5}
value={content.body ?? ""}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
/>
</div>
{/* ── Video side ── */}
<div className={[
"space-y-1.5",
content.video_position === "left" ? "md:order-first" : "",
].join(" ")}>
<Label>Video</Label>
{content.url ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
{thumb ? (
<img
src={thumb}
alt="Video thumbnail"
className="w-full aspect-video object-cover"
/>
) : (
<div className="w-full aspect-video bg-muted flex items-center justify-center">
<VideoIcon className="h-10 w-10 text-muted-foreground/50" />
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<p className="text-white text-sm font-medium">Change Video</p>
</div>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="h-12 w-12 rounded-full bg-black/50 flex items-center justify-center">
<VideoIcon className="h-5 w-5 text-white" />
</div>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full aspect-video 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"
>
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select a video</p>
</button>
)}
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
})}
/>
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from "react";
import { VideoIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../AssetPickerSheet";
export function VideoBlock({ content, onUpdate }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
return (
<div className="space-y-3">
<Label>Video</Label>
{content.url ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
{thumb ? (
<img
src={thumb}
alt="Video thumbnail"
className="w-full aspect-video object-cover"
/>
) : (
<div className="w-full aspect-video bg-muted flex items-center justify-center">
<VideoIcon className="h-10 w-10 text-muted-foreground/50" />
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<p className="text-white text-sm font-medium">Change Video</p>
</div>
{/* Play icon overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="h-12 w-12 rounded-full bg-black/50 flex items-center justify-center">
<VideoIcon className="h-5 w-5 text-white" />
</div>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full aspect-video 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"
>
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select a video</p>
</button>
)}
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
})}
/>
</div>
);
}