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
@@ -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>
);
}