From 16d49be6c47587381d07f154eea082937da680d8 Mon Sep 17 00:00:00 2001 From: Navnath Kadam Date: Thu, 17 Sep 2026 22:26:09 +0530 Subject: [PATCH] feat: add dashboard page, PWA install prompt, and AI dialog components --- app/dashboard/page.tsx | 36 +- src/components/DashboardAIDialog.tsx | 652 +++++++++++++++++++++++++++ src/components/PWAInstallPrompt.tsx | 63 +-- 3 files changed, 716 insertions(+), 35 deletions(-) create mode 100644 src/components/DashboardAIDialog.tsx diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 8f851e8..ce617bf 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -81,6 +81,7 @@ import { } from "@/templates/starterTemplates"; import UseTemplateDialog from "@/components/UseTemplateDialog"; import TemplateBrowserDialog from "@/components/TemplateBrowserDialog"; +import DashboardAIDialog from "@/components/DashboardAIDialog"; type ViewMode = "grid" | "list"; type FilterTab = "all" | "development" | "production" | "starred"; @@ -129,6 +130,8 @@ export default function DashboardPage() { const [composerPrompt, setComposerPrompt] = useState(""); const composerTextareaRef = useRef(null); const [isThinkEnabled, setIsThinkEnabled] = useState(true); + const [aiModalOpen, setAiModalOpen] = useState(false); + const [aiPromptForDialog, setAiPromptForDialog] = useState(""); // Subtle rotating prompts for main dashboard heading const ROTATING_PROMPTS = useMemo( @@ -348,12 +351,24 @@ export default function DashboardPage() { ); }; - // Submit Prompt to Workspace AI Copilot + // Submit Prompt to Workspace AI Copilot (Opens Target Selection Dialog) const handlePromptSubmit = (customPrompt?: string) => { const finalPrompt = (customPrompt || composerPrompt).trim(); - const query = finalPrompt ? `&prompt=${encodeURIComponent(finalPrompt)}` : ""; - const thinkParam = isThinkEnabled ? "&think=true" : ""; - router.push(`/workspace?ai=true${thinkParam}${query}`); + if (!finalPrompt) return; + setAiPromptForDialog(finalPrompt); + setAiModalOpen(true); + }; + + const handleConfirmLaunchAI = ( + workspaceId: string, + diagramId: string, + prompt: string, + think: boolean + ) => { + setComposerPrompt(""); + const query = prompt ? `&prompt=${encodeURIComponent(prompt)}` : ""; + const thinkParam = think ? "&think=true" : ""; + router.push(`/dashboard/workspace/${workspaceId}/${diagramId}?ai=true${thinkParam}${query}`); }; const handleCreateWorkspace = async (e: React.FormEvent) => { @@ -2072,6 +2087,19 @@ export default function DashboardPage() { handleOpenUseTemplate(tmpl); }} /> + + {/* ── Relay AI Target Workspace & Diagram Dialog ───────────────────── */} + setAiModalOpen(false)} + prompt={aiPromptForDialog} + thinkEnabled={isThinkEnabled} + workspaces={workspaces} + token={token} + defaultWorkspaceId={selectedWsId} + onCreateWorkspace={handleCreateWorkspaceForTemplate} + onConfirmLaunch={handleConfirmLaunchAI} + /> ); } diff --git a/src/components/DashboardAIDialog.tsx b/src/components/DashboardAIDialog.tsx new file mode 100644 index 0000000..e8502b3 --- /dev/null +++ b/src/components/DashboardAIDialog.tsx @@ -0,0 +1,652 @@ +"use client"; + +import React, { useState, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + FiFolder, + FiPlus, + FiCheck, + FiSearch, + FiAlertCircle, + FiArrowRight, + FiLayers, + FiCpu, + FiFileText, + FiZap, +} from "react-icons/fi"; +import { Sparkles, Loader2 } from "lucide-react"; +import { WorkspaceOption } from "@/components/UseTemplateDialog"; +import { + createDiagram, + getWorkspaceDiagrams, + type DiagramDTO, +} from "@/services/diagramApi"; + +export interface DashboardAIDialogProps { + isOpen: boolean; + onClose: () => void; + prompt: string; + thinkEnabled: boolean; + workspaces: WorkspaceOption[]; + token: string | null; + defaultWorkspaceId?: string | null; + onCreateWorkspace: ( + name: string, + env: "DEV" | "PROD" | "STAGING", + ) => Promise; + onConfirmLaunch: ( + workspaceId: string, + diagramId: string, + prompt: string, + think: boolean, + ) => void; +} + +export default function DashboardAIDialog({ + isOpen, + onClose, + prompt, + thinkEnabled, + workspaces, + token, + defaultWorkspaceId, + onCreateWorkspace, + onConfirmLaunch, +}: DashboardAIDialogProps) { + // State + const [selectedWsId, setSelectedWsId] = useState(""); + const [wsSearch, setWsSearch] = useState(""); + const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); + const [newWsName, setNewWsName] = useState(""); + const [newWsEnv, setNewWsEnv] = useState<"DEV" | "PROD" | "STAGING">("DEV"); + const [isSubmittingWs, setIsSubmittingWs] = useState(false); + + // Diagram selection within workspace + const [diagramMode, setDiagramMode] = useState<"new" | "existing">("existing"); + const [newDiagramTitle, setNewDiagramTitle] = useState(""); + const [selectedDiagramId, setSelectedDiagramId] = useState(""); + const [workspaceDiagrams, setWorkspaceDiagrams] = useState([]); + const [isLoadingDiagrams, setIsLoadingDiagrams] = useState(false); + const [isLaunching, setIsLaunching] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + + const isAtWorkspaceLimit = workspaces.length >= 5; + + // Derive smart initial title from prompt + const suggestedTitle = useMemo(() => { + if (!prompt || !prompt.trim()) return "AI Architecture"; + const clean = prompt.trim(); + // Check known patterns + if (/cache[- ]aside/i.test(clean)) return "Cache-Aside Architecture"; + if (/load[- ]balancer/i.test(clean)) return "Load Balanced Topology"; + if (/api[- ]gateway/i.test(clean)) return "Gateway Microservices Flow"; + if (/message[- ]queue|rabbitmq/i.test(clean)) return "Event Queue Pipeline"; + if (/pub[- ]?sub/i.test(clean)) return "PubSub Event Broker Flow"; + // Extract first 4-5 words or 35 chars + const words = clean.split(/\s+/).slice(0, 5).join(" "); + return words.length > 35 ? `${words.slice(0, 32)}…` : words; + }, [prompt]); + + // Reset dialog state on open + useEffect(() => { + if (isOpen) { + setErrorMessage(null); + setIsCreatingWorkspace(false); + setNewWsName(""); + setNewWsEnv("DEV"); + setIsLaunching(false); + setNewDiagramTitle(suggestedTitle); + + // Select default workspace or first available + if ( + defaultWorkspaceId && + workspaces.some((w) => w.id === defaultWorkspaceId) + ) { + setSelectedWsId(defaultWorkspaceId); + } else if (workspaces.length > 0) { + setSelectedWsId(workspaces[0].id); + } else { + setSelectedWsId(""); + setIsCreatingWorkspace(true); + } + } + }, [isOpen, defaultWorkspaceId, workspaces, suggestedTitle]); + + // Fetch diagrams whenever selected workspace changes + useEffect(() => { + if (!isOpen || !selectedWsId || !token) { + setWorkspaceDiagrams([]); + return; + } + + setIsLoadingDiagrams(true); + getWorkspaceDiagrams(selectedWsId, token) + .then((diagrams) => { + setWorkspaceDiagrams(diagrams || []); + if (!diagrams || diagrams.length === 0) { + setDiagramMode("new"); + setSelectedDiagramId(""); + } else { + // If there are existing diagrams, keep mode user selected or default to "new" + if ( + !selectedDiagramId || + !diagrams.some((d) => d.id === selectedDiagramId) + ) { + setSelectedDiagramId(diagrams[0].id); + } + } + }) + .catch((err) => { + console.warn("Could not fetch workspace diagrams:", err); + setWorkspaceDiagrams([]); + setDiagramMode("new"); + }) + .finally(() => { + setIsLoadingDiagrams(false); + }); + }, [isOpen, selectedWsId, token]); + + // Filter workspaces by search + const filteredWorkspaces = useMemo(() => { + const q = wsSearch.toLowerCase().trim(); + if (!q) return workspaces; + return workspaces.filter((w) => w.name.toLowerCase().includes(q)); + }, [workspaces, wsSearch]); + + // Handle inline workspace creation + const handleCreateWorkspaceSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newWsName.trim()) return; + if (isAtWorkspaceLimit) { + setErrorMessage( + "Free plan limit reached (max 5 workspaces). Please select an existing workspace.", + ); + return; + } + + setIsSubmittingWs(true); + setErrorMessage(null); + try { + const created = await onCreateWorkspace(newWsName.trim(), newWsEnv); + if (created) { + setSelectedWsId(created.id); + setIsCreatingWorkspace(false); + setNewWsName(""); + setDiagramMode("new"); + } + } catch (err: any) { + setErrorMessage(err.message || "Failed to create workspace"); + } finally { + setIsSubmittingWs(false); + } + }; + + // Launch AI in selected workspace + diagram + const handleLaunch = async () => { + if (!selectedWsId) { + setErrorMessage("Please select or create a workspace first."); + return; + } + + if (!token) { + setErrorMessage("Authentication token missing. Please sign in."); + return; + } + + setIsLaunching(true); + setErrorMessage(null); + + try { + if (diagramMode === "new") { + // Create new diagram in the selected workspace + const titleToUse = + newDiagramTitle.trim() || suggestedTitle || "AI Architecture"; + const created = await createDiagram( + selectedWsId, + { + title: titleToUse, + description: prompt.slice(0, 160), + nodes: [], + edges: [], + configs: {}, + }, + token, + ); + onConfirmLaunch(selectedWsId, created.id, prompt, thinkEnabled); + } else { + // Use existing diagram + if (!selectedDiagramId) { + setErrorMessage( + "Please select a diagram or choose 'Create New Diagram'.", + ); + setIsLaunching(false); + return; + } + onConfirmLaunch(selectedWsId, selectedDiagramId, prompt, thinkEnabled); + } + onClose(); + } catch (err: any) { + setErrorMessage(err.message || "Failed to initialize workspace canvas"); + setIsLaunching(false); + } + }; + + if (!isOpen) return null; + + return ( + !open && onClose()}> + + +
+
+ +
+
+ + Run Relay AI in Workspace + + + Select where to compile, simulate, and persist your AI + architecture. + +
+
+
+ + {/* Prompt Preview Snippet */} +
+
+ + Incoming Prompt + + {thinkEnabled && ( + + + Think Mode: ON + + )} +
+

+ “{prompt}” +

+
+ + {/* Error Alert */} + {errorMessage && ( +
+
+ + {errorMessage} +
+ +
+ )} + + {/* ── 1. Target Workspace Section ──────────────────────────────── */} +
+
+ + + {!isCreatingWorkspace && !isAtWorkspaceLimit && ( + + )} + + {isAtWorkspaceLimit && !isCreatingWorkspace && ( + + 5/5 Workspaces (Limit) + + )} +
+ + {isCreatingWorkspace ? ( + /* Inline Workspace Creation Form */ +
+
+ + New Workspace Name + + setNewWsName(e.target.value)} + placeholder="e.g. Production Cluster, Microservices Lab" + className="w-full h-8 px-2.5 text-xs rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" + /> +
+ +
+ + Environment + +
+ {(["DEV", "STAGING", "PROD"] as const).map((env) => ( + + ))} +
+
+ +
+ {workspaces.length > 0 && ( + + )} + +
+
+ ) : ( + /* Workspaces List */ +
+ {workspaces.length > 3 && ( +
+ + setWsSearch(e.target.value)} + placeholder="Search workspaces…" + className="w-full h-7 pl-8 pr-2.5 text-xs rounded-md border border-border bg-muted/20 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+ )} + + {workspaces.length === 0 ? ( +
+

+ No workspaces found. Create your first workspace to persist + and simulate your architectures. +

+ +
+ ) : ( +
+ {filteredWorkspaces.map((ws) => { + const isSelected = selectedWsId === ws.id; + return ( +
setSelectedWsId(ws.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedWsId(ws.id); + } + }} + className={`flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer text-left min-h-[42px] ${ + isSelected + ? "bg-primary/10 border-primary/40 text-foreground font-semibold shadow-2xs" + : "bg-muted/10 border-border/70 hover:bg-muted/30 text-muted-foreground hover:text-foreground" + }`} + > +
+ +
+ + {ws.name} + + + {ws.diagrams_count || 0} diagram + {(ws.diagrams_count || 0) !== 1 ? "s" : ""} + +
+
+ +
+ + {ws.env || "DEV"} + + {isSelected && ( + + + + )} +
+
+ ); + })} +
+ )} +
+ )} +
+ + {/* ── 2. Target Diagram Mode (New vs Existing) ─────────────────── */} + {selectedWsId && !isCreatingWorkspace && ( +
+
+ + + {/* Toggle New vs Existing */} + {workspaceDiagrams.length > 0 && ( +
+ + +
+ )} +
+ + {isLoadingDiagrams ? ( +
+ + Loading workspace diagrams… +
+ ) : diagramMode === "new" || workspaceDiagrams.length === 0 ? ( + /* Create New Diagram Input */ +
+
+ + Diagram Name + + + Fresh Canvas + +
+ setNewDiagramTitle(e.target.value)} + placeholder="e.g. Cache-Aside Microservices" + className="w-full h-8 px-2.5 text-xs rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary" + /> +

+ Relay Will Open Into that workspace and diagram onto which you + select +

+
+ ) : ( + /* Existing Diagrams Selector */ +
+ {workspaceDiagrams.map((d) => { + const isSelected = selectedDiagramId === d.id; + return ( +
setSelectedDiagramId(d.id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedDiagramId(d.id); + } + }} + className={`flex items-center justify-between p-2.5 rounded-xl border transition-all cursor-pointer text-left min-h-[38px] ${ + isSelected + ? "bg-primary/10 border-primary/40 text-foreground font-semibold shadow-2xs" + : "bg-muted/10 border-border/70 hover:bg-muted/30 text-muted-foreground hover:text-foreground" + }`} + > +
+ + + {d.title} + +
+
+ + {d.nodes_count || 0} nodes + + {isSelected && ( + + + + )} +
+
+ ); + })} +
+ )} +
+ )} + + {/* Footer Actions */} +
+ + + +
+
+
+ ); +} diff --git a/src/components/PWAInstallPrompt.tsx b/src/components/PWAInstallPrompt.tsx index 41f8399..0246974 100644 --- a/src/components/PWAInstallPrompt.tsx +++ b/src/components/PWAInstallPrompt.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import Image from "next/image"; +import { usePathname } from "next/navigation"; import { FiDownload, FiX, FiShare } from "react-icons/fi"; interface BeforeInstallPromptEvent extends Event { @@ -13,10 +14,11 @@ interface BeforeInstallPromptEvent extends Event { prompt(): Promise; } +const PWA_SHOWN_KEY = "flowframe_pwa_first_time_shown"; const DISMISS_KEY = "flowframe_pwa_dismissed_at"; -const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; // 7 days export default function PWAInstallPrompt() { + const pathname = usePathname(); const [deferredPrompt, setDeferredPrompt] = useState(null); const [showPrompt, setShowPrompt] = useState(false); @@ -24,7 +26,20 @@ export default function PWAInstallPrompt() { const [isStandalone, setIsStandalone] = useState(false); useEffect(() => { - // 1. Check if already running in standalone PWA window + // 1. STRICT ROUTE CHECK: Only show on the home page ('/'). Never show in workspace, dashboard, docs, etc. + if (pathname !== "/") { + setShowPrompt(false); + return; + } + + // 2. CHECK IF ALREADY SHOWN OR DISMISSED: Only 1 time ever per user/device + const alreadyShown = localStorage.getItem(PWA_SHOWN_KEY); + const dismissedAt = localStorage.getItem(DISMISS_KEY); + if (alreadyShown || dismissedAt) { + return; // Never show again + } + + // 3. Check if already running in standalone PWA window const isStandaloneMode = window.matchMedia("(display-mode: standalone)").matches || (window.navigator as unknown as { standalone?: boolean }).standalone === true; @@ -34,31 +49,12 @@ export default function PWAInstallPrompt() { return; } - // 2. Register Service Worker - if ("serviceWorker" in navigator && process.env.NODE_ENV === "production") { - navigator.serviceWorker - .register("/sw.js") - .then((reg) => { - console.log("[PWA] Service Worker registered:", reg.scope); - }) - .catch((err) => { - console.warn("[PWA] Service Worker registration failed:", err); - }); - } else if ("serviceWorker" in navigator) { - // In dev mode register as well so install prompt fires locally + // 4. Register Service Worker + if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => {}); } - // 3. Check dismissal cooldown - const dismissedAt = localStorage.getItem(DISMISS_KEY); - if (dismissedAt) { - const timeSince = Date.now() - parseInt(dismissedAt, 10); - if (timeSince < DISMISS_COOLDOWN_MS) { - return; // Still in cooldown - } - } - - // 4. Detect iOS Safari + // 5. Detect iOS Safari const ua = window.navigator.userAgent.toLowerCase(); const isIosDevice = /iphone|ipad|ipod/.test(ua); const isSafari = @@ -67,23 +63,25 @@ export default function PWAInstallPrompt() { if (isIosDevice && isSafari) { setIsIOS(true); - // Show iOS helper with delay const timer = setTimeout(() => { + // Mark as shown so it never repeats + localStorage.setItem(PWA_SHOWN_KEY, "true"); setShowPrompt(true); - }, 3500); + }, 4000); return () => clearTimeout(timer); } - // 5. Chromium beforeinstallprompt handler + // 6. Chromium beforeinstallprompt handler const handleBeforeInstallPrompt = (e: Event) => { e.preventDefault(); const pwaEvent = e as BeforeInstallPromptEvent; setDeferredPrompt(pwaEvent); - // Brief delay before showing to ensure page settles + // Show only once after home page settles setTimeout(() => { + localStorage.setItem(PWA_SHOWN_KEY, "true"); setShowPrompt(true); - }, 2500); + }, 3500); }; window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt); @@ -92,6 +90,7 @@ export default function PWAInstallPrompt() { const handleAppInstalled = () => { setShowPrompt(false); setDeferredPrompt(null); + localStorage.setItem(PWA_SHOWN_KEY, "true"); localStorage.setItem(DISMISS_KEY, Date.now().toString()); }; @@ -101,9 +100,10 @@ export default function PWAInstallPrompt() { window.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt); window.removeEventListener("appinstalled", handleAppInstalled); }; - }, []); + }, [pathname]); const handleInstallClick = async () => { + localStorage.setItem(PWA_SHOWN_KEY, "true"); if (!deferredPrompt) return; try { @@ -124,10 +124,11 @@ export default function PWAInstallPrompt() { const handleDismiss = () => { setShowPrompt(false); + localStorage.setItem(PWA_SHOWN_KEY, "true"); localStorage.setItem(DISMISS_KEY, Date.now().toString()); }; - if (!showPrompt || isStandalone) { + if (pathname !== "/" || !showPrompt || isStandalone) { return null; }