mirror of
https://github.com/jnsahaj/tweakcn.git
synced 2026-08-28 23:02:07 +08:00
Support for Google Fonts in Preview script and Error Feedback (#202)
* feat: Add support for loading Google Fonts in the embed script. * feat: Add error state and UI feedback
This commit is contained in:
@@ -8,8 +8,11 @@ import {
|
||||
} from "@/components/block-viewer";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { LoadingLogo } from "@/components/editor/ai/loading-logo";
|
||||
import { HorizontalScrollArea } from "@/components/horizontal-scroll-area";
|
||||
import { SocialLink } from "@/components/social-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useIframeThemeInjector } from "@/hooks/use-iframe-theme-injector";
|
||||
import { useWebsitePreview } from "@/hooks/use-website-preview";
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
ExternalLink,
|
||||
Globe,
|
||||
GlobeLock,
|
||||
Info,
|
||||
Loader,
|
||||
RefreshCw,
|
||||
X,
|
||||
@@ -101,7 +105,7 @@ function DynamicWebsitePreviewProvider({
|
||||
}) {
|
||||
const websitePreviewState = useWebsitePreview({ allowCrossOrigin });
|
||||
|
||||
const { status, retryValidation } = useIframeThemeInjector({
|
||||
const { status, retryValidation, themeInjectionError } = useIframeThemeInjector({
|
||||
allowCrossOrigin: allowCrossOrigin && !!websitePreviewState.currentUrl,
|
||||
iframeRef: websitePreviewState.iframeRef,
|
||||
});
|
||||
@@ -110,6 +114,7 @@ function DynamicWebsitePreviewProvider({
|
||||
...websitePreviewState,
|
||||
status,
|
||||
retryValidation,
|
||||
themeInjectionError,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -124,7 +129,7 @@ function DynamicToolbarControls() {
|
||||
inputUrl,
|
||||
setInputUrl,
|
||||
currentUrl,
|
||||
isLoading,
|
||||
isLoading: previewIsLoading,
|
||||
loadUrl,
|
||||
refreshIframe,
|
||||
openInNewTab,
|
||||
@@ -157,18 +162,18 @@ function DynamicToolbarControls() {
|
||||
|
||||
<Button
|
||||
onClick={loadUrl}
|
||||
disabled={isLoading || !inputUrl}
|
||||
disabled={previewIsLoading || !inputUrl}
|
||||
size="sm"
|
||||
className="h-8 w-16 shadow-none"
|
||||
>
|
||||
{isLoading ? <Loader className="size-3 animate-spin" /> : "Load"}
|
||||
{previewIsLoading ? <Loader className="size-3 animate-spin" /> : "Load"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={refreshIframe}
|
||||
disabled={isLoading || !currentUrl}
|
||||
disabled={previewIsLoading || !currentUrl}
|
||||
className="size-8 shadow-none"
|
||||
>
|
||||
<RefreshCw className={cn("size-3")} />
|
||||
@@ -194,17 +199,18 @@ function DynamicToolbarControls() {
|
||||
function DynamicIframeContent() {
|
||||
const {
|
||||
currentUrl,
|
||||
isLoading,
|
||||
error,
|
||||
isLoading: previewIsLoading,
|
||||
error: previewError,
|
||||
status,
|
||||
retryValidation,
|
||||
allowCrossOrigin,
|
||||
iframeRef,
|
||||
handleIframeLoad,
|
||||
handleIframeError,
|
||||
themeInjectionError,
|
||||
} = useDynamicWebsitePreview();
|
||||
|
||||
if (!currentUrl && !error) {
|
||||
if (!currentUrl && !previewError) {
|
||||
return (
|
||||
<div className="relative size-full overflow-hidden p-4">
|
||||
<div className="text-muted-foreground mx-auto flex h-full max-w-lg flex-col items-center justify-center space-y-6">
|
||||
@@ -222,10 +228,15 @@ function DynamicIframeContent() {
|
||||
<p className="text-foreground text-lg font-medium">Preview External Websites</p>
|
||||
<p className="text-muted-foreground text-sm text-pretty">
|
||||
Enter a URL to preview websites built with{" "}
|
||||
<span className="font-medium">shadcn/ui</span> components.
|
||||
{allowCrossOrigin
|
||||
? "External sites can integrate with tweakcn by including our script for live theme previews."
|
||||
: "Same-origin websites support direct theme injection without requiring external scripts."}
|
||||
<span className="font-medium">shadcn/ui</span> components.{" "}
|
||||
{allowCrossOrigin ? (
|
||||
<span>
|
||||
External sites can integrate with tweakcn by including our script for{" "}
|
||||
<span className="font-medium">live theme previews</span>.
|
||||
</span>
|
||||
) : (
|
||||
"Same-origin websites support direct theme injection without requiring external scripts."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -246,12 +257,12 @@ function DynamicIframeContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (previewError) {
|
||||
return (
|
||||
<div className="relative size-full overflow-hidden p-4">
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-2 p-4 text-center">
|
||||
<p className="text-destructive text-sm font-medium">Error Loading Website</p>
|
||||
<span className="text-muted-foreground max-w-md text-xs">{error}</span>
|
||||
<span className="text-muted-foreground max-w-md text-xs">{previewError}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -259,7 +270,7 @@ function DynamicIframeContent() {
|
||||
|
||||
return (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
{isLoading && (
|
||||
{previewIsLoading && (
|
||||
<div className="bg-background/80 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-6">
|
||||
@@ -285,18 +296,19 @@ function DynamicIframeContent() {
|
||||
onError={handleIframeError}
|
||||
sandbox={
|
||||
allowCrossOrigin
|
||||
? "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
? "allow-scripts allow-same-origin allow-forms allow-popups"
|
||||
: "allow-scripts allow-same-origin"
|
||||
}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{!isLoading && !!status && allowCrossOrigin && (
|
||||
<div className="bg-background/60 outline-border/50 absolute bottom-2 left-2 z-10 rounded-md px-2 py-1 outline-2 backdrop-blur-lg">
|
||||
{!previewIsLoading && !!status && allowCrossOrigin && (
|
||||
<div className="absolute bottom-2 left-2 z-10">
|
||||
<ConnectionStatus
|
||||
status={status}
|
||||
retryValidation={retryValidation}
|
||||
isLoading={isLoading}
|
||||
isLoading={previewIsLoading}
|
||||
errorMsg={themeInjectionError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -308,45 +320,119 @@ function ConnectionStatus({
|
||||
status,
|
||||
retryValidation,
|
||||
isLoading,
|
||||
errorMsg,
|
||||
}: {
|
||||
status: IframeStatus;
|
||||
retryValidation: () => void;
|
||||
isLoading: boolean;
|
||||
errorMsg?: string | null;
|
||||
}) {
|
||||
if (isLoading || status === "unknown") return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-8 items-center gap-2">
|
||||
{ICONS[status]}
|
||||
<span className="text-muted-foreground text-sm font-medium">{TEXTS[status]}</span>
|
||||
{(status === "missing" || status === "unsupported") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-1 text-xs"
|
||||
onClick={retryValidation}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
<div className="bg-popover/90 outline-border/50 flex h-8 items-center gap-2 rounded-lg px-2 shadow-sm outline backdrop-blur-lg">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-foreground/90">
|
||||
{errorMsg ? (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>{ICONS[status]}</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
align="start"
|
||||
side="top"
|
||||
className="size-fit max-w-[280px] min-w-[140px] p-2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Info className="size-3" />
|
||||
<p className="text-xs font-medium">Error details:</p>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-xs text-pretty">{errorMsg}</p>
|
||||
|
||||
{status === "missing" && (
|
||||
<code className="text-foreground bg-muted group/script relative isolate block overflow-hidden rounded-md border">
|
||||
<HorizontalScrollArea className="relative isolate">
|
||||
<span className="p-2 font-mono text-xs text-nowrap">
|
||||
{TWEAKCN_EMBED_SCRIPT_TAG}
|
||||
</span>
|
||||
</HorizontalScrollArea>
|
||||
|
||||
<CopyButton
|
||||
variant="default"
|
||||
size="icon"
|
||||
textToCopy={TWEAKCN_EMBED_SCRIPT_TAG}
|
||||
className="absolute top-1 right-1 z-10 hidden group-hover/script:inline-flex [&>svg]:size-3"
|
||||
/>
|
||||
</code>
|
||||
)}
|
||||
|
||||
{status === "unsupported" && (
|
||||
<div className="space-y-1">
|
||||
<p className="border-primary! border-l-2 pl-2 text-xs font-medium text-pretty">
|
||||
Consult the shadcn/ui docs for more information:
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SocialLink
|
||||
showIcon
|
||||
href="https://ui.shadcn.com/docs/installation"
|
||||
className="text-primary text-xs"
|
||||
>
|
||||
Installation docs
|
||||
</SocialLink>
|
||||
<SocialLink
|
||||
showIcon
|
||||
href="https://ui.shadcn.com/docs/theming"
|
||||
className="text-primary text-xs"
|
||||
>
|
||||
Theming docs
|
||||
</SocialLink>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
ICONS[status]
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground/90 flex items-center gap-1 text-sm font-medium">
|
||||
{TEXTS[status]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(status === "missing" || status === "unsupported" || status === "error") && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs shadow-none"
|
||||
onClick={retryValidation}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS: Record<IframeStatus, React.ReactNode> = {
|
||||
checking: <Loader className="text-foreground size-4 animate-spin" />,
|
||||
connected: <CheckCircle className="text-foreground size-4" />,
|
||||
supported: <CheckCircle className="text-foreground size-4" />,
|
||||
unsupported: <AlertCircle className="text-foreground size-4" />,
|
||||
missing: <XCircle className="text-destructive size-4" />,
|
||||
unknown: null,
|
||||
checking: <Loader className="size-4 animate-spin" />,
|
||||
connected: <CheckCircle className="size-4" />,
|
||||
supported: <CheckCircle className="size-4" />,
|
||||
unsupported: <AlertCircle className="size-4" />,
|
||||
missing: <XCircle className="text-destructive size-4" />,
|
||||
error: <XCircle className="text-destructive size-4" />,
|
||||
};
|
||||
|
||||
const TEXTS: Record<IframeStatus, string> = {
|
||||
checking: "Checking connection...",
|
||||
unknown: "",
|
||||
checking: "Checking connection",
|
||||
connected: "Connected",
|
||||
supported: "Live preview enabled",
|
||||
unsupported: "Unsupported site",
|
||||
missing: "Script not found",
|
||||
unknown: "",
|
||||
error: "An error occurred",
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EmbedMessage, IframeStatus, MESSAGE } from "@/types/live-preview-embed"
|
||||
import { applyThemeToElement } from "@/utils/apply-theme";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const THEME_UPDATE_DEBOUNCE_MS = 150; // Throttle theme updates to ~6-7 fps
|
||||
const THEME_UPDATE_DEBOUNCE_MS = 50;
|
||||
|
||||
export interface UseIframeThemeInjectorProps {
|
||||
allowCrossOrigin?: boolean; // default false - must explicitly opt-in for external sites
|
||||
@@ -24,6 +24,7 @@ export const useIframeThemeInjector = ({
|
||||
|
||||
const { themeState } = useEditorStore();
|
||||
const [status, setStatus] = useState<IframeStatus>("unknown");
|
||||
const [themeInjectionError, setThemeInjectionError] = useState<string | null>(null);
|
||||
const validationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const themeUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
@@ -31,9 +32,9 @@ export const useIframeThemeInjector = ({
|
||||
if (allowCrossOrigin) return; // Only for same-origin mode
|
||||
|
||||
const iframe = ref.current;
|
||||
if (!iframe?.contentDocument?.documentElement) return;
|
||||
const iframeRoot = iframe?.contentDocument?.documentElement;
|
||||
if (!iframeRoot) return;
|
||||
|
||||
const iframeRoot = iframe.contentDocument.documentElement;
|
||||
applyThemeToElement(themeState, iframeRoot);
|
||||
}, [allowCrossOrigin, ref, themeState]);
|
||||
|
||||
@@ -45,6 +46,7 @@ export const useIframeThemeInjector = ({
|
||||
iframe.contentWindow.postMessage(msg, "*");
|
||||
} catch (error) {
|
||||
console.warn("Failed to send message to iframe:", error);
|
||||
setThemeInjectionError("Failed to establish the connection.");
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -58,7 +60,10 @@ export const useIframeThemeInjector = ({
|
||||
clearTimeout(validationTimeoutRef.current!);
|
||||
validationTimeoutRef.current = setTimeout(() => {
|
||||
setStatus("missing");
|
||||
}, 2000);
|
||||
setThemeInjectionError(
|
||||
"The tweakcn's live theme preview script could not be found. Please make sure the script is included in the website's source code and try again."
|
||||
);
|
||||
}, 3000);
|
||||
}, [postMessage]);
|
||||
|
||||
// Listen for iframe load
|
||||
@@ -77,6 +82,7 @@ export const useIframeThemeInjector = ({
|
||||
// Same-origin: just apply theme directly
|
||||
applySameOriginTheme();
|
||||
setStatus("supported"); // Always supported for same-origin
|
||||
setThemeInjectionError(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,13 +106,34 @@ export const useIframeThemeInjector = ({
|
||||
|
||||
switch (message.type) {
|
||||
case MESSAGE.EMBED_LOADED:
|
||||
console.log("Tweakcn Embed: Embed loaded");
|
||||
setThemeInjectionError(null);
|
||||
break;
|
||||
|
||||
case MESSAGE.PONG:
|
||||
setStatus("connected");
|
||||
setThemeInjectionError(null);
|
||||
postMessage({ type: MESSAGE.CHECK_SHADCN });
|
||||
break;
|
||||
|
||||
case MESSAGE.SHADCN_STATUS:
|
||||
setStatus(message.payload.supported ? "supported" : "unsupported");
|
||||
const { supported } = message.payload;
|
||||
if (supported) {
|
||||
setStatus("supported");
|
||||
setThemeInjectionError(null);
|
||||
} else {
|
||||
setStatus("unsupported");
|
||||
setThemeInjectionError(
|
||||
"Live theme preview requires shadcn/ui setup. Please make sure that the basic shadcn/ui variables are configured correctly."
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case MESSAGE.EMBED_ERROR:
|
||||
const { error } = message.payload;
|
||||
console.error("Tweakcn Embed: Error from iframe:", error);
|
||||
setStatus("error");
|
||||
setThemeInjectionError(error);
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -143,5 +170,6 @@ export const useIframeThemeInjector = ({
|
||||
ref,
|
||||
status: allowCrossOrigin ? status : "supported", // Same-origin is always "supported"
|
||||
retryValidation: allowCrossOrigin ? startCrossOriginValidation : applySameOriginTheme,
|
||||
themeInjectionError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,51 +1,27 @@
|
||||
// Tweakcn Live Preview Embed Script
|
||||
// Enables live theme updates for external websites using shadcn/ui
|
||||
|
||||
const TWEAKCN_MESSAGE = {
|
||||
PING: "TWEAKCN_PING",
|
||||
PONG: "TWEAKCN_PONG",
|
||||
CHECK_SHADCN: "TWEAKCN_CHECK_SHADCN",
|
||||
SHADCN_STATUS: "TWEAKCN_SHADCN_STATUS",
|
||||
THEME_UPDATE: "TWEAKCN_THEME_UPDATE",
|
||||
THEME_APPLIED: "TWEAKCN_THEME_APPLIED",
|
||||
EMBED_LOADED: "TWEAKCN_EMBED_LOADED",
|
||||
};
|
||||
|
||||
// Required shadcn/ui CSS variables for detection
|
||||
// ----- SHADCN SUPPORT -----
|
||||
const REQUIRED_SHADCN_VARS = [
|
||||
"--background",
|
||||
"--foreground",
|
||||
"--primary",
|
||||
"--card",
|
||||
"--radius",
|
||||
];
|
||||
"--background",
|
||||
"--foreground",
|
||||
"--card",
|
||||
"--card-foreground",
|
||||
"--popover",
|
||||
"--popover-foreground",
|
||||
"--primary",
|
||||
"--primary-foreground",
|
||||
"--secondary",
|
||||
"--secondary-foreground",
|
||||
"--muted",
|
||||
"--muted-foreground",
|
||||
"--accent",
|
||||
"--accent-foreground",
|
||||
"--destructive",
|
||||
"--border",
|
||||
"--input",
|
||||
"--ring",
|
||||
]
|
||||
|
||||
const updateThemeClass = (root, mode) => {
|
||||
root.classList.toggle("dark", mode === "dark");
|
||||
};
|
||||
|
||||
const applyStyleProperty = (root, key, value) => {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
};
|
||||
|
||||
const applyThemeStyles = (root, themeStyles, mode) => {
|
||||
// Apply light theme styles first (base styles)
|
||||
const lightStyles = themeStyles.light || {};
|
||||
for (const [key, value] of Object.entries(lightStyles)) {
|
||||
applyStyleProperty(root, key, value);
|
||||
}
|
||||
|
||||
// Apply dark mode overrides if in dark mode
|
||||
if (mode === "dark" && themeStyles.dark) {
|
||||
for (const [key, value] of Object.entries(themeStyles.dark)) {
|
||||
applyStyleProperty(root, key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkShadcnSupport = () => {
|
||||
function checkShadcnSupport() {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const hasSupport = REQUIRED_SHADCN_VARS.every(
|
||||
(v) => rootStyles.getPropertyValue(v).trim() !== ""
|
||||
@@ -53,21 +29,103 @@ const checkShadcnSupport = () => {
|
||||
return { supported: hasSupport };
|
||||
};
|
||||
|
||||
const applyTheme = (themeState) => {
|
||||
// ----- FONT LOADING UTILITIES -----
|
||||
const DEFAULT_FONT_WEIGHTS = ["400", "500", "600", "700"];
|
||||
|
||||
function extractFontFamily(fontFamilyValue) {
|
||||
if (!fontFamilyValue) return null;
|
||||
const firstFont = fontFamilyValue.split(",")[0].trim();
|
||||
const cleanFont = firstFont.replace(/['"]/g, "");
|
||||
const systemFonts = [
|
||||
"ui-sans-serif", "ui-serif", "ui-monospace", "system-ui",
|
||||
"sans-serif", "serif", "monospace", "cursive", "fantasy"
|
||||
];
|
||||
if (systemFonts.includes(cleanFont.toLowerCase())) return null;
|
||||
return cleanFont;
|
||||
}
|
||||
|
||||
function buildFontCssUrl(family, weights) {
|
||||
weights = weights || DEFAULT_FONT_WEIGHTS;
|
||||
const encodedFamily = encodeURIComponent(family);
|
||||
const weightsParam = weights.join(";");
|
||||
return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@${weightsParam}&display=swap`;
|
||||
}
|
||||
|
||||
function loadGoogleFont(family, weights) {
|
||||
weights = weights || DEFAULT_FONT_WEIGHTS;
|
||||
const href = buildFontCssUrl(family, weights);
|
||||
const existing = document.querySelector(`link[href="${href}"]`);
|
||||
if (existing) return;
|
||||
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
function loadThemeFonts(themeStyles) {
|
||||
try {
|
||||
const currentFonts = {
|
||||
sans: themeStyles["font-sans"],
|
||||
serif: themeStyles["font-serif"],
|
||||
mono: themeStyles["font-mono"],
|
||||
};
|
||||
|
||||
Object.entries(currentFonts).forEach(([_type, fontValue]) => {
|
||||
const fontFamily = extractFontFamily(fontValue);
|
||||
if (fontFamily) {
|
||||
loadGoogleFont(fontFamily, DEFAULT_FONT_WEIGHTS);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Tweakcn Embed: Failed to load fonts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ----- THEME STYLES APPLICATION -----
|
||||
function applyStyleProperty(root, key, value) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
root.style.setProperty(`--${key}`, value);
|
||||
}
|
||||
};
|
||||
|
||||
function updateThemeModeClass(root, mode) {
|
||||
root.classList.toggle("dark", mode === "dark");
|
||||
};
|
||||
|
||||
function applyThemeStyles(root, themeStyles, mode) {
|
||||
updateThemeModeClass(root, mode);
|
||||
|
||||
// Apply light theme styles first (base styles)
|
||||
const lightStyles = themeStyles.light || {};
|
||||
for (const [key, value] of Object.entries(lightStyles)) {
|
||||
applyStyleProperty(root, key, value);
|
||||
}
|
||||
|
||||
// Apply dark mode overrides
|
||||
const darkStyles = themeStyles.dark;
|
||||
if (mode === "dark" && darkStyles) {
|
||||
for (const [key, value] of Object.entries(darkStyles)) {
|
||||
applyStyleProperty(root, key, value);
|
||||
}
|
||||
}
|
||||
|
||||
loadThemeFonts(lightStyles);
|
||||
};
|
||||
|
||||
function applyTheme(themeState) {
|
||||
const root = document.documentElement;
|
||||
if (!root || !themeState || !themeState.styles) {
|
||||
console.warn("Tweakcn Embed: Missing root element or theme styles.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentMode: mode, styles: themeStyles } = themeState;
|
||||
|
||||
// Follow the same pattern as utils/apply-theme.ts
|
||||
updateThemeClass(root, mode);
|
||||
const { currentMode: mode, styles: themeStyles } = themeState;
|
||||
applyThemeStyles(root, themeStyles, mode);
|
||||
};
|
||||
|
||||
const sendMessageToParent = (message) => {
|
||||
// ----- MESSAGE SENDING -----
|
||||
function sendMessageToParent(message) {
|
||||
if (window.parent && window.parent !== window) {
|
||||
try {
|
||||
window.parent.postMessage(message, "*");
|
||||
@@ -77,17 +135,36 @@ const sendMessageToParent = (message) => {
|
||||
}
|
||||
};
|
||||
|
||||
const TWEAKCN_MESSAGE = {
|
||||
PING: "TWEAKCN_PING",
|
||||
PONG: "TWEAKCN_PONG",
|
||||
CHECK_SHADCN: "TWEAKCN_CHECK_SHADCN",
|
||||
SHADCN_STATUS: "TWEAKCN_SHADCN_STATUS",
|
||||
THEME_UPDATE: "TWEAKCN_THEME_UPDATE",
|
||||
THEME_APPLIED: "TWEAKCN_THEME_APPLIED",
|
||||
EMBED_LOADED: "TWEAKCN_EMBED_LOADED",
|
||||
EMBED_ERROR: "TWEAKCN_EMBED_ERROR",
|
||||
};
|
||||
|
||||
// ----- MAIN SCRIPT -----
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
// Prevent multiple initialization
|
||||
if (window.TweakcnEmbed) return;
|
||||
if (window.tweakcnEmbed) return;
|
||||
|
||||
const handleMessage = (event) => {
|
||||
// Verify the message is from the parent window
|
||||
if (event.source !== window.parent) return;
|
||||
// Verify the message has the expected structure
|
||||
if (!event.data || typeof event.data.type !== "string") return;
|
||||
|
||||
// TODO: Remove localhost once this is live
|
||||
const ALLOWED_ORIGINS = ['https://tweakcn.com', 'http://localhost:3000'];
|
||||
if (!ALLOWED_ORIGINS.includes(event.origin)){
|
||||
sendMessageToParent({ type: TWEAKCN_MESSAGE.EMBED_ERROR, payload: { error: "Origin not allowed. Preview failed to establish the connection with tweakcn." } });
|
||||
return;
|
||||
} ;
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
@@ -119,12 +196,12 @@ const sendMessageToParent = (message) => {
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
|
||||
window.TweakcnEmbed = {
|
||||
window.tweakcnEmbed = {
|
||||
initialized: true,
|
||||
version: "1.0.0",
|
||||
destroy: () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
delete window.TweakcnEmbed;
|
||||
delete window.tweakcnEmbed;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const MESSAGE = {
|
||||
THEME_UPDATE: "TWEAKCN_THEME_UPDATE",
|
||||
THEME_APPLIED: "TWEAKCN_THEME_APPLIED",
|
||||
EMBED_LOADED: "TWEAKCN_EMBED_LOADED",
|
||||
EMBED_ERROR: "TWEAKCN_EMBED_ERROR",
|
||||
} as const;
|
||||
|
||||
export type MessageType = (typeof MESSAGE)[keyof typeof MESSAGE];
|
||||
@@ -28,7 +29,8 @@ export type EmbedMessage =
|
||||
| { type: typeof MESSAGE.SHADCN_STATUS; payload: ShadcnStatusPayload }
|
||||
| { type: typeof MESSAGE.THEME_UPDATE; payload: ThemeUpdatePayload }
|
||||
| { type: typeof MESSAGE.THEME_APPLIED }
|
||||
| { type: typeof MESSAGE.EMBED_LOADED };
|
||||
| { type: typeof MESSAGE.EMBED_LOADED }
|
||||
| { type: typeof MESSAGE.EMBED_ERROR; payload: { error: string } };
|
||||
|
||||
export type IframeStatus =
|
||||
| "unknown"
|
||||
@@ -36,4 +38,5 @@ export type IframeStatus =
|
||||
| "connected"
|
||||
| "supported"
|
||||
| "unsupported"
|
||||
| "missing";
|
||||
| "missing"
|
||||
| "error";
|
||||
|
||||
Reference in New Issue
Block a user