Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PCC-1531] Redesigned smart components #300

Merged
merged 3 commits into from
Aug 21, 2024
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
1,200 changes: 106 additions & 1,094 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions starters/nextjs-starter-approuter-ts/app/api/hello.ts

This file was deleted.

5 changes: 0 additions & 5 deletions starters/nextjs-starter-approuter-ts/app/api/hello/route.ts

This file was deleted.

38 changes: 38 additions & 0 deletions starters/nextjs-starter-approuter-ts/app/api/utils/oembed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import queryString from "query-string";

const oembedURLs = {
twitter: "https://publish.twitter.com/oembed",
instagram: "https://www.instagram.com/api/v1/oembed",
youtube: "https://www.youtube.com/oembed",
};

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
let type = searchParams.get('type');

if (!type) {
return NextResponse.json({ error: "type query required" }, { status: 400 });
}

const oembedUrl = oembedURLs[type as keyof typeof oembedURLs];

if (!oembedUrl) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}

const queryParams = { url };

const response = await fetch(
`${oembedUrl}?${queryString.stringify(queryParams)}`
);

if (response.ok) {
const json = await response.json();
return NextResponse.json(json);
} else {
console.error(await response.text());
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
}
3 changes: 1 addition & 2 deletions starters/nextjs-starter-approuter-ts/app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ export default function GlobalError() {
src="/images/error.png"
alt="Pantheon Logo"
fill
objectFit="contain"
className="grayscale"
className="grayscale object-contain"
/>
</div>
</div>
Expand Down
3 changes: 1 addition & 2 deletions starters/nextjs-starter-approuter-ts/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ export default function NotFound() {
src="/images/error.png"
alt="Pantheon Logo"
fill
objectFit="contain"
className="grayscale"
className="grayscale object-contain"
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { SmartComponentMap } from "@pantheon-systems/pcc-react-sdk/components";
import LeadCapture from "./lead-capture";
import { serverSmartComponentMap } from "./server-components";
import MediaPreview from "./media-preview";
import { withSmartComponentErrorBoundary } from "./error-boundary";

const clientSmartComponentMap: SmartComponentMap = {
LEAD_CAPTURE: {
...serverSmartComponentMap.LEAD_CAPTURE,
reactComponent: LeadCapture,
MEDIA_PREVIEW: {
...serverSmartComponentMap.MEDIA_PREVIEW,
reactComponent: withSmartComponentErrorBoundary(MediaPreview),
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, { Component, Suspense } from "react";

interface Props {
children: React.ReactNode;
}

export class SmartComponentErrorBoundary extends Component<Props> {
state = {
hasError: false,
};

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
if (this.state.hasError) {
return (
<div className="my-2 text-gray-400">
Something went wrong while rendering this smart component.
</div>
);
}

return this.props.children;
}
}

const SmartComponentSuspenseErrorBoundary = ({ children }: Props) => {
return (
<SmartComponentErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
</SmartComponentErrorBoundary>
);
};

export const withSmartComponentErrorBoundary =
// eslint-disable-next-line react/display-name
(Component: React.ComponentType) => (props: Record<string, unknown>) => (
<SmartComponentSuspenseErrorBoundary>
<Component {...props} />
</SmartComponentSuspenseErrorBoundary>
);

export default SmartComponentErrorBoundary;

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getPreviewComponentFromURL, SUPPORTED_PROVIDERS } from "./providers";

interface Props {
url: string;
}

const MediaPreview = ({ url }: Props) => {
const previewComponent = getPreviewComponentFromURL(url);

if (!previewComponent) {
return (
<div className="max-w-[400px] w-full outline outline-black/10 p-4 rounded-md">
<p className="my-2 text-lg font-medium">
Unsupported Media Preview URL &quot;{url}&quot;
</p>
<p className="text-sm">
Supported Platforms: {SUPPORTED_PROVIDERS.join(", ")}
</p>
</div>
);
}

return <div className="w-full">{previewComponent}</div>;
};

export default MediaPreview;
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import Script from "next/script";
import useSWR from "swr";

export const SUPPORTED_PROVIDERS = [
"Youtube",
"Vimeo",
"Twitter",
"X",
"Instagram",
"DailyMotion",
"Loom",
"Any URL (generic iframe)",
];

interface EmbedProps {
url: string;
}

export function getPreviewComponentFromURL(url: string) {
if (!url) return null;

try {
let urlWithProtocol = url;

if (url.indexOf("http://") === -1 && url.indexOf("https://") === -1) {
urlWithProtocol = `https://${url}`;
} else if (url.indexOf("http://") === 0) {
url = url.replace("http://", "https://");
}

const urlObj = new URL(urlWithProtocol);
const hostname = urlObj.hostname;
const hostnameParts = hostname.split(".");
const provider = hostnameParts[hostnameParts.length - 2];

switch (provider.toLowerCase()) {
case "youtube":
case "youtu":
case "yt":
return <YoutubePreview url={urlWithProtocol} />;

case "twitter":
case "x":
return <TwitterPreview url={urlWithProtocol} />;

case "vimeo":
return <VimeoPreview url={urlWithProtocol} />;
case "instagram":
return <InstagramPreview url={urlWithProtocol} />;
case "dailymotion":
return <DailyMotionPreview url={urlWithProtocol} />;
case "loom":
return <LoomPreview url={urlWithProtocol} />;
default:
return <GenericIframe url={urlWithProtocol} />;
}
} catch (e) {
console.error("Media smart component render failed", e, { url });
return null;
}
}

function extractVideoId(url: string) {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const pathnameParts = pathname.split("/");
return pathnameParts[pathnameParts.length - 1];
}

function VimeoPreview({ url }: EmbedProps) {
const embedUrl = `https://player.vimeo.com/video/${extractVideoId(url)}`;

return (
<div className="responsive-iframe-container">
<iframe
src={embedUrl}
className="rounded-2xl responsive-iframe"
/>
</div>
);
}

function DailyMotionPreview({ url }: EmbedProps) {
const embedUrl = `https://www.dailymotion.com/embed/video/${extractVideoId(
url,
)}`;

return (
<div className="responsive-iframe-container">
<iframe
src={embedUrl}
className="rounded-2xl responsive-iframe"
/>
</div>
);
}

interface OEmbedResponse {
html: string;
width: number;
thumbnail_width: number;
thumbnail_height: number;
}

const fetcher = (url: string) => fetch(url).then((res) => res.json());

function YoutubePreview({ url }: EmbedProps) {
const { data, error, isLoading } = useSWR<OEmbedResponse>(
`/api/utils/oembed?url=${encodeURIComponent(url)}&type=youtube`,
fetcher,
);

if (error) return <div>Error loading Youtube preview</div>;
if (isLoading) return <div>Loading...</div>;
if (!data) return <div>Unable to parse iframe</div>;

// Set width of iframe to thumbnail width and height
const html = data.html
// Remove width and height attributes from iframe
.replace(/width="(\d+)"/, `"`)
.replace(/height="(\d+)"/, `"`)
// Add responsive-iframe class
.replace(/<iframe/, `<iframe class="responsive-iframe"`);

return (
<div
dangerouslySetInnerHTML={{ __html: html }}
className="responsive-iframe-container [&>iframe]:rounded-2xl"
/>
);
}

function TwitterPreview({ url }: EmbedProps) {
// Replace X with Twitter
const twitterURL = url.replace("x.com", "twitter.com");

const { data, error, isLoading } = useSWR<OEmbedResponse>(
`/api/utils/oembed?url=${encodeURIComponent(twitterURL)}&type=twitter`,
fetcher,
);

if (error) return <div>Error loading Twitter preview</div>;
if (!data) return <div>Unable to parse iframe</div>;
if (isLoading) return <div>Loading...</div>;

return (
<>
<div
style={{ maxWidth: data.width }}
dangerouslySetInnerHTML={{ __html: data.html }}
/>
<Script src="https://platform.twitter.com/widgets.js" />
</>
);
}

function LoomPreview({ url }: EmbedProps) {
return (
<div className="responsive-iframe-container">
<iframe
src={`https://www.loom.com/embed/${extractVideoId(url)}`}
allowFullScreen
className="rounded-2xl responsive-iframe"
></iframe>
</div>
);
}

function GenericIframe({ url }: EmbedProps) {
return (
<div className="responsive-iframe-container">
<iframe
src={url}
allowFullScreen
className="rounded-2xl responsive-iframe"
></iframe>
</div>
);
}

function InstagramPreview({ url }: EmbedProps) {
const { data, error, isLoading } = useSWR<OEmbedResponse>(
`/api/utils/oembed?url=${encodeURIComponent(url)}&type=instagram`,
fetcher,
);

if (error) return <div>Error loading Instagram preview</div>;
if (!data) return <div>Unable to parse iframe</div>;
if (isLoading) return <div>Loading...</div>;

return (
<>
<div
style={{ maxWidth: data.width }}
dangerouslySetInnerHTML={{ __html: data.html }}
className="[&>iframe]:!rounded-2xl"
/>
<Script src="https://www.instagram.com/embed.js" />
</>
);
}
Loading