Skip to content
Open
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
16 changes: 8 additions & 8 deletions components/dynamic-website-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,8 +231,8 @@ function DynamicWebsitePreviewContent({ name }: { name: string }) {

function Controls() {
const {
inputUrl,
setInputUrl,
inputValue,
setInputValue,
currentUrl,
isLoading: previewIsLoading,
loadUrl,
Expand All @@ -245,9 +245,9 @@ function Controls() {
const handleReset = () => {
if (currentUrl) {
reset();
setInputUrl("");
setInputValue("");
} else {
setInputUrl("");
setInputValue("");
}
};

Expand All @@ -261,10 +261,10 @@ function Controls() {
? "Enter same-origin URL for direct theme injection"
: "Enter website URL (e.g. http://localhost:3000/login)"
}
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (!inputUrl.trim()) return;
if (!inputValue.trim()) return;
if (e.key === "Enter") {
loadUrl();
}
Expand All @@ -283,7 +283,7 @@ function Controls() {
)}
/>

{(currentUrl || inputUrl) && (
{(currentUrl || inputValue) && (
<TooltipWrapper asChild label="Reset">
<Button
variant="ghost"
Expand Down
79 changes: 79 additions & 0 deletions components/open-in-tweakcn-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export function OpenInTweakcnButton({
url,
disabled,
...props
}: React.ComponentProps<typeof Button> & { url: string; disabled?: boolean }) {
const openInTweakcnUrl = `https://tweakcn.com/editor/theme?p=custom&url=${encodeURIComponent(url)}`;

return (
<Button
aria-label="Open in tweakcn"
title="Open in tweakcn"
asChild
disabled={disabled}
{...props}
>
<a
href={openInTweakcnUrl}
target="_blank"
rel="noreferrer"
className={cn("gap-1", disabled && "pointer-events-none opacity-50")}
>
Open in{" "}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className="size-5 text-current"
aria-hidden="true"
>
<rect width="256" height="256" fill="none" />

<line
x1="208"
y1="128"
x2="207.8"
y2="128.2"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="168.2"
y1="167.8"
x2="128"
y2="208"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>

<line
x1="192"
y1="40"
x2="115.8"
y2="116.2"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="76.2"
y1="155.8"
x2="40"
y2="192"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>

<circle cx="188" cy="148" r="24" fill="none" stroke="currentColor" stroke-width="24" />
<circle cx="96" cy="136" r="24" fill="none" stroke="currentColor" stroke-width="24" />
</svg>
<span className="sr-only">Open in tweakcn</span>
</a>
Comment on lines +19 to +76
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Disabled button still opens the link

When disabled is true we only add pointer-events-none, so the <a> keeps its href, stays focusable, and still opens via keyboard activation. Users relying on keyboard or assistive tech can’t trust the disabled state. Please strip the href (and adjust focus/ARIA) whenever the control is disabled. (fastbootstrap.com)

Apply this diff to neutralize the link when disabled:

-      <a
-        href={openInTweakcnUrl}
+      <a
+        href={disabled ? undefined : openInTweakcnUrl}
         target="_blank"
         rel="noreferrer"
-        className={cn("gap-1", disabled && "pointer-events-none opacity-50")}
+        aria-disabled={disabled ? "true" : undefined}
+        tabIndex={disabled ? -1 : undefined}
+        className={cn("gap-1", disabled && "pointer-events-none opacity-50")}
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<a
href={openInTweakcnUrl}
target="_blank"
rel="noreferrer"
className={cn("gap-1", disabled && "pointer-events-none opacity-50")}
>
Open in{" "}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className="size-5 text-current"
aria-hidden="true"
>
<rect width="256" height="256" fill="none" />
<line
x1="208"
y1="128"
x2="207.8"
y2="128.2"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="168.2"
y1="167.8"
x2="128"
y2="208"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="192"
y1="40"
x2="115.8"
y2="116.2"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="76.2"
y1="155.8"
x2="40"
y2="192"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<circle cx="188" cy="148" r="24" fill="none" stroke="currentColor" stroke-width="24" />
<circle cx="96" cy="136" r="24" fill="none" stroke="currentColor" stroke-width="24" />
</svg>
<span className="sr-only">Open in tweakcn</span>
</a>
<a
href={disabled ? undefined : openInTweakcnUrl}
target="_blank"
rel="noreferrer"
aria-disabled={disabled ? "true" : undefined}
tabIndex={disabled ? -1 : undefined}
className={cn("gap-1", disabled && "pointer-events-none opacity-50")}
>
Open in{" "}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256"
className="size-5 text-current"
aria-hidden="true"
>
<rect width="256" height="256" fill="none" />
<line
x1="208"
y1="128"
x2="207.8"
y2="128.2"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="168.2"
y1="167.8"
x2="128"
y2="208"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="192"
y1="40"
x2="115.8"
y2="116.2"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<line
x1="76.2"
y1="155.8"
x2="40"
y2="192"
stroke="currentColor"
stroke-linecap="round"
stroke-width="24"
/>
<circle cx="188" cy="148" r="24" fill="none" stroke="currentColor" stroke-width="24" />
<circle cx="96" cy="136" r="24" fill="none" stroke="currentColor" stroke-width="24" />
</svg>
<span className="sr-only">Open in tweakcn</span>
</a>
🤖 Prompt for AI Agents
In components/open-in-tweakcn-button.tsx around lines 19 to 76, the anchor still
has an href when disabled so it remains focusable and can be activated by
keyboard; remove/omit the href when disabled (e.g., href={disabled ? undefined :
openInTweakcnUrl}), add aria-disabled="true" and tabIndex={disabled ? -1 : 0} so
assistive tech and keyboard users cannot activate it, and keep the visual
disabled styles (pointer-events-none/opacity-50) as-is.

</Button>
);
}
116 changes: 80 additions & 36 deletions hooks/use-website-preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useReducer, useRef } from "react";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { useQueryState } from "nuqs";
import { useWebsitePreviewStore } from "@/store/website-preview-store";

const LOADING_TIMEOUT_MS = 5000;
Expand Down Expand Up @@ -41,17 +42,74 @@ export interface UseWebsitePreviewProps {
allowCrossOrigin?: boolean;
}

/**
* Simplified version with clear source of truth priority:
* 1. URL param exists → Always use it, override persisted state and input
* 2. URL param doesn't exist → Use persisted currentUrl
*
* - inputValue: Local state for input field (uncontrolled, ephemeral)
* - currentUrl: Persisted store state (only used when URL param doesn't exist)
* - URL param: Single source of truth when it exists
*/
export function useWebsitePreview({ allowCrossOrigin = false }: UseWebsitePreviewProps) {
const [state, dispatch] = useReducer(reducer, initialState);
const iframeRef = useRef<HTMLIFrameElement>(null);
const loadingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const inputUrl = useWebsitePreviewStore((state) => state.inputUrl);
// Input field value (uncontrolled, just for typing)
const [inputValue, setInputValue] = useState("");

const [activeTab] = useQueryState("p");
const [urlParam, setUrlParam] = useQueryState("url");
const isCustomTab = activeTab === "custom";

const currentUrl = useWebsitePreviewStore((state) => state.currentUrl);
const setInputUrlStore = useWebsitePreviewStore((state) => state.setInputUrl);
const setCurrentUrlStore = useWebsitePreviewStore((state) => state.setCurrentUrl);
const resetStore = useWebsitePreviewStore((state) => state.reset);

// Helper function to load URL into iframe
const loadUrlIntoIframe = useCallback((url: string, cacheBuster: string = "_t") => {
if (!iframeRef.current) return;
try {
const urlObj = new URL(url);
urlObj.searchParams.set(cacheBuster, Date.now().toString());
iframeRef.current.src = urlObj.toString();
} catch {
// Fallback for invalid URLs
iframeRef.current.src = url + `?${cacheBuster}=${Date.now()}`;
}
}, []);

// Sync effect: keep URL param and store in sync based on tab
useEffect(() => {
// Leaving custom tab: remove url param if present
if (!isCustomTab) {
if (urlParam) setUrlParam(null).catch(() => {});
return;
}

// On custom tab:
if (urlParam) {
// URL is source of truth: reflect it in store and input
if (urlParam !== currentUrl) setCurrentUrlStore(urlParam);
if (urlParam !== inputValue) setInputValue(urlParam);
return;
}

// No url param: restore from persisted store if available
if (!urlParam && currentUrl) {
setUrlParam(currentUrl).catch(() => {});
}
}, [isCustomTab, urlParam, currentUrl, setUrlParam, setCurrentUrlStore]);

// Loader effect: load iframe whenever currentUrl changes
useEffect(() => {
if (!currentUrl) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "CLEAR_ERROR" });
loadUrlIntoIframe(currentUrl);
}, [currentUrl, loadUrlIntoIframe]);

const clearLoadingTimeout = () => {
if (loadingTimeoutRef.current) {
clearTimeout(loadingTimeoutRef.current);
Expand Down Expand Up @@ -87,51 +145,35 @@ export function useWebsitePreview({ allowCrossOrigin = false }: UseWebsitePrevie
}
}, [state.isLoading, currentUrl]);

const setInputUrl = useCallback(
(url: string) => {
setInputUrlStore(url);
dispatch({ type: "CLEAR_ERROR" });
},
[setInputUrlStore]
);
const setInputValueHandler = useCallback((url: string) => {
setInputValue(url);
dispatch({ type: "CLEAR_ERROR" });
}, []);

const loadUrl = useCallback(() => {
if (!inputUrl.trim()) {
if (!inputValue.trim()) {
dispatch({ type: "SET_LOAD_ERROR", payload: "Please enter a valid URL" });
return;
}

let formattedUrl = inputUrl.trim();
let formattedUrl = inputValue.trim();
if (!formattedUrl.startsWith("http://") && !formattedUrl.startsWith("https://")) {
formattedUrl = "https://" + formattedUrl;
}

// Save to currentUrl (persisted)
setCurrentUrlStore(formattedUrl);
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "CLEAR_ERROR" });

if (iframeRef.current) {
try {
const url = new URL(formattedUrl);
url.searchParams.set("_t", Date.now().toString());
iframeRef.current.src = url.toString();
} catch {
iframeRef.current.src = formattedUrl + "?_t=" + Date.now();
}
// If on custom tab, also update URL param
if (isCustomTab) {
setUrlParam(formattedUrl).catch(() => {});
}
}, [inputUrl, setCurrentUrlStore]);
}, [inputValue, setCurrentUrlStore, isCustomTab, setUrlParam, loadUrlIntoIframe]);

const refreshIframe = useCallback(() => {
if (!currentUrl || !iframeRef.current) return;
if (!currentUrl) return;
dispatch({ type: "SET_LOADING", payload: true });
try {
const url = new URL(currentUrl);
url.searchParams.set("_refresh", Date.now().toString());
iframeRef.current.src = url.toString();
} catch {
iframeRef.current.src = currentUrl + "?_refresh=" + Date.now();
}
}, [currentUrl]);
loadUrlIntoIframe(currentUrl, "_refresh");
}, [currentUrl, loadUrlIntoIframe]);

const openInNewTab = useCallback(() => {
if (!currentUrl) return;
Expand All @@ -141,16 +183,18 @@ export function useWebsitePreview({ allowCrossOrigin = false }: UseWebsitePrevie
const reset = useCallback(() => {
clearLoadingTimeout();
resetStore();
setInputValue("");
if (isCustomTab) setUrlParam(null).catch(() => {});
dispatch({ type: "RESET" });
}, [resetStore]);
}, [resetStore, isCustomTab, setUrlParam]);

return {
inputUrl,
inputValue,
currentUrl,
setInputValue: setInputValueHandler,
isLoading: state.isLoading,
error: state.error,
iframeRef,
setInputUrl,
loadUrl,
refreshIframe,
openInNewTab,
Expand Down
6 changes: 1 addition & 5 deletions store/website-preview-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,17 @@ import { create } from "zustand";
import { persist } from "zustand/middleware";

interface WebsitePreviewStore {
inputUrl: string;
currentUrl: string;
setInputUrl: (url: string) => void;
setCurrentUrl: (url: string) => void;
reset: () => void;
}

export const useWebsitePreviewStore = create<WebsitePreviewStore>()(
persist(
(set) => ({
inputUrl: "",
currentUrl: "",
setInputUrl: (url: string) => set({ inputUrl: url }),
setCurrentUrl: (url: string) => set({ currentUrl: url }),
reset: () => set({ inputUrl: "", currentUrl: "" }),
reset: () => set({ currentUrl: "" }),
}),
{
name: "website-preview-storage",
Expand Down