Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion apps/api/src/lib/content-blocks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { eq } from "@dariah-eric/database";
import { alias, eq } from "@dariah-eric/database";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import * as schema from "@dariah-eric/database/schema";
import * as v from "valibot";
Expand Down Expand Up @@ -31,15 +31,32 @@ export const DataContentBlockSchema = v.object({
limit: v.nullable(v.number()),
});

export const HeroContentBlockSchema = v.object({
type: v.literal("hero"),
title: v.string(),
eyebrow: v.nullable(v.string()),
image: v.nullable(v.object({ url: v.string() })),
ctas: v.nullable(v.array(v.object({ label: v.string(), url: v.string() }))),
});

export const AccordionContentBlockSchema = v.object({
type: v.literal("accordion"),
items: v.array(v.object({ title: v.string(), content: v.optional(v.any()) })),
});

export const ContentBlockSchema = v.union([
RichTextContentBlockSchema,
EmbedContentBlockSchema,
ImageContentBlockSchema,
DataContentBlockSchema,
HeroContentBlockSchema,
AccordionContentBlockSchema,
]);

export type ContentBlock = v.InferOutput<typeof ContentBlockSchema>;

const heroAssets = alias(schema.assets, "hero_assets");

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function getContentBlocks(db: Database | Transaction, entityId: string) {
const rows = await db
Expand All @@ -55,6 +72,11 @@ export async function getContentBlocks(db: Database | Transaction, entityId: str
imageKey: schema.assets.key,
dataLimit: schema.dataContentBlocks.limit,
dataType: schema.dataContentBlockTypes.type,
heroTitle: schema.heroContentBlocks.title,
heroEyebrow: schema.heroContentBlocks.eyebrow,
heroImageKey: heroAssets.key,
heroCtas: schema.heroContentBlocks.ctas,
accordionItems: schema.accordionContentBlocks.items,
})
.from(schema.fields)
.innerJoin(
Expand All @@ -78,6 +100,12 @@ export async function getContentBlocks(db: Database | Transaction, entityId: str
schema.dataContentBlockTypes,
eq(schema.dataContentBlockTypes.id, schema.dataContentBlocks.typeId),
)
.leftJoin(schema.heroContentBlocks, eq(schema.heroContentBlocks.id, schema.contentBlocks.id))
.leftJoin(heroAssets, eq(heroAssets.id, schema.heroContentBlocks.imageId))
.leftJoin(
schema.accordionContentBlocks,
eq(schema.accordionContentBlocks.id, schema.contentBlocks.id),
)
.where(eq(schema.fields.entityId, entityId))
.orderBy(schema.contentBlocks.position);

Expand Down Expand Up @@ -108,6 +136,11 @@ function normalizeRow(row: {
imageKey: string | null;
dataLimit: number | null;
dataType: string | null;
heroTitle: string | null;
heroEyebrow: string | null;
heroImageKey: string | null;
heroCtas: unknown;
accordionItems: unknown;
}): ContentBlock {
switch (row.blockType) {
case "rich_text": {
Expand All @@ -133,6 +166,27 @@ function normalizeRow(row: {
limit: row.dataLimit,
};
}
case "hero": {
return {
type: "hero",
title: row.heroTitle!,
eyebrow: row.heroEyebrow,
image:
row.heroImageKey != null
? images.generateSignedImageUrl({
key: row.heroImageKey,
options: { width: imageWidth.featured },
})
: null,
ctas: row.heroCtas as Array<{ label: string; url: string }> | null,
};
}
case "accordion": {
return {
type: "accordion",
items: (row.accordionItems as Array<{ title: string; content?: unknown }> | null) ?? [],
};
}
default: {
throw new Error(`Unknown content block type: ${row.blockType}`);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ export default async function BreadcrumbsSlot(

if (index === segments.length - 1) {
return (
<BreadcrumbsItem key={href} className="capitalize">
<BreadcrumbsItem key={[index, href].join("-")} className="capitalize">
{route.replaceAll("-", " ")}
</BreadcrumbsItem>
);
}

return (
<Fragment key={href}>
<Fragment key={[index, href].join("-")}>
<BreadcrumbsItem className="capitalize" href={href}>
{route}
</BreadcrumbsItem>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"use client";

import { StarterKit } from "@tiptap/starter-kit";
import { renderToReactElement } from "@tiptap/static-renderer/pm/react";
import type { ReactNode } from "react";

import type { ContentBlock } from "@/app/(app)/[locale]/(dashboard)/dashboard/_components/content-blocks";

function getEmbedUrl(url: string): string {
const watchMatch = /youtube\.com\/watch\?.*?v=([\w-]+)/.exec(url);
if (watchMatch != null) {
return `https://www.youtube-nocookie.com/embed/${watchMatch[1]!}`;
}

const shortMatch = /youtu\.be\/([\w-]+)/.exec(url);
if (shortMatch != null) {
return `https://www.youtube-nocookie.com/embed/${shortMatch[1]!}`;
}

return url;
}

interface ContentBlocksViewProps {
contentBlocks: Array<ContentBlock>;
}

export function ContentBlocksView({ contentBlocks }: Readonly<ContentBlocksViewProps>): ReactNode {
return contentBlocks.map((contentBlock) => {
return <ContentBlockView key={String(contentBlock.id)} contentBlock={contentBlock} />;
});
}

interface ContentBlockViewProps {
contentBlock: ContentBlock;
}

function ContentBlockView({ contentBlock }: Readonly<ContentBlockViewProps>): ReactNode {
switch (contentBlock.type) {
case "accordion": {
const items = contentBlock.content?.items;

if (!items || items.length === 0) {
return null;
}

return (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border">
{items.map((accordionItem, idx) => {
return (
<details key={idx} className="group px-4">
<summary className="flex cursor-pointer items-center justify-between py-3 text-sm font-medium">
{accordionItem.title}
<svg
className="size-4 shrink-0 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M19 9l-7 7-7-7"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
</summary>
{accordionItem.content != null && (
<div className="richtext richtext-sm pb-3">
{renderToReactElement({
content: accordionItem.content,
extensions: [StarterKit],
})}
</div>
)}
</details>
);
})}
</div>
);
}

case "data": {
return null;
}

case "embed": {
const url = contentBlock.content?.url;
const title = contentBlock.content?.title;
const caption = contentBlock.content?.caption;

if (url == null || !url) {
return null;
}

const embedUrl = getEmbedUrl(url);

return (
<figure>
<div className="aspect-video w-full overflow-hidden rounded-lg">
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen={true}
className="size-full"
sandbox="allow-scripts allow-same-origin allow-presentation"
src={embedUrl}
title={title ?? embedUrl}
/>
</div>
{caption != null ? <figcaption>{caption}</figcaption> : null}
</figure>
);
}

case "hero": {
const title = contentBlock.content?.title;
const eyebrow = contentBlock.content?.eyebrow;
const imageUrl = contentBlock.content?.imageUrl;
const ctas = contentBlock.content?.ctas;

if (title == null || !title) {
return null;
}

return (
<div className="flex flex-col gap-y-4">
{eyebrow != null && (
<p className="text-sm font-medium uppercase tracking-wide text-muted-fg">{eyebrow}</p>
)}
<h2 className="text-2xl font-bold">{title}</h2>
{imageUrl != null && (
<img alt="" className="w-full rounded-lg object-cover" src={imageUrl} />
)}
{ctas != null && ctas.length > 0 && (
<div className="flex flex-wrap gap-2">
{ctas.map((cta, idx) => {
return (
<a
key={idx}
className="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-fg"
href={cta.url}
>
{cta.label}
</a>
);
})}
</div>
)}
</div>
);
}

case "image": {
const imageUrl = contentBlock.content?.imageUrl;
const caption = contentBlock.content?.caption;

if (imageUrl == null || !imageUrl) {
return null;
}

return (
<figure>
<img alt={caption ?? ""} src={imageUrl} />
{caption != null ? <figcaption>{caption}</figcaption> : null}
</figure>
);
}

case "rich_text": {
if (!contentBlock.content) {
return null;
}

return (
<div className="richtext richtext-sm">
{renderToReactElement({ content: contentBlock.content, extensions: [StarterKit] })}
</div>
);
}

default: {
return null;
}
}
}
Loading
Loading