diff --git a/package.json b/package.json index 7d922d6..cc03954 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "framer-motion": "^12.38.0", "input-otp": "^1.4.2", "lucide-react": "^1.14.0", + "nanoid": "^5.1.11", "next-themes": "^0.4.6", "radix-ui": "^1.4.3", "react": "^19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ae3d87..4218d5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: lucide-react: specifier: ^1.14.0 version: 1.14.0(react@19.2.5) + nanoid: + specifier: ^5.1.11 + version: 5.1.11 next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2647,6 +2650,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5821,6 +5829,8 @@ snapshots: nanoid@3.3.12: {} + nanoid@5.1.11: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index 1fe91b4..ab7a77a 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -1,21 +1,13 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { Search, CheckCircle2 } from "lucide-react"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, -} from "@/components/ui/sheet"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from "@/components/ui/sheet"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import { Badge } from "@/components/ui/badge"; import { useAssets } from "@/contexts/AdminAssetsContext"; -// ─── Empty ──────────────────────────────────────────────────────────────────── - function EmptyState({ fileType }) { return (
@@ -26,8 +18,6 @@ function EmptyState({ fileType }) { ); } -// ─── Asset Card ─────────────────────────────────────────────────────────────── - function AssetCard({ asset, selected, onSelect }) { const thumb = asset.thumbnail_url ?? asset.file_url; @@ -43,7 +33,6 @@ function AssetCard({ asset, selected, onSelect }) { : "border-border", ].join(" ")} > - {/* Thumbnail */}
{thumb ? ( )}
- - {/* Name */}

{asset.display_name}

- - {/* Selected checkmark */} {selected && (
@@ -73,33 +58,39 @@ function AssetCard({ asset, selected, onSelect }) { ); } -// ─── 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 [search, setSearch] = useState(""); + const [committed, setCommitted] = useState(""); // ← only updates on search trigger + const [page, setPage] = useState(1); + const [selected, setSelected] = useState(null); const LIMIT = 12; - // ── Fetch on open / search / page change ────────────────────────────────── + const triggerSearch = useCallback(() => { + setCommitted(search); + setPage(1); + }, [search]); + + // ── Fetch only when committed search, page, or open changes ────────────── useEffect(() => { if (!open) return; fetchAssets({ page, limit: LIMIT, filters: [ - ...(fileType ? [{ id: "file_type", value: [fileType] }] : []), - ...(search ? [{ id: "display_name", value: [search] }] : []), + ...(fileType ? [{ id: "file_type", value: [fileType] }] : []), + ...(committed ? [{ id: "display_name", value: [committed] }] : []), ], }); - }, [open, page, search, fileType]); + }, [open, page, committed, fileType]); // ── Reset on close ──────────────────────────────────────────────────────── useEffect(() => { if (!open) { setSearch(""); + setCommitted(""); setPage(1); setSelected(null); } @@ -129,14 +120,24 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { {/* ── Search ── */}
-
- - { setSearch(e.target.value); setPage(1); }} - className="pl-9" - /> +
+
+ + setSearch(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && triggerSearch()} + className="pl-9" + /> +
+
diff --git a/src/components/generic/BlockList.jsx b/src/components/generic/BlockList.jsx index 7e1fe54..72a4517 100644 --- a/src/components/generic/BlockList.jsx +++ b/src/components/generic/BlockList.jsx @@ -1,66 +1,84 @@ // components/generic/CMS/BlockList.jsx -import { BlockWrapper } from "./BlockWrapper"; -import { TextBlock } from "./Blocks/TextBlock"; -import { ImageBlock } from "./Blocks/ImageBlock"; +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 { VideoBlock } from "./Blocks/VideoBlock"; import { TextVideoBlock } from "./Blocks/TextVideoBlock"; // ─── Block renderer ─────────────────────────────────────────────────────────── +// +// blockId is forwarded to TextBlock (and any future rich-text blocks) so the +// WYSIWYG editor knows exactly when to seed its innerHTML from saved data. -function BlockContent({ type, content, onUpdate }) { - switch (type) { - case "text": return ; - case "image": return ; - case "text-image": return ; - case "video": return ; - case "text-video": return ; - default: return

Unknown block type.

; - } +function BlockContent({ block, onUpdate }) { + const { id, type, content } = block; + + switch (type) { + case "text": + return ( + + ); + case "image": + return ; + case "text-image": + return ; + case "video": + return ; + case "text-video": + return ; + default: + return

Unknown block type.

; + } } // ─── 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" }, + "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 ( -
-

No blocks yet.

-

Use the button below to add your first block.

-
- ); - } - + if (!blocks.length) { return ( -
- {blocks.map((block, index) => ( - onMove(block.id, "up")} - onMoveDown={() => onMove(block.id, "down")} - onDelete={() => onDelete(block.id)} - > - onUpdate(block.id, updated)} - /> - - ))} -
+
+

No blocks yet.

+

+ Use the button below to add your first block. +

+
); + } + + return ( +
+ {blocks.map((block, index) => ( + onMove(block.id, "up")} + onMoveDown={() => onMove(block.id, "down")} + onDelete={() => onDelete(block.id)} + > + onUpdate(block.id, updated)} + /> + + ))} +
+ ); } \ No newline at end of file diff --git a/src/components/generic/Blocks/TextBlock.jsx b/src/components/generic/Blocks/TextBlock.jsx index d456cf6..3bf1f65 100644 --- a/src/components/generic/Blocks/TextBlock.jsx +++ b/src/components/generic/Blocks/TextBlock.jsx @@ -1,18 +1,457 @@ -// components/generic/CMS/blocks/TextBlock.jsx +// components/generic/CMS/Blocks/TextBlock.jsx -import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; +import { useEffect, useRef, useState } from "react"; +import { + Bold, Italic, Underline, + AlignLeft, AlignCenter, AlignRight, AlignJustify, + List, ListOrdered, Indent, Outdent, + Link2, FileText, Table, +} from "lucide-react"; +import { Label } from "@/components/ui/label"; +import { AssetPickerSheet } from "../AssetPickerSheet"; +import { cn } from "@/lib/utils"; -export function TextBlock({ content, onUpdate }) { - return ( -
- -