Spaces:
Build error
Build error
| # 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 ( | |
| <header className="sticky top-0 z-40 w-full border-b border-gray-200 bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60"> | |
| <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8"> | |
| <div className="flex items-center gap-3"> | |
| <div className="h-9 w-9 rounded-md bg-brand-600 text-white grid place-items-center font-semibold">AI</div> | |
| <Link href="/" className="text-lg font-semibold text-gray-900 hover:text-brand-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 rounded"> | |
| {process.env.NEXT_PUBLIC_APP_TITLE || "AI Studio App Viewer"} | |
| </Link> | |
| </div> | |
| <nav className="hidden md:flex items-center gap-6"> | |
| <Link href="/" className="text-sm text-gray-700 hover:text-brand-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 rounded">Home</Link> | |
| <Link href="/#about" className="text-sm text-gray-700 hover:text-brand-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 rounded">About</Link> | |
| <a | |
| href="https://aistudio.google.com/" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="text-sm text-gray-700 hover:text-brand-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 rounded" | |
| > | |
| Google AI Studio | |
| </a> | |
| <a | |
| href="https://huggingface.co/spaces/akhaliq/anycoder" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="inline-flex items-center rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2" | |
| > | |
| Built with anycoder | |
| </a> | |
| </nav> | |
| <button | |
| aria-label="Open menu" | |
| aria-expanded={open} | |
| onClick={() => setOpen(!open)} | |
| className="md:hidden inline-flex items-center justify-center rounded-md p-2 text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" | |
| > | |
| <svg className="h-6 w-6" viewBox="0 0 24 24" fill="none" aria-hidden="true"> | |
| <path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> | |
| </svg> | |
| </button> | |
| </div> | |
| {open && ( | |
| <div className="md:hidden border-t border-gray-200 bg-white"> | |
| <div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8 space-y-2"> | |
| <Link href="/" className="block rounded px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">Home</Link> | |
| <Link href="/#about" className="block rounded px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">About</Link> | |
| <a | |
| href="https://aistudio.google.com/" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="block rounded px-3 py-2 text-sm text-gray-700 hover:bg-gray-100" | |
| > | |
| Google AI Studio | |
| </a> | |
| <a | |
| href="https://huggingface.co/spaces/akhaliq/anycoder" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="block rounded px-3 py-2 text-sm text-brand-700 hover:bg-brand-50" | |
| > | |
| Built with anycoder | |
| </a> | |
| </div> | |
| </div> | |
| )} | |
| </header> | |
| ); | |
| }; | |
| export default Header; | |
| === components/Loader.jsx === | |
| const Loader = ({ label = "Loading..." }) => { | |
| return ( | |
| <div role="status" aria-live="polite" className="flex items-center gap-3 text-gray-700"> | |
| <svg className="h-5 w-5 animate-spin text-brand-600" viewBox="0 0 24 24"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4A4 4 0 008 12H4z"></path> | |
| </svg> | |
| <span className="text-sm">{label}</span> | |
| </div> | |
| ); | |
| }; | |
| export default Loader; | |
| === components/ErrorMessage.jsx === | |
| const ErrorMessage = ({ message }) => { | |
| if (!message) return null; | |
| return ( | |
| <div role="alert" className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"> | |
| {message} | |
| </div> | |
| ); | |
| }; | |
| 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 ( | |
| <form onSubmit={handleSubmit} className="w-full"> | |
| <label htmlFor="targetUrl" className="block text-sm font-medium text-gray-700"> | |
| Enter an AI Studio App URL | |
| </label> | |
| <div className="mt-1 flex gap-2"> | |
| <input | |
| id="targetUrl" | |
| name="targetUrl" | |
| type="url" | |
| placeholder="https://aistudio.google.com/apps/..." | |
| value={url} | |
| onChange={(e) => { | |
| setUrl(e.target.value); | |
| if (!valid) setValid(true); | |
| }} | |
| className="w-full rounded-md border border-gray-300 px-3 py-2 text-gray-900 placeholder-gray-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500" | |
| aria-invalid={!valid} | |
| aria-describedby={!valid ? "url-error" : undefined} | |
| required | |
| /> | |
| <button | |
| type="submit" | |
| className="inline-flex items-center rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" | |
| > | |
| Load | |
| </button> | |
| </div> | |
| {!valid && ( | |
| <p id="url-error" className="mt-2 text-sm text-red-600"> | |
| Please enter a valid http(s) URL. | |
| </p> | |
| )} | |
| </form> | |
| ); | |
| }; | |
| export default URLInput; | |
| === pages/_app.js === | |
| import "@/styles/globals.css"; | |
| export default function App({ Component, pageProps }) { | |
| return <Component {...pageProps} />; | |
| } | |
| === 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 ( | |
| <div className="min-h-screen bg-gray-50 text-gray-900"> | |
| <Header /> | |
| <main className="mx-auto max-w-7xl px-4 pb-16 pt-8 sm:px-6 lg:px-8"> | |
| <section className="mx-auto max-w-3xl"> | |
| <h1 className="text-2xl font-semibold tracking-tight text-gray-900">Embed Google AI Studio App</h1> | |
| <p className="mt-2 text-sm text-gray-600"> | |
| Paste a Google AI Studio App URL to preview it below. We construct a safe embeddable URL and show it in an iframe. | |
| </p> | |
| <div className="mt-6"> | |
| <URLInput defaultUrl={DEFAULT_URL} onSubmit={setTargetUrl} /> | |
| </div> | |
| <div className="mt-6"> | |
| {loading && <Loader label="Resolving and preparing preview..." />} | |
| {!loading && error && <ErrorMessage message={error} />} | |
| </div> | |
| </section> | |
| <section className="mt-8"> | |
| <div className="aspect-video w-full overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"> | |
| {embedUrl ? ( | |
| <iframe | |
| title="AI Studio App Preview" | |
| src={embedUrl} | |
| className="h-full w-full" | |
| allow="clipboard-read; clipboard-write; geolocation; microphone; camera; encrypted-media; fullscreen" | |
| sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-downloads" | |
| /> | |
| ) : ( | |
| <div className="grid h-full place-items-center"> | |
| <p className="text-sm text-gray-500">Enter a valid AI Studio app URL to preview.</p> | |
| </div> | |
| )} | |
| </div> | |
| <p id="about" className="mt-6 text-sm text-gray-600"> | |
| Note: Some AI Studio apps may restrict embedding via X-Frame-Options or Content-Security-Policy. | |
| If embedding is blocked, open the link directly. | |
| </p> | |
| </section> | |
| </main> | |
| <footer className="border-t border-gray-200 bg-white"> | |
| <div className="mx-auto max-w-7xl px-4 py-6 text-sm text-gray-600 sm:px-6 lg:px-8"> | |
| <div className="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center"> | |
| <p>© {new Date().getFullYear()} AI Studio App Viewer</p> | |
| <div className="flex items-center gap-4"> | |
| <a | |
| href="https://aistudio.google.com/" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="text-gray-700 hover:text-brand-600" | |
| > | |
| Google AI Studio | |
| </a> | |
| <a | |
| href="https://huggingface.co/spaces/akhaliq/anycoder" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="text-brand-700 hover:text-brand-900" | |
| > | |
| Built with anycoder | |
| </a> | |
| </div> | |
| </div> | |
| </div> | |
| </footer> | |
| </div> | |
| ); | |
| } | |
| === 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 === | |
| /* Focus styles for better accessibility */ | |
| :focus-visible { | |
| outline: none; | |
| } | |
| /* Page base */ | |
| html, body, #__next { | |
| height: 100%; | |
| } | |
| body { | |
| } | |
| /* Utility compositions */ | |
| .container-page { | |
| } |