mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
wip: course layout and func to context
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user