# Use official Node.js LTS image
FROM node:18-alpine AS builder
# Create app directory
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund
# Copy source
COPY . .
# Build Next.js app
RUN npm run build
# Production image
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=7860
# Copy only necessary files
COPY --from=builder /app/package.json ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/next.config.js ./next.config.js
EXPOSE 7860
# Start Next.js
CMD ["npm", "start"]
=== package.json ===
{
"name": "nextjs-tailwind-aistudio-viewer",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 7860",
"build": "next build",
"start": "next start -p 7860",
"lint": "eslint ."
},
"dependencies": {
"next": "14.2.10",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"autoprefixer": "10.4.20",
"eslint": "8.57.1",
"eslint-config-next": "14.2.10",
"postcss": "8.4.49",
"tailwindcss": "3.4.14"
}
}
=== next.config.js ===
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
// Hugging Face Spaces often expects the app to listen on 7860 and basePath "/"
env: {
NEXT_PUBLIC_APP_TITLE: "AI Studio App Viewer"
},
images: {
// Allow external images if needed
remotePatterns: [
{ protocol: 'https', hostname: '**' }
]
}
};
module.exports = nextConfig;
=== postcss.config.js ===
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
=== tailwind.config.js ===
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,jsx}",
"./components/**/*.{js,jsx}"
],
theme: {
extend: {
colors: {
brand: {
50: "#f2f6ff",
100: "#dae6ff",
200: "#b3c9ff",
300: "#86a8ff",
400: "#5c86ff",
500: "#3f6fff",
600: "#2f56e6",
700: "#2643b3",
800: "#1f368c",
900: "#1b2d73"
}
}
}
},
plugins: []
};
=== components/Header.jsx ===
import Link from "next/link";
import { useState } from "react";
const Header = () => {
const [open, setOpen] = useState(false);
return (
AI
{process.env.NEXT_PUBLIC_APP_TITLE || "AI Studio App Viewer"}
{open && (
)}
);
};
export default Header;
=== components/Loader.jsx ===
const Loader = ({ label = "Loading..." }) => {
return (
);
};
export default Loader;
=== components/ErrorMessage.jsx ===
const ErrorMessage = ({ message }) => {
if (!message) return null;
return (
{message}
);
};
export default ErrorMessage;
=== components/URLInput.jsx ===
import { useState } from "react";
const URLInput = ({ defaultUrl, onSubmit }) => {
const [url, setUrl] = useState(defaultUrl || "");
const [valid, setValid] = useState(true);
const validate = (value) => {
try {
const u = new URL(value);
return u.protocol === "http:" || u.protocol === "https:";
} catch {
return false;
}
};
const handleSubmit = (e) => {
e.preventDefault();
const isValid = validate(url);
setValid(isValid);
if (isValid) onSubmit(url);
};
return (
);
};
export default URLInput;
=== pages/_app.js ===
import "@/styles/globals.css";
export default function App({ Component, pageProps }) {
return ;
}
=== pages/index.js ===
import { useEffect, useState } from "react";
import Header from "@/components/Header";
import URLInput from "@/components/URLInput";
import Loader from "@/components/Loader";
import ErrorMessage from "@/components/ErrorMessage";
const DEFAULT_URL = "https://aistudio.google.com/apps/drive/1-7mSoYm5fqiIMDUfTcSylunPzGO9R_gG?showPreview=true&showAssistant=true&fullscreenApplet=true";
export default function Home() {
const [targetUrl, setTargetUrl] = useState(DEFAULT_URL);
const [embedUrl, setEmbedUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Build an embeddable URL via the API to avoid CORS issues and validate input
useEffect(() => {
const run = async () => {
if (!targetUrl) return;
setLoading(true);
setError("");
try {
const res = await fetch(`/api/resolve?url=${encodeURIComponent(targetUrl)}`);
if (!res.ok) {
const text = await res.text();
throw new Error(text || "Failed to resolve URL");
}
const data = await res.json();
setEmbedUrl(data.embedUrl);
} catch (e) {
setError(e.message || "Unexpected error");
setEmbedUrl("");
} finally {
setLoading(false);
}
};
run();
}, [targetUrl]);
return (
Embed Google AI Studio App
Paste a Google AI Studio App URL to preview it below. We construct a safe embeddable URL and show it in an iframe.
{loading && }
{!loading && error && }
{embedUrl ? (
) : (
Enter a valid AI Studio app URL to preview.
)}
Note: Some AI Studio apps may restrict embedding via X-Frame-Options or Content-Security-Policy.
If embedding is blocked, open the link directly.
);
}
=== pages/api/resolve.js ===
export default function handler(req, res) {
try {
const { url } = req.query;
if (!url || typeof url !== "string") {
return res.status(400).json({ error: "Missing url parameter" });
}
let parsed;
try {
parsed = new URL(url);
} catch {
return res.status(400).json({ error: "Invalid URL" });
}
// Only allow google aistudio domain to avoid open redirect / embedding arbitrary sites
const allowedHosts = [
"aistudio.google.com",
"www.aistudio.google.com"
];
if (!allowedHosts.includes(parsed.hostname)) {
return res.status(400).json({ error: "Only aistudio.google.com URLs are allowed" });
}
// We pass through the URL as the iframe src directly. If needed, we could transform params.
// Preserve query parameters.
const embedUrl = parsed.toString();
return res.status(200).json({ embedUrl });
} catch (e) {
return res.status(500).json({ error: "Server error" });
}
}
=== styles/globals.css ===
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Focus styles for better accessibility */
:focus-visible {
outline: none;
}
/* Page base */
html, body, #__next {
height: 100%;
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
/* Utility compositions */
.container-page {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}