mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-09-03 12:56:09 +08:00
feat: persistent background generation with toast notifications across all studios
- Web app (StandaloneShell): added global notification stack at bottom-right - Success toast with teal border, checkmark, studio name, and 'Open' button - Error toast with red border, error message text, auto-dismiss - Wired onGenerationComplete + onGenerationError into all 9 studio components - Studios: added onGenerationError callback to all studios - VibeMotionStudio + MarketingStudio: added both callbacks (were missing) - All others: added onGenerationError to catch blocks - Replaced alert() with onGenerationError in CinemaStudio and MarketingStudio - Desktop app (src/main.js): fixed router to use show/hide instead of innerHTML='' so studio components stay alive during tab switches, preserving in-progress generation across navigation
This commit is contained in:
+110
-10
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { ImageStudio, VideoStudio, ClippingStudio, VibeMotionStudio, LipSyncStudio, RecastStudio, CinemaStudio, AudioStudio, MarketingStudio, WorkflowStudio, AgentStudio, AppsStudio, AiInfluencerStudio, getUserBalance } from 'studio';
|
||||
@@ -80,6 +80,32 @@ export default function StandaloneShell() {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [droppedFiles, setDroppedFiles] = useState(null);
|
||||
|
||||
// ── Global Generation Notifications ────────────────────────────────────────
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const activeTabRef = useRef(null);
|
||||
useEffect(() => { activeTabRef.current = activeTab; }, [activeTab]);
|
||||
|
||||
const pushNotification = useCallback((notif) => {
|
||||
const id = `notif-${Date.now()}-${Math.random()}`;
|
||||
const entry = { ...notif, id };
|
||||
setNotifications(prev => [entry, ...prev].slice(0, 5));
|
||||
const ttl = notif.type === 'success' ? 8000 : 6000;
|
||||
setTimeout(() => setNotifications(prev => prev.filter(n => n.id !== id)), ttl);
|
||||
}, []);
|
||||
|
||||
const dismissNotification = useCallback((id) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
}, []);
|
||||
|
||||
const makeSuccessCallback = useCallback((tabId) => (data) => {
|
||||
const tab = TABS.find(t => t.id === tabId);
|
||||
pushNotification({ type: 'success', tabId, label: tab?.label || tabId, data });
|
||||
}, [pushNotification]);
|
||||
|
||||
const makeErrorCallback = useCallback((tabId) => (message) => {
|
||||
const tab = TABS.find(t => t.id === tabId);
|
||||
pushNotification({ type: 'error', tabId, label: tab?.label || tabId, message });
|
||||
}, [pushNotification]);
|
||||
|
||||
// Popstate event listener to sync tab state with URL on back/forward navigation
|
||||
useEffect(() => {
|
||||
@@ -364,31 +390,31 @@ export default function StandaloneShell() {
|
||||
{/* Studio Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className={activeTab === 'image' ? "h-full w-full" : "hidden"}>
|
||||
<ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<ImageStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('image')} onGenerationError={makeErrorCallback('image')} />
|
||||
</div>
|
||||
<div className={activeTab === 'video' ? "h-full w-full" : "hidden"}>
|
||||
<VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<VideoStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('video')} onGenerationError={makeErrorCallback('video')} />
|
||||
</div>
|
||||
<div className={activeTab === 'clipping' ? "h-full w-full" : "hidden"}>
|
||||
<ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<ClippingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('clipping')} onGenerationError={makeErrorCallback('clipping')} />
|
||||
</div>
|
||||
<div className={activeTab === 'vibe-motion' ? "h-full w-full" : "hidden"}>
|
||||
<VibeMotionStudio apiKey={apiKey} />
|
||||
<VibeMotionStudio apiKey={apiKey} onGenerationComplete={makeSuccessCallback('vibe-motion')} onGenerationError={makeErrorCallback('vibe-motion')} />
|
||||
</div>
|
||||
<div className={activeTab === 'lipsync' ? "h-full w-full" : "hidden"}>
|
||||
<LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<LipSyncStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('lipsync')} onGenerationError={makeErrorCallback('lipsync')} />
|
||||
</div>
|
||||
<div className={activeTab === 'body-swap' ? "h-full w-full" : "hidden"}>
|
||||
<RecastStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<RecastStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('body-swap')} onGenerationError={makeErrorCallback('body-swap')} />
|
||||
</div>
|
||||
<div className={activeTab === 'cinema' ? "h-full w-full" : "hidden"}>
|
||||
<CinemaStudio apiKey={apiKey} />
|
||||
<CinemaStudio apiKey={apiKey} onGenerationComplete={makeSuccessCallback('cinema')} onGenerationError={makeErrorCallback('cinema')} />
|
||||
</div>
|
||||
<div className={activeTab === 'audio' ? "h-full w-full" : "hidden"}>
|
||||
<AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<AudioStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('audio')} onGenerationError={makeErrorCallback('audio')} />
|
||||
</div>
|
||||
<div className={activeTab === 'marketing' ? "h-full w-full" : "hidden"}>
|
||||
<MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} />
|
||||
<MarketingStudio apiKey={apiKey} droppedFiles={droppedFiles} onFilesHandled={handleFilesHandled} onGenerationComplete={makeSuccessCallback('marketing')} onGenerationError={makeErrorCallback('marketing')} />
|
||||
</div>
|
||||
<div className={activeTab === 'workflows' ? "h-full w-full" : "hidden"}>
|
||||
<WorkflowStudio apiKey={apiKey} isHeaderVisible={isHeaderVisible} onToggleHeader={setIsHeaderVisible} />
|
||||
@@ -409,6 +435,80 @@ export default function StandaloneShell() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Global Generation Notification Stack ── */}
|
||||
{notifications.length > 0 && (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="fixed bottom-6 right-6 z-[200] flex flex-col gap-3 pointer-events-none"
|
||||
style={{ maxWidth: '360px' }}
|
||||
>
|
||||
{notifications.map((notif) => (
|
||||
<div
|
||||
key={notif.id}
|
||||
className="pointer-events-auto flex items-start gap-3 bg-[#0e0e10] border rounded-xl px-4 py-3 shadow-2xl shadow-black/60"
|
||||
style={{
|
||||
borderColor: notif.type === 'success' ? 'rgba(34,211,238,0.35)' : 'rgba(239,68,68,0.35)',
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftColor: notif.type === 'success' ? '#22d3ee' : '#ef4444',
|
||||
animation: 'slideInRight 280ms cubic-bezier(0.16,1,0.3,1) forwards',
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="flex-shrink-0 w-7 h-7 rounded-full flex items-center justify-center mt-0.5"
|
||||
style={{ background: notif.type === 'success' ? 'rgba(34,211,238,0.12)' : 'rgba(239,68,68,0.12)' }}
|
||||
>
|
||||
{notif.type === 'success' ? (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#22d3ee" strokeWidth="3"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#ef4444" strokeWidth="3"><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-bold text-white/90 leading-tight">
|
||||
{notif.label}
|
||||
<span className="font-normal text-white/50">
|
||||
{notif.type === 'success' ? ' · Generation complete' : ' · Generation failed'}
|
||||
</span>
|
||||
</p>
|
||||
{notif.type === 'error' && notif.message && (
|
||||
<p className="text-[11px] text-red-400/80 mt-0.5 leading-snug truncate" title={notif.message}>
|
||||
{notif.message}
|
||||
</p>
|
||||
)}
|
||||
{notif.type === 'success' && (
|
||||
<button
|
||||
onClick={() => { handleTabChange(notif.tabId); dismissNotification(notif.id); }}
|
||||
className="mt-1.5 text-[11px] font-bold text-[#22d3ee] hover:underline"
|
||||
>
|
||||
Open →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss */}
|
||||
<button
|
||||
onClick={() => dismissNotification(notif.id)}
|
||||
className="flex-shrink-0 text-white/30 hover:text-white/70 transition-colors text-lg leading-none mt-0.5"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyframe for toast slide-in */}
|
||||
<style>{`
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(110%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettings && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in-up">
|
||||
|
||||
@@ -475,6 +475,7 @@ function PremiumAudioPlayer({ url, title }) {
|
||||
export default function AudioStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
historyItems,
|
||||
droppedFiles,
|
||||
onFilesHandled,
|
||||
@@ -661,6 +662,7 @@ export default function AudioStudio({
|
||||
} catch (e) {
|
||||
console.error("[AudioStudio]", e);
|
||||
setGenerateError(e.message?.slice(0, 100) ?? "Audio generation failed");
|
||||
onGenerationError?.(e.message?.slice(0, 120) || "Audio generation failed");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -444,6 +444,7 @@ function CameraControlsOverlay({
|
||||
export default function CinemaStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
historyItems,
|
||||
}) {
|
||||
const PERSIST_KEY = "hg_cinema_studio_persistent";
|
||||
@@ -632,7 +633,7 @@ export default function CinemaStudio({
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Generation Failed: " + e.message);
|
||||
onGenerationError?.(e.message?.slice(0, 120) || "Cinema generation failed");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ const getAspectClass = (ar) => {
|
||||
export default function ClippingStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
droppedFiles,
|
||||
onFilesHandled,
|
||||
}) {
|
||||
@@ -397,6 +398,7 @@ export default function ClippingStudio({
|
||||
} catch (err) {
|
||||
console.error("[ClippingStudio] Error generating clips:", err);
|
||||
setGenerateError(err.message || "Failed to process AI clipping.");
|
||||
onGenerationError?.(err.message?.slice(0, 120) || "AI Clipping failed");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -811,6 +811,7 @@ function SimpleDropdown({ title, options, selected, onSelect, onClose }) {
|
||||
export default function ImageStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
historyItems,
|
||||
droppedFiles,
|
||||
onFilesHandled,
|
||||
@@ -1176,6 +1177,7 @@ export default function ImageStudio({
|
||||
console.error("[ImageStudio] Generation failed:", e);
|
||||
setGenerateError(e.message.slice(0, 80));
|
||||
setTimeout(() => setGenerateError(null), 4000);
|
||||
onGenerationError?.(e.message?.slice(0, 120) || "Image generation failed");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
@@ -318,6 +318,7 @@ const VideoIcon = ({
|
||||
export default function LipSyncStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
historyItems,
|
||||
droppedFiles,
|
||||
onFilesHandled,
|
||||
@@ -692,6 +693,7 @@ export default function LipSyncStudio({
|
||||
console.error("[LipSyncStudio]", e);
|
||||
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
|
||||
setTimeout(() => setGenerateError(null), 4000);
|
||||
onGenerationError?.(e.message?.slice(0, 120) || "Lip sync generation failed");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ function SimpleDropdown({ isOpen, title, options, selected, onSelect, onClose })
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled }) {
|
||||
export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled, onGenerationComplete, onGenerationError }) {
|
||||
const PERSIST_KEY = "hg_marketing_studio_persistent";
|
||||
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@@ -369,9 +369,10 @@ export default function MarketingStudio({ apiKey, droppedFiles, onFilesHandled }
|
||||
};
|
||||
setHistory(prev => [entry, ...prev]);
|
||||
setFullscreenUrl(result.url);
|
||||
onGenerationComplete?.({ url: result.url, type: "video" });
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Generation failed: " + err.message);
|
||||
onGenerationError?.(err.message?.slice(0, 120) || "Marketing generation failed");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -388,6 +388,7 @@ const ImageIcon = ({
|
||||
export default function RecastStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
historyItems,
|
||||
droppedFiles,
|
||||
onFilesHandled,
|
||||
@@ -745,6 +746,7 @@ export default function RecastStudio({
|
||||
console.error("[RecastStudio]", e);
|
||||
setGenerateError(e.message?.slice(0, 80) ?? "Unknown error");
|
||||
setTimeout(() => setGenerateError(null), 4000);
|
||||
onGenerationError?.(e.message?.slice(0, 120) || "Body swap generation failed");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ function DropdownItem({ label, selected, onClick }) {
|
||||
}
|
||||
|
||||
// ── Main Component ────────────────────────────────────────────────────────────
|
||||
export default function VibeMotionStudio({ apiKey }) {
|
||||
export default function VibeMotionStudio({ apiKey, onGenerationComplete, onGenerationError }) {
|
||||
const PERSIST_KEY = "hg_vibe_motion_studio_persistent";
|
||||
|
||||
// ── Params ────────────────────────────────────────────────────────────────
|
||||
@@ -160,6 +160,7 @@ export default function VibeMotionStudio({ apiKey }) {
|
||||
|
||||
const next = [entry, ...history].slice(0, 30);
|
||||
saveHistory(next);
|
||||
onGenerationComplete?.({ url: videoUrl, type: "video" });
|
||||
} catch (err) {
|
||||
// Detect the backend's "animation code not saved" limitation
|
||||
const raw = err.message || "";
|
||||
@@ -183,6 +184,7 @@ export default function VibeMotionStudio({ apiKey }) {
|
||||
setGenerateError(raw.slice(0, 120) || "Generation failed");
|
||||
}
|
||||
setTimeout(() => setGenerateError(null), 10000);
|
||||
onGenerationError?.(err.message?.slice(0, 120) || "Vibe Motion generation failed");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
stopTimer();
|
||||
|
||||
@@ -410,6 +410,7 @@ function ControlBtn({ icon, label, onClick, style }) {
|
||||
export default function VideoStudio({
|
||||
apiKey,
|
||||
onGenerationComplete,
|
||||
onGenerationError,
|
||||
historyItems,
|
||||
droppedFiles,
|
||||
onFilesHandled,
|
||||
@@ -1287,6 +1288,7 @@ export default function VideoStudio({
|
||||
console.error("[VideoStudio]", e);
|
||||
setGenerateError(e.message?.slice(0, 80) || "Generation failed");
|
||||
setTimeout(() => setGenerateError(null), 4000);
|
||||
onGenerationError?.(e.message?.slice(0, 120) || "Video generation failed");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
|
||||
+44
-28
@@ -5,37 +5,53 @@ import { ImageStudio } from './components/ImageStudio.js';
|
||||
const app = document.querySelector('#app');
|
||||
let contentArea;
|
||||
|
||||
// Router
|
||||
// Keep all mounted page nodes so async generation survives tab switches
|
||||
const mountedPages = {};
|
||||
|
||||
// Router — show/hide instead of destroy
|
||||
function navigate(page) {
|
||||
if (!contentArea) return;
|
||||
contentArea.innerHTML = '';
|
||||
|
||||
if (page === 'image') {
|
||||
contentArea.appendChild(ImageStudio());
|
||||
} else if (page === 'video') {
|
||||
import('./components/VideoStudio.js').then(({ VideoStudio }) => {
|
||||
contentArea.appendChild(VideoStudio());
|
||||
});
|
||||
} else if (page === 'cinema') {
|
||||
import('./components/CinemaStudio.js').then(({ CinemaStudio }) => {
|
||||
contentArea.appendChild(CinemaStudio());
|
||||
});
|
||||
} else if (page === 'lipsync') {
|
||||
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
|
||||
contentArea.appendChild(LipSyncStudio());
|
||||
});
|
||||
} else if (page === 'workflows') {
|
||||
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
|
||||
contentArea.appendChild(WorkflowStudio());
|
||||
});
|
||||
} else if (page === 'agents') {
|
||||
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
|
||||
contentArea.appendChild(AgentStudio());
|
||||
});
|
||||
} else if (page === 'mcp-cli') {
|
||||
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
|
||||
contentArea.appendChild(McpCliStudio());
|
||||
});
|
||||
// Hide all existing pages
|
||||
Object.values(mountedPages).forEach(node => { node.style.display = 'none'; });
|
||||
|
||||
if (mountedPages[page]) {
|
||||
// Already mounted — just show it again
|
||||
mountedPages[page].style.display = '';
|
||||
} else {
|
||||
// First visit — create a wrapper and mount the studio into it
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
|
||||
contentArea.appendChild(wrapper);
|
||||
mountedPages[page] = wrapper;
|
||||
|
||||
if (page === 'image') {
|
||||
wrapper.appendChild(ImageStudio());
|
||||
} else if (page === 'video') {
|
||||
import('./components/VideoStudio.js').then(({ VideoStudio }) => {
|
||||
wrapper.appendChild(VideoStudio());
|
||||
});
|
||||
} else if (page === 'cinema') {
|
||||
import('./components/CinemaStudio.js').then(({ CinemaStudio }) => {
|
||||
wrapper.appendChild(CinemaStudio());
|
||||
});
|
||||
} else if (page === 'lipsync') {
|
||||
import('./components/LipSyncStudio.js').then(({ LipSyncStudio }) => {
|
||||
wrapper.appendChild(LipSyncStudio());
|
||||
});
|
||||
} else if (page === 'workflows') {
|
||||
import('./components/WorkflowStudio.js').then(({ WorkflowStudio }) => {
|
||||
wrapper.appendChild(WorkflowStudio());
|
||||
});
|
||||
} else if (page === 'agents') {
|
||||
import('./components/AgentStudio.js').then(({ AgentStudio }) => {
|
||||
wrapper.appendChild(AgentStudio());
|
||||
});
|
||||
} else if (page === 'mcp-cli') {
|
||||
import('./components/McpCliStudio.js').then(({ McpCliStudio }) => {
|
||||
wrapper.appendChild(McpCliStudio());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user