feat: PWA support + mobile-responsive layout + QR code to open Atlas on phone
Made-with: Cursor
This commit is contained in:
@@ -7,6 +7,57 @@ import { AtlasChat } from "@/components/AtlasChat";
|
||||
import { OrchestratorChat } from "@/components/OrchestratorChat";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function MobileQRButton({ projectId, workspace }: { projectId: string; workspace: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
const url = typeof window !== "undefined"
|
||||
? `${window.location.origin}/${workspace}/project/${projectId}/overview`
|
||||
: "";
|
||||
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(url)}&bgcolor=f6f4f0&color=1a1a1a&margin=2`;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button
|
||||
onClick={() => setShow(s => !s)}
|
||||
title="Open on your phone"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
padding: "6px 12px", borderRadius: 7,
|
||||
background: "none", border: "1px solid #e0dcd4",
|
||||
fontSize: "0.72rem", fontFamily: "Outfit, sans-serif",
|
||||
color: "#8a8478", cursor: "pointer",
|
||||
transition: "border-color 0.12s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.borderColor = "#b5b0a6")}
|
||||
onMouseLeave={e => (e.currentTarget.style.borderColor = "#e0dcd4")}
|
||||
>
|
||||
📱 Open on phone
|
||||
</button>
|
||||
{show && (
|
||||
<div style={{
|
||||
position: "absolute", top: "calc(100% + 8px)", right: 0,
|
||||
background: "#fff", borderRadius: 12,
|
||||
border: "1px solid #e8e4dc",
|
||||
boxShadow: "0 8px 24px #1a1a1a12",
|
||||
padding: "16px", zIndex: 50,
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: 10,
|
||||
minWidth: 220,
|
||||
}}>
|
||||
<img src={qrSrc} alt="QR code" width={180} height={180} style={{ borderRadius: 8 }} />
|
||||
<p style={{ fontSize: "0.72rem", color: "#8a8478", textAlign: "center", margin: 0, fontFamily: "Outfit, sans-serif" }}>
|
||||
Scan to open Atlas on your phone
|
||||
</p>
|
||||
<p style={{ fontSize: "0.65rem", color: "#b5b0a6", textAlign: "center", margin: 0, fontFamily: "IBM Plex Mono, monospace", wordBreak: "break-all" }}>
|
||||
{url}
|
||||
</p>
|
||||
<button onClick={() => setShow(false)} style={{ fontSize: "0.68rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer" }}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
productName: string;
|
||||
@@ -16,6 +67,7 @@ interface Project {
|
||||
export default function ProjectOverviewPage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
const workspace = params.workspace as string;
|
||||
const { status: authStatus } = useSession();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -50,6 +102,15 @@ export default function ProjectOverviewPage() {
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||
{/* Desktop-only: Open on phone button */}
|
||||
<style>{`@media (max-width: 768px) { .vibn-phone-btn { display: none !important; } }`}</style>
|
||||
<div className="vibn-phone-btn" style={{
|
||||
position: "absolute", top: 14, right: 248,
|
||||
zIndex: 20,
|
||||
}}>
|
||||
<MobileQRButton projectId={projectId} workspace={workspace} />
|
||||
</div>
|
||||
|
||||
{(!project.stage || project.stage === "discovery") ? (
|
||||
<AtlasChat
|
||||
projectId={projectId}
|
||||
|
||||
@@ -24,8 +24,18 @@ const ibmPlexMono = IBM_Plex_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "VIBN - AI-Powered Development Platform",
|
||||
description: "Track, manage, and deploy your AI-coded projects with ease",
|
||||
title: "VIBN — Build with Atlas",
|
||||
description: "Chat with Atlas to define your product, then let AI build it.",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "VIBN",
|
||||
},
|
||||
other: {
|
||||
"mobile-web-app-capable": "yes",
|
||||
"msapplication-TileColor": "#1a1a1a",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -35,6 +45,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1a1a1a" />
|
||||
<link rel="apple-touch-icon" href="/vibn-logo-circle.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
</head>
|
||||
<body
|
||||
className={`${outfit.variable} ${newsreader.variable} ${ibmPlexMono.variable} antialiased`}
|
||||
>
|
||||
@@ -42,6 +58,13 @@ export default function RootLayout({
|
||||
{children}
|
||||
<Toaster />
|
||||
</Providers>
|
||||
<script dangerouslySetInnerHTML={{ __html: `
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
`}} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -351,7 +351,7 @@ export function AtlasChat({ projectId }: AtlasChatProps) {
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<div style={{ padding: "14px 32px 22px", flexShrink: 0 }}>
|
||||
<div style={{ padding: "14px 32px max(22px, env(safe-area-inset-bottom))", flexShrink: 0 }}>
|
||||
<div style={{
|
||||
display: "flex", gap: 8, padding: "5px 5px 5px 16px",
|
||||
background: "#fff", border: "1px solid #e0dcd4", borderRadius: 10,
|
||||
|
||||
@@ -129,15 +129,30 @@ export function ProjectShell({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", height: "100vh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
<style>{`
|
||||
@media (max-width: 768px) {
|
||||
.vibn-left-sidebar { display: none !important; }
|
||||
.vibn-right-panel { display: none !important; }
|
||||
.vibn-tab-bar { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
.vibn-tab-bar a { padding: 10px 14px !important; font-size: 0.75rem !important; }
|
||||
.vibn-project-header { padding: 12px 16px !important; }
|
||||
.vibn-page-content { padding-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.vibn-tab-bar a { padding: 10px 10px !important; }
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ display: "flex", height: "100dvh", background: "#f6f4f0", overflow: "hidden" }}>
|
||||
{/* Left sidebar */}
|
||||
<div className="vibn-left-sidebar" style={{ display: "flex" }}>
|
||||
<VIBNSidebar workspace={workspace} />
|
||||
</div>
|
||||
|
||||
{/* Main column */}
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
|
||||
{/* Project header */}
|
||||
<div style={{
|
||||
<div className="vibn-project-header" style={{
|
||||
padding: "18px 32px",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
display: "flex",
|
||||
@@ -189,7 +204,7 @@ export function ProjectShell({
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{
|
||||
<div className="vibn-tab-bar" style={{
|
||||
padding: "0 32px",
|
||||
borderBottom: "1px solid #e8e4dc",
|
||||
display: "flex",
|
||||
@@ -219,13 +234,13 @@ export function ProjectShell({
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
<div className="vibn-page-content" style={{ flex: 1, overflow: "auto" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div style={{
|
||||
<div className="vibn-right-panel" style={{
|
||||
width: 230,
|
||||
borderLeft: "1px solid #e8e4dc",
|
||||
background: "#fff",
|
||||
|
||||
26
public/manifest.json
Normal file
26
public/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "VIBN — Build with Atlas",
|
||||
"short_name": "VIBN",
|
||||
"description": "Chat with Atlas to define your product requirements — anywhere, anytime.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f6f4f0",
|
||||
"theme_color": "#1a1a1a",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/vibn-logo-circle.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/vibn-logo-circle.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["productivity", "business"],
|
||||
"lang": "en"
|
||||
}
|
||||
47
public/sw.js
Normal file
47
public/sw.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// VIBN Service Worker — enables PWA install + basic offline shell
|
||||
const CACHE = 'vibn-v1';
|
||||
|
||||
// Cache the app shell on install
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(
|
||||
caches.open(CACHE).then(cache =>
|
||||
cache.addAll(['/', '/manifest.json'])
|
||||
)
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', () => self.clients.claim());
|
||||
|
||||
// Network-first for API calls, cache-first for static assets
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const { request } = e;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Never cache API calls
|
||||
if (url.pathname.startsWith('/api/')) return;
|
||||
|
||||
// Cache-first for static assets
|
||||
if (
|
||||
request.destination === 'image' ||
|
||||
request.destination === 'font' ||
|
||||
url.pathname.startsWith('/_next/static/')
|
||||
) {
|
||||
e.respondWith(
|
||||
caches.match(request).then(cached => {
|
||||
if (cached) return cached;
|
||||
return fetch(request).then(res => {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(request, clone));
|
||||
return res;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for everything else (HTML pages)
|
||||
e.respondWith(
|
||||
fetch(request).catch(() => caches.match(request))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user