Skip to content
Draft
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
312 changes: 312 additions & 0 deletions dev_server.log

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"clsx": "^2.1.1",
"modern-screenshot": "^4.6.8",
"motion": "^12.23.24",
"next": "16.0.7",
"next": "^16.0.7",
"next-mdx-remote": "^6.0.0",
"next-sanity": "^11.4.2",
"next-themes": "^0.4.6",
Expand Down
117 changes: 63 additions & 54 deletions src/app/api/snapshot/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import puppeteer from "puppeteer-core";
import { puppeteerManager } from "@/utils/puppeteer-manager";

export const maxDuration = 60;
Expand Down Expand Up @@ -31,67 +30,77 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

let browser: any = null;
let page: any = null;

try {
const { html, width, height, devicePixelRatio = 2 } = await req.json();
const body = await req.json();
const tasks = Array.isArray(body.tasks) ? body.tasks : [body];

if (!html) {
return NextResponse.json({ error: "HTML content is required" }, { status: 400 });
if (tasks.length === 0) {
return NextResponse.json({ error: "No tasks provided" }, { status: 400 });
}

// Connect to the persistent browser instance
const wsEndpoint = await puppeteerManager.getWsEndpoint();
browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint });

page = await browser.newPage();

// Set viewport correctly for this specific snapshot
const safeWidth = Math.min(Math.max(width || 1280, 100), 3840);
const safeHeight = Math.min(Math.max(height || 720, 100), 2160);
const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3);

await page.setViewport({
width: safeWidth,
height: safeHeight,
deviceScaleFactor: safeScale,
});

// Performance: Disable JS
await page.setJavaScriptEnabled(false);

// Wait for full load
await page.setContent(html, { waitUntil: "load" });

// Tiny delay for layout/font rendering
await new Promise(r => setTimeout(r, 100));

await page.evaluate(() => {
const htmlEl = document.documentElement;
const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0');
const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0');
window.scrollTo(x, y);
});

const buffer = await page.screenshot({
type: "png",
fullPage: false,
});
// Use the single, persistent browser instance
const browser = await puppeteerManager.getBrowser();

const snapshots = await Promise.all(tasks.map(async (task: any) => {
let page: any = null;
try {
const { html, width, height, devicePixelRatio = 2 } = task;

if (!html) {
throw new Error("HTML content is required");
}

page = await browser.newPage();

// Set viewport correctly for this specific snapshot
const safeWidth = Math.min(Math.max(width || 1280, 100), 3840);
const safeHeight = Math.min(Math.max(height || 720, 100), 2160);
const safeScale = Math.min(Math.max(devicePixelRatio || 2, 1), 3);

await page.setViewport({
width: safeWidth,
height: safeHeight,
deviceScaleFactor: safeScale,
});

// Performance: Disable JS
await page.setJavaScriptEnabled(false);

// Wait for full load
await page.setContent(html, { waitUntil: "load" });

// Tiny delay for layout/font rendering
await new Promise(r => setTimeout(r, 100));

await page.evaluate(() => {
const htmlEl = document.documentElement;
const x = parseInt(htmlEl.getAttribute('data-scroll-x') || '0');
const y = parseInt(htmlEl.getAttribute('data-scroll-y') || '0');
window.scrollTo(x, y);
});

const buffer = await page.screenshot({
type: "png",
fullPage: false,
});

return `data:image/png;base64,${Buffer.from(buffer).toString("base64")}`;
} finally {
if (page) {
await page.close().catch(() => {});
}
}
}));

// If it was a single task (non-array input), return single snapshot for backward compatibility
if (!Array.isArray(body.tasks)) {
return NextResponse.json({ snapshot: snapshots[0] });
}

const base64 = `data:image/png;base64,${buffer.toString("base64")}`;
return NextResponse.json({ snapshot: base64 });
return NextResponse.json({ snapshots });

} catch (error: any) {
console.error("Snapshot API error:", error);
return NextResponse.json({ error: error.message }, { status: 500 });
} finally {
if (page) {
await page.close().catch(() => {});
}
if (browser) {
// We disconnect from the persistent browser, NOT close it
await browser.disconnect();
}
}
}
82 changes: 70 additions & 12 deletions src/components/features/ThemeSwitcher/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,13 @@ type ThemeSwitcherProps = {
};

const LoadingIcon = () => (
<span className=" relative inline-block w-8 h-8 inset-[2px_-2px] before:content-[''] before:absolute before:inset-0 before:rounded-full before:bg-black dark:before:bg-white after:content-[''] after:absolute after:inset-0 after:rounded-full after:bg-black dark:after:bg-white before:animate-[animloader_1s_linear_infinite] after:animate-[animloader_1s_linear_infinite] after:animate-delay-[0.25s]"></span>
<span className="relative inline-block w-8 h-8 inset-[2px_-2px] before:content-[''] before:absolute before:inset-0 before:rounded-full before:bg-black dark:before:bg-white after:content-[''] after:absolute after:inset-0 after:rounded-full after:bg-black dark:after:bg-white before:animate-[animloader_1s_linear_infinite] after:animate-[animloader_1s_linear_infinite] after:animate-delay-[0.25s]"></span>
);

const SettingsIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2a2 2 0 0 1-2 2a2 2 0 0 0-2 2a2 2 0 0 1-2 2a2 2 0 0 0-2 2v.44a2 2 0 0 0 2 2a2 2 0 0 1 2 2a2 2 0 0 0 2 2a2 2 0 0 1 2 2a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2a2 2 0 0 1 2-2a2 2 0 0 0 2-2a2 2 0 0 1 2-2a2 2 0 0 0 2-2v-.44a2 2 0 0 0-2-2a2 2 0 0 1-2-2a2 2 0 0 0-2-2a2 2 0 0 1-2-2a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>
</svg>
);

export function ThemeSwitcher({
Expand All @@ -25,20 +31,35 @@ export function ThemeSwitcher({
setWipeDirection,
}: ThemeSwitcherProps) {
const [mounted, setMounted] = useState(false);

const [showDebug, setShowDebug] = useState(false);
const [forceFallback, setForceFallback] = useState({
disablePuppeteer: false,
disableModernScreenshot: false,
});

const { resolvedTheme } = useTheme();
const { toggleTheme, snapshots, isCapturing, originalTheme, animationStyles } = useThemeWipe({
const { toggleTheme, snapshots, isCapturing, isWarmingUp, originalTheme, animationStyles } = useThemeWipe({
wipeProgress,
wipeDirection,
setWipeDirection,
});

useEffect(() => {
setMounted(true);
// Warm up the snapshot API on mount
fetch("/api/snapshot").catch(() => {});
// Initialize global from state
if (typeof window !== "undefined") {
(window as any).FORCE_FALLBACK = forceFallback;
}
}, []);

const updateFallback = (key: keyof typeof forceFallback, value: boolean) => {
const newState = { ...forceFallback, [key]: value };
setForceFallback(newState);
if (typeof window !== "undefined") {
(window as any).FORCE_FALLBACK = newState;
}
};

if (!mounted) {
return <LoadingIcon />;
}
Expand All @@ -47,12 +68,49 @@ export function ThemeSwitcher({

return (
<>
<ThemeToggleButtonIcon
onClick={toggleTheme}
progress={wipeProgress}
initialTheme={initialThemeForIcon}
isLoading={isCapturing}
/>
<div className="relative flex items-center gap-2">
<ThemeToggleButtonIcon
onClick={toggleTheme}
progress={wipeProgress}
initialTheme={initialThemeForIcon}
isLoading={isCapturing || isWarmingUp}
/>

{/* Debug Toggle Button */}
<button
onClick={() => setShowDebug(!showDebug)}
className="p-1 rounded-full hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title="Theme Toggle Debug Settings"
>
<SettingsIcon />
</button>
</div>

{showDebug && createPortal(
<div className="fixed bottom-24 right-4 z-[20000] p-4 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-xl text-xs space-y-3 min-w-[200px]">
<h4 className="font-bold border-bottom pb-1 mb-2">Debug Settings</h4>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={forceFallback.disablePuppeteer}
onChange={(e) => updateFallback('disablePuppeteer', e.target.checked)}
/>
Disable Puppeteer
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={forceFallback.disableModernScreenshot}
onChange={(e) => updateFallback('disableModernScreenshot', e.target.checked)}
/>
Disable Modern-Screenshot
</label>
<div className="pt-2 border-t border-zinc-100 dark:border-zinc-800 text-[10px] text-zinc-500">
Current: {isWarmingUp ? 'Waking up...' : 'Ready'}
</div>
</div>,
document.body
)}

{createPortal(
<WipeAnimationOverlay
Expand All @@ -64,4 +122,4 @@ export function ThemeSwitcher({
)}
</>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export function WipeAnimationOverlay({
className="fixed inset-0 z-10000"
data-html2canvas-ignore="true"
>
{/* Debug Method Text */}
<div className="absolute top-4 left-4 z-[10001] bg-black/50 text-white px-2 py-1 rounded text-xs font-mono">
Method: {snapshots.method || 'unknown'}
</div>

{/* Target Theme Snapshot (Bottom Layer - Revealed) */}
<div
className="absolute inset-0 bg-no-repeat bg-size-[100%_100%]"
Expand Down Expand Up @@ -62,4 +67,4 @@ export function WipeAnimationOverlay({
)}
</AnimatePresence>
);
}
}
Loading