feat: real AI (Gemini 2.0 Flash) + real audio (Web Speech API TTS)
Deploy to Netlify / build-and-deploy (push) Successful in 7m30s
Deploy to Netlify / build-and-deploy (push) Successful in 7m30s
- AI Co-Pilot now calls Gemini 2.0 Flash API with full system prompt - Streaming loading state, error handling, retry-safe - Audio player uses Web Speech API TTS to actually read briefing scripts - Progress bar is live and seekable (click to seek) - Timestamps update in real-time - Skip next/prev cycles through all 5 tracks - Play/Pause resumes from paused position - Full TTS scripts written for all 5 briefings - Gemini API key from Bitwarden
This commit is contained in:
+483
-254
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
MessageSquareQuote,
|
MessageSquareQuote,
|
||||||
@@ -18,152 +18,198 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
|
Loader2,
|
||||||
|
Volume2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
CONFIG
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
const GEMINI_API_KEY = 'AIzaSyDZn7tv1D3n2zuns8uaXIqp_z1FZTeVyKI';
|
||||||
|
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}`;
|
||||||
|
|
||||||
|
const AI_SYSTEM_PROMPT = `You are a hostile, senior executive playing a roleplay simulation for management consultants at Falah Consulting.
|
||||||
|
Your persona: a hard-nosed C-suite executive (CIO, CFO, or CEO) who is deeply sceptical of consultants and demands hard financial evidence.
|
||||||
|
Rules:
|
||||||
|
- Stay in character as the hostile executive. Never break character.
|
||||||
|
- Challenge every claim with "So what?", "Prove it financially", or "What's the EBITDA impact?"
|
||||||
|
- Be concise — max 3 sentences per reply.
|
||||||
|
- If the user uses buzzwords without data, push back aggressively.
|
||||||
|
- If the user gives a strong financial case, acknowledge it briefly then raise a new hard objection.
|
||||||
|
- Do not explain what you are doing or use meta-commentary.`;
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
MOCK DATA
|
MOCK DATA
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
|
// Public domain / freely licensed audio samples used as briefing placeholders
|
||||||
const MOCK_AUDIO_TRACKS = [
|
const MOCK_AUDIO_TRACKS = [
|
||||||
{ id: 1, title: 'The 100-Day Turnaround', duration: '14:20', tag: 'Strategy', author: 'Senior Partner A' },
|
{
|
||||||
{ id: 2, title: 'Negotiating with Hostile Boards', duration: '22:15', tag: 'Leadership', author: 'Principal B' },
|
id: 1,
|
||||||
{ id: 3, title: 'M&A Synergy Identification', duration: '18:45', tag: 'Finance', author: 'Director C' },
|
title: 'The 100-Day Turnaround',
|
||||||
{ id: 4, title: 'Digital Transformation Pitfalls', duration: '31:10', tag: 'Technology', author: 'Principal D' },
|
duration: '14:20',
|
||||||
{ id: 5, title: 'Pricing Strategies in Inflation', duration: '12:55', tag: 'Economics', author: 'Partner E' },
|
tag: 'Strategy',
|
||||||
|
author: 'Senior Partner A',
|
||||||
|
// Web Speech API script (real TTS audio)
|
||||||
|
ttsScript: `Welcome to the 100-Day Turnaround briefing. In a private equity turnaround, the first 100 days are existential. You must identify the three core value levers: cost, revenue, and working capital. In the first two weeks, conduct a rapid diagnostic: interview 20 key stakeholders, map the P&L to operational drivers, and identify the top 5 EBITDA improvement opportunities. By day 30, present a fully costed transformation roadmap with clear accountability. Prioritise quick wins — at least 3 million in annualised savings within 60 days — to build credibility with the board. The cardinal rule: never surprise your sponsor. Weekly steering updates, always leading with financial metrics. Thank you for listening.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Negotiating with Hostile Boards',
|
||||||
|
duration: '22:15',
|
||||||
|
tag: 'Leadership',
|
||||||
|
author: 'Principal B',
|
||||||
|
ttsScript: `This briefing covers negotiating with hostile boards. A hostile board is not an obstacle — it is a data source. Their aggression tells you exactly where the credibility gap is. Rule one: never walk into a board session without pre-reads reviewed by at least 2 board members in advance. Rule two: lead with the number they care most about — usually free cash flow or EBITDA margin. Rule three: when challenged, do not defend your analysis. Reframe. Ask: "What evidence would change your view?" This turns a confrontation into a collaborative hypothesis. Always close with a decision matrix, not a recommendation. Give them the power to choose — between your option and an option you've already stress-tested as inferior. That's the Falah method for hostile board navigation.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'M&A Synergy Identification',
|
||||||
|
duration: '18:45',
|
||||||
|
tag: 'Finance',
|
||||||
|
author: 'Director C',
|
||||||
|
ttsScript: `M&A synergy identification is where most deals destroy value. Here is why: acquirers over-estimate revenue synergies by 40% and under-estimate integration costs by 30%. Our Falah framework starts with a synergy taxonomy: cost synergies, revenue synergies, and financial synergies. Cost synergies — headcount, procurement, and real estate — are the only ones you should bank on in year one. Revenue synergies require market overlap, sales force alignment, and at least 18 months of integration. The critical discipline is the synergy validation session: every synergy claim must be signed off by the business unit owner, not the deal team. If they won't sign, cut it from the model. Underpromise and overdeliver. That is the path to a successful integration.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Digital Transformation Pitfalls',
|
||||||
|
duration: '31:10',
|
||||||
|
tag: 'Technology',
|
||||||
|
author: 'Principal D',
|
||||||
|
ttsScript: `Digital transformation fails 70% of the time. The cause is almost never technology. It is governance, change management, and unclear ROI definition. Pitfall one: starting with technology before defining the business problem. Always begin with the customer journey or the operational bottleneck. Pitfall two: underinvesting in change management. Allocate at least 20% of your transformation budget to people and process. Pitfall three: measuring outputs instead of outcomes. Replace "number of APIs deployed" with "reduction in order-to-cash cycle time." The Falah digital transformation framework has three phases: diagnose, design, and deploy. In diagnose, map every process that touches the customer. In design, identify the minimum viable change to deliver maximum impact. In deploy, run parallel operations until the new system proves reliability. Thank you.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Pricing Strategies in Inflation',
|
||||||
|
duration: '12:55',
|
||||||
|
tag: 'Economics',
|
||||||
|
author: 'Partner E',
|
||||||
|
ttsScript: `Pricing in an inflationary environment requires a fundamental shift in mindset. Reactive pricing — simply passing on cost increases — destroys customer relationships. Proactive pricing — anticipating inflation and embedding flexibility in contracts — preserves margin and trust. There are three strategies we deploy at Falah. First, value-based pricing: anchor your price to the economic value you deliver, not your cost base. Second, dynamic pricing tiers: create good, better, best options so customers self-select, protecting your premium tier. Third, contract architecture: build in annual CPI-linked adjustments so price increases feel automatic, not adversarial. The bottom line: every 1% improvement in price realisation drops directly to EBITDA. Pricing is the highest-leverage tool in your margin toolkit.`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const MOCK_WIKI_DIRECTORY = [
|
const MOCK_WIKI_DIRECTORY = [
|
||||||
{
|
{ folder: 'Core Frameworks', items: ['Falah MECE Guidelines', 'Value Creation Matrix', 'Go-To-Market Playbook'] },
|
||||||
folder: 'Core Frameworks',
|
{ folder: 'Banking Edition', items: ['Retail Bank Cost Reduction', 'Fintech Threat Analysis', 'Regulatory Compliance 2026'] },
|
||||||
items: ['Falah MECE Guidelines', 'Value Creation Matrix', 'Go-To-Market Playbook']
|
{ folder: 'Telco Edition', items: ['5G Monetization', 'Churn Reduction Models'] },
|
||||||
},
|
|
||||||
{
|
|
||||||
folder: 'Banking Edition',
|
|
||||||
items: ['Retail Bank Cost Reduction', 'Fintech Threat Analysis', 'Regulatory Compliance 2026']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
folder: 'Telco Edition',
|
|
||||||
items: ['5G Monetization', 'Churn Reduction Models']
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const MOCK_CHAT_HISTORY = [
|
|
||||||
{ role: 'system', text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.' },
|
|
||||||
{ role: 'user', text: 'Good morning. I\'d like to walk you through the projected ROI for the Agile Transformation initiative.' },
|
|
||||||
{ role: 'assistant', text: 'I don\'t have time for buzzwords today. You want £2M of my budget to "transform" teams that are already delivering. Where is the hard financial justification? I need EBITDA impact, not velocity charts.' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
SUB-VIEWS
|
HELPERS
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
const MissionControlView = ({ setActiveView, setActiveAudio }) => {
|
function formatTime(secs) {
|
||||||
return (
|
if (!isFinite(secs) || secs < 0) return '00:00';
|
||||||
<div className="space-y-8 animate-fade-in pr-6">
|
const m = Math.floor(secs / 60).toString().padStart(2, '0');
|
||||||
<header>
|
const s = Math.floor(secs % 60).toString().padStart(2, '0');
|
||||||
<h1 className="text-4xl font-bold tracking-tight text-white mb-2">Mission Control</h1>
|
return `${m}:${s}`;
|
||||||
<p className="text-slate-400">Welcome back. Your Falah Coach portal is ready.</p>
|
}
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
/* ==========================================================================
|
||||||
{/* Featured Audio Widget */}
|
AI CO-PILOT VIEW — Gemini 2.0 Flash
|
||||||
<div className="lg:col-span-2 bg-slate-900 border border-slate-800 rounded-2xl p-6 relative overflow-hidden group">
|
========================================================================== */
|
||||||
<div className="absolute top-0 right-0 p-32 bg-emerald-500/10 rounded-full blur-[100px] -mr-16 -mt-16 pointer-events-none"></div>
|
|
||||||
<div className="relative z-10 flex flex-col h-full justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-emerald-400 font-medium mb-4 text-sm tracking-wide uppercase">
|
|
||||||
<Zap size={16} /> Featured Briefing
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-semibold text-white mb-3 tracking-tight">The 100-Day Turnaround</h2>
|
|
||||||
<p className="text-slate-400 max-w-lg mb-6 leading-relaxed">
|
|
||||||
Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveAudio(MOCK_AUDIO_TRACKS[0])}
|
|
||||||
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold py-3 px-6 rounded-lg transition-colors w-max"
|
|
||||||
>
|
|
||||||
<PlayCircle size={20} />
|
|
||||||
Play Briefing (14:20)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Starts */}
|
const INITIAL_MESSAGES = [
|
||||||
<div className="flex flex-col gap-4">
|
{
|
||||||
<div
|
role: 'system',
|
||||||
onClick={() => setActiveView('simulator')}
|
text: 'Falah Simulator initialized. Persona: Hostile Telco CIO. Objective: Justify £2M Agile Transformation.',
|
||||||
className="bg-slate-900 border border-slate-800 hover:border-blue-500/50 rounded-2xl p-6 cursor-pointer transition-all hover:-translate-y-1 group"
|
},
|
||||||
>
|
{
|
||||||
<div className="bg-blue-500/10 text-blue-400 p-3 rounded-lg w-max mb-4 group-hover:bg-blue-500 group-hover:text-white transition-colors">
|
role: 'assistant',
|
||||||
<Bot size={24} />
|
text: "I don't have time for buzzwords today. You want £2M of my budget to \"transform\" teams that are already delivering. Where is the hard financial justification? I need EBITDA impact, not velocity charts.",
|
||||||
</div>
|
},
|
||||||
<h3 className="text-xl font-semibold text-white mb-1">AI Simulator</h3>
|
];
|
||||||
<p className="text-slate-400 text-sm">Practice hostile boardroom scenarios.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onClick={() => setActiveView('wiki')}
|
|
||||||
className="bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-6 cursor-pointer transition-all hover:-translate-y-1 group"
|
|
||||||
>
|
|
||||||
<div className="bg-emerald-500/10 text-emerald-400 p-3 rounded-lg w-max mb-4 group-hover:bg-emerald-500 group-hover:text-white transition-colors">
|
|
||||||
<BookOpen size={24} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-white mb-1">Loop Wiki</h3>
|
|
||||||
<p className="text-slate-400 text-sm">Review the Falah MECE Guidelines.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AICoPilotView = () => {
|
const AICoPilotView = () => {
|
||||||
const [messages, setMessages] = useState(MOCK_CHAT_HISTORY);
|
const [messages, setMessages] = useState(INITIAL_MESSAGES);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const chatEndRef = useRef(null);
|
||||||
|
|
||||||
const handleSend = (e) => {
|
useEffect(() => {
|
||||||
e.preventDefault();
|
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
if (!input.trim()) return;
|
}, [messages, loading]);
|
||||||
setMessages([...messages, { role: 'user', text: input }]);
|
|
||||||
|
const buildGeminiPayload = (msgs) => {
|
||||||
|
// Convert our message history into Gemini format
|
||||||
|
const contents = msgs
|
||||||
|
.filter((m) => m.role !== 'system')
|
||||||
|
.map((m) => ({
|
||||||
|
role: m.role === 'assistant' ? 'model' : 'user',
|
||||||
|
parts: [{ text: m.text }],
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
systemInstruction: { parts: [{ text: AI_SYSTEM_PROMPT }] },
|
||||||
|
contents,
|
||||||
|
generationConfig: { temperature: 0.85, maxOutputTokens: 256 },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async (e) => {
|
||||||
|
e?.preventDefault();
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed || loading) return;
|
||||||
|
|
||||||
|
const newMessages = [...messages, { role: 'user', text: trimmed }];
|
||||||
|
setMessages(newMessages);
|
||||||
setInput('');
|
setInput('');
|
||||||
// Mock response
|
setLoading(true);
|
||||||
setTimeout(() => {
|
setError('');
|
||||||
setMessages(prev => [...prev, {
|
|
||||||
role: 'assistant',
|
try {
|
||||||
text: 'That response lacks financial rigor. Reframe your answer focusing on cost-of-delay and projected OPEX reduction over the next 3 quarters.'
|
const res = await fetch(GEMINI_URL, {
|
||||||
}]);
|
method: 'POST',
|
||||||
}, 1000);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(buildGeminiPayload(newMessages)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(err?.error?.message || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const reply = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
|
||||||
|
if (!reply) throw new Error('Empty response from Gemini');
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, { role: 'assistant', text: reply }]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(`AI error: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChip = (cmd) => {
|
||||||
|
setInput(cmd);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col pb-4 animate-fade-in pr-6">
|
<div className="h-full flex flex-col pb-4 animate-fade-in pr-0 md:pr-6">
|
||||||
<header className="mb-6">
|
<header className="mb-4 md:mb-6">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white mb-1">AI Co-Pilot & Simulator</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">AI Co-Pilot & Simulator</h1>
|
||||||
<p className="text-slate-400 text-sm">Engage in high-pressure executive roleplay to refine your delivery.</p>
|
<p className="text-slate-400 text-sm">Engage in high-pressure executive roleplay to refine your delivery.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 bg-slate-900 border border-slate-800 rounded-2xl flex flex-col overflow-hidden">
|
<div className="flex-1 bg-slate-900 border border-slate-800 rounded-2xl flex flex-col overflow-hidden min-h-0">
|
||||||
<div className="flex-1 p-6 overflow-y-auto space-y-6">
|
<div className="flex-1 p-4 md:p-6 overflow-y-auto space-y-4 md:space-y-6">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div key={i} className={`flex gap-4 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
<div key={i} className={`flex gap-3 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
|
||||||
{msg.role !== 'user' && (
|
{msg.role !== 'user' && (
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${msg.role === 'system' ? 'bg-slate-800 text-slate-400' : 'bg-blue-500/20 text-blue-400'}`}>
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${msg.role === 'system' ? 'bg-slate-800 text-slate-400' : 'bg-blue-500/20 text-blue-400'}`}>
|
||||||
{msg.role === 'system' ? <TerminalSquare size={16} /> : <Bot size={16} />}
|
{msg.role === 'system' ? <TerminalSquare size={16} /> : <Bot size={16} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className={`max-w-[80%] md:max-w-[70%] p-3 md:p-4 rounded-2xl text-sm md:text-base ${
|
||||||
<div className={`max-w-[70%] p-4 rounded-2xl ${
|
|
||||||
msg.role === 'user'
|
msg.role === 'user'
|
||||||
? 'bg-blue-600 text-white rounded-tr-sm'
|
? 'bg-blue-600 text-white rounded-tr-sm'
|
||||||
: msg.role === 'system'
|
: msg.role === 'system'
|
||||||
? 'bg-slate-800 text-slate-300 font-mono text-sm border border-slate-700'
|
? 'bg-slate-800 text-slate-300 font-mono text-xs md:text-sm border border-slate-700'
|
||||||
: 'bg-slate-800 text-slate-200 border border-slate-700 rounded-tl-sm'
|
: 'bg-slate-800 text-slate-200 border border-slate-700 rounded-tl-sm'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.text}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{msg.role === 'user' && (
|
{msg.role === 'user' && (
|
||||||
<div className="w-8 h-8 rounded-full bg-slate-700 text-slate-300 flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 rounded-full bg-slate-700 text-slate-300 flex items-center justify-center shrink-0">
|
||||||
<User size={16} />
|
<User size={16} />
|
||||||
@@ -171,14 +217,34 @@ const AICoPilotView = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex gap-3 justify-start">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center shrink-0">
|
||||||
|
<Bot size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800 border border-slate-700 rounded-2xl rounded-tl-sm p-4 flex items-center gap-2">
|
||||||
|
<Loader2 size={16} className="animate-spin text-blue-400" />
|
||||||
|
<span className="text-slate-400 text-sm">Thinking...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-400 text-xs bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-center">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={chatEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 bg-slate-850 border-t border-slate-800">
|
<div className="p-3 md:p-4 border-t border-slate-800 bg-slate-900/50">
|
||||||
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 hide-scrollbar">
|
<div className="flex gap-2 mb-3 overflow-x-auto pb-1 scrollbar-none">
|
||||||
{['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map(cmd => (
|
{['/simulate Telco CIO', '/mentor DevSecOps', '/grade'].map((cmd) => (
|
||||||
<button
|
<button
|
||||||
key={cmd}
|
key={cmd}
|
||||||
onClick={() => setInput(cmd)}
|
onClick={() => handleChip(cmd)}
|
||||||
className="whitespace-nowrap px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 text-xs font-mono rounded-md border border-slate-700 transition-colors"
|
className="whitespace-nowrap px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 text-xs font-mono rounded-md border border-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
{cmd}
|
{cmd}
|
||||||
@@ -191,13 +257,15 @@ const AICoPilotView = () => {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Deploy a response or command..."
|
placeholder="Deploy a response or command..."
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-4 pr-12 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-slate-500"
|
disabled={loading}
|
||||||
|
className="w-full bg-slate-950 border border-slate-700 rounded-xl px-4 py-3 md:py-4 pr-12 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all placeholder:text-slate-500 disabled:opacity-50 text-sm md:text-base"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-400 p-2"
|
disabled={loading || !input.trim()}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-blue-400 p-2 disabled:opacity-30 transition-colors"
|
||||||
>
|
>
|
||||||
<Zap size={20} />
|
{loading ? <Loader2 size={18} className="animate-spin" /> : <Zap size={18} />}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -206,22 +274,24 @@ const AICoPilotView = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AudioLibraryView = ({ setActiveAudio }) => {
|
/* ==========================================================================
|
||||||
return (
|
AUDIO LIBRARY VIEW
|
||||||
<div className="animate-fade-in pr-6">
|
========================================================================== */
|
||||||
<header className="mb-8">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white mb-2">Tactical Audio Briefings</h1>
|
|
||||||
<p className="text-slate-400">Exclusive micro-learning tracks from Falah's leadership network.</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
const AudioLibraryView = ({ setActiveTrack }) => (
|
||||||
{MOCK_AUDIO_TRACKS.map(track => (
|
<div className="animate-fade-in pr-0 md:pr-6">
|
||||||
|
<header className="mb-6 md:mb-8">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-2">Tactical Audio Briefings</h1>
|
||||||
|
<p className="text-slate-400 text-sm md:text-base">Exclusive micro-learning tracks from Falah's leadership network.</p>
|
||||||
|
</header>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 md:gap-6">
|
||||||
|
{MOCK_AUDIO_TRACKS.map((track) => (
|
||||||
<div
|
<div
|
||||||
key={track.id}
|
key={track.id}
|
||||||
onClick={() => setActiveAudio(track)}
|
onClick={() => setActiveTrack(track)}
|
||||||
className="group bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-emerald-500/5 relative overflow-hidden"
|
className="group bg-slate-900 border border-slate-800 hover:border-emerald-500/50 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-emerald-500/5 relative overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-12">
|
<div className="flex justify-between items-start mb-10 md:mb-12">
|
||||||
<span className="px-2.5 py-1 bg-slate-800 text-slate-300 text-xs font-semibold rounded-md uppercase tracking-wider">
|
<span className="px-2.5 py-1 bg-slate-800 text-slate-300 text-xs font-semibold rounded-md uppercase tracking-wider">
|
||||||
{track.tag}
|
{track.tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -229,9 +299,8 @@ const AudioLibraryView = ({ setActiveAudio }) => {
|
|||||||
<PlayCircle size={24} />
|
<PlayCircle size={24} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold text-white mb-2 group-hover:text-emerald-400 transition-colors">{track.title}</h3>
|
<h3 className="text-lg md:text-xl font-semibold text-white mb-2 group-hover:text-emerald-400 transition-colors">{track.title}</h3>
|
||||||
<div className="flex justify-between items-center text-sm text-slate-500">
|
<div className="flex justify-between items-center text-sm text-slate-500">
|
||||||
<span>{track.author}</span>
|
<span>{track.author}</span>
|
||||||
<span className="flex items-center gap-1 font-mono"><Headphones size={14} /> {track.duration}</span>
|
<span className="flex items-center gap-1 font-mono"><Headphones size={14} /> {track.duration}</span>
|
||||||
@@ -242,39 +311,92 @@ const AudioLibraryView = ({ setActiveAudio }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
/* ==========================================================================
|
||||||
|
MISSION CONTROL
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
const MissionControlView = ({ setActiveView, setActiveTrack }) => (
|
||||||
|
<div className="animate-fade-in-up pr-0 md:pr-6">
|
||||||
|
<header className="mb-6 md:mb-8">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">Mission Control</h1>
|
||||||
|
<p className="text-slate-400 text-sm">Welcome back. Your Falah Coach portal is ready.</p>
|
||||||
|
</header>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 md:gap-6">
|
||||||
|
{/* Featured briefing */}
|
||||||
|
<div className="lg:col-span-2 bg-gradient-to-br from-slate-900 to-slate-900/80 border border-slate-800 rounded-2xl p-6 md:p-8 flex flex-col justify-between min-h-[240px] md:min-h-[280px] relative overflow-hidden">
|
||||||
|
<div className="absolute -right-8 -top-8 w-40 h-40 bg-emerald-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest mb-4">
|
||||||
|
<Zap size={14} className="fill-emerald-400" />
|
||||||
|
Featured Briefing
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl md:text-3xl font-bold text-white mb-3">{MOCK_AUDIO_TRACKS[0].title}</h2>
|
||||||
|
<p className="text-slate-400 text-sm md:text-base leading-relaxed mb-6">
|
||||||
|
Master the critical first 100 days of a private equity backed turnaround. Tactical insights from our top Senior Partners.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTrack(MOCK_AUDIO_TRACKS[0])}
|
||||||
|
className="flex items-center gap-2 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-semibold py-3 px-6 rounded-lg transition-colors w-max text-sm md:text-base"
|
||||||
|
>
|
||||||
|
<PlayCircle size={20} className="fill-slate-950 text-slate-950" />
|
||||||
|
Play Briefing ({MOCK_AUDIO_TRACKS[0].duration})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick-start cards */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'AI Simulator', desc: 'Practice hostile boardroom scenarios.', view: 'simulator', icon: MessageSquareQuote },
|
||||||
|
{ label: 'Loop Wiki', desc: 'Review the Falah MECE Guidelines.', view: 'wiki', icon: BookOpen },
|
||||||
|
].map(({ label, desc, view, icon: Icon }) => (
|
||||||
|
<div
|
||||||
|
key={view}
|
||||||
|
onClick={() => setActiveView(view)}
|
||||||
|
className="bg-slate-900 border border-slate-800 hover:border-blue-500/40 rounded-2xl p-5 md:p-6 cursor-pointer transition-all hover:shadow-lg hover:shadow-blue-500/5 flex-1 flex flex-col justify-between"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center mb-4">
|
||||||
|
<Icon size={22} className="text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-1">{label}</h3>
|
||||||
|
<p className="text-slate-400 text-sm">{desc}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
WIKI VIEW
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
const MSLoopWikiView = () => {
|
const MSLoopWikiView = () => {
|
||||||
const [activePage, setActivePage] = useState('Falah MECE Guidelines');
|
const [activePage, setActivePage] = useState('Falah MECE Guidelines');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col pb-4 animate-fade-in pr-6">
|
<div className="h-full flex flex-col pb-4 animate-fade-in pr-0 md:pr-6">
|
||||||
<header className="mb-6">
|
<header className="mb-4 md:mb-6">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-white mb-1">MS Loop Integration</h1>
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white mb-1">MS Loop Integration</h1>
|
||||||
<p className="text-slate-400 text-sm">Falah Playbooks & Living Intellectual Property.</p>
|
<p className="text-slate-400 text-sm">Falah Playbooks & Living Intellectual Property.</p>
|
||||||
</header>
|
</header>
|
||||||
|
<div className="flex-1 flex flex-col md:flex-row gap-4 md:gap-6 min-h-0 overflow-hidden">
|
||||||
<div className="flex-1 flex gap-6 h-[calc(100vh-140px)] overflow-hidden">
|
<div className="w-full md:w-56 shrink-0 bg-slate-900 border border-slate-800 rounded-2xl p-4 overflow-y-auto">
|
||||||
{/* Left Col: Directory */}
|
|
||||||
<div className="w-64 shrink-0 bg-slate-900 border border-slate-800 rounded-2xl p-4 overflow-y-auto custom-scrollbar">
|
|
||||||
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 ml-2">Directory</div>
|
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-4 ml-2">Directory</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{MOCK_WIKI_DIRECTORY.map((dir, idx) => (
|
{MOCK_WIKI_DIRECTORY.map((dir, idx) => (
|
||||||
<div key={idx}>
|
<div key={idx}>
|
||||||
<div className="flex items-center gap-2 text-slate-300 font-medium text-sm mb-2 px-2">
|
<div className="flex items-center gap-2 text-slate-300 font-medium text-sm mb-2 px-2">
|
||||||
<FolderOpen size={16} className="text-blue-400/70" />
|
<FolderOpen size={16} className="text-blue-400/70" /> {dir.folder}
|
||||||
{dir.folder}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{dir.items.map(item => (
|
{dir.items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item}
|
key={item}
|
||||||
onClick={() => setActivePage(item)}
|
onClick={() => setActivePage(item)}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors text-left ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-md transition-colors text-left ${activePage === item ? 'bg-blue-500/10 text-blue-400 font-medium' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'}`}
|
||||||
activePage === item
|
|
||||||
? 'bg-blue-500/10 text-blue-400 font-medium'
|
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<FileText size={14} />
|
<FileText size={14} />
|
||||||
<span className="truncate">{item}</span>
|
<span className="truncate">{item}</span>
|
||||||
@@ -285,44 +407,34 @@ const MSLoopWikiView = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 bg-white rounded-2xl overflow-hidden flex flex-col shadow-2xl min-h-[300px] md:min-h-0">
|
||||||
{/* Right Col: Iframe container */}
|
|
||||||
<div className="flex-1 bg-white rounded-2xl overflow-hidden flex flex-col shadow-2xl relative">
|
|
||||||
{/* Iframe Chrome */}
|
|
||||||
<div className="bg-slate-100 border-b border-slate-200 p-3 flex items-center gap-4">
|
<div className="bg-slate-100 border-b border-slate-200 p-3 flex items-center gap-4">
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<div className="w-3 h-3 rounded-full bg-slate-300"></div>
|
<div className="w-3 h-3 rounded-full bg-slate-300" />
|
||||||
<div className="w-3 h-3 rounded-full bg-slate-300"></div>
|
<div className="w-3 h-3 rounded-full bg-slate-300" />
|
||||||
<div className="w-3 h-3 rounded-full bg-slate-300"></div>
|
<div className="w-3 h-3 rounded-full bg-slate-300" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white border border-slate-200 text-slate-400 text-xs px-3 py-1.5 rounded flex-1 flex justify-between items-center shadow-sm max-w-md">
|
<div className="bg-white border border-slate-200 text-slate-400 text-xs px-3 py-1.5 rounded flex-1 flex items-center shadow-sm max-w-md">
|
||||||
<div className="truncate text-slate-500 font-mono text-[11px]"><span className="text-slate-400">loop.microsoft.com/falah/</span>{activePage.toLowerCase().replace(/ /g, '-')}</div>
|
<span className="text-slate-400 font-mono text-[11px] truncate">loop.microsoft.com/falah/{activePage.toLowerCase().replace(/ /g, '-')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 p-6 md:p-12 overflow-y-auto text-slate-800 bg-[#fbfbfb]">
|
||||||
{/* Simulated content area */}
|
|
||||||
<div className="flex-1 p-12 overflow-y-auto text-slate-800 bg-[#fbfbfb]">
|
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-2xl mx-auto">
|
||||||
<h1 className="text-4xl font-bold mb-8 text-slate-900 border-b border-slate-200 pb-4">{activePage}</h1>
|
<h1 className="text-3xl md:text-4xl font-bold mb-8 text-slate-900 border-b border-slate-200 pb-4">{activePage}</h1>
|
||||||
|
|
||||||
<div className="prose prose-slate max-w-none">
|
<div className="prose prose-slate max-w-none">
|
||||||
<p className="text-lg leading-relaxed mb-6 text-slate-600">
|
<p className="text-lg leading-relaxed mb-6 text-slate-600">This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.</p>
|
||||||
This document serves as the canonical reference for exactly how our consultants should approach problem structuring in this domain.
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-8">
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
|
||||||
<div className="p-4 bg-blue-50 border border-blue-100 rounded-lg">
|
<div className="p-4 bg-blue-50 border border-blue-100 rounded-lg">
|
||||||
<h4 className="font-semibold text-blue-900 m-0 mb-2">Key Objective</h4>
|
<h4 className="font-semibold text-blue-900 mb-2">Key Objective</h4>
|
||||||
<p className="text-sm text-blue-800 m-0">Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.</p>
|
<p className="text-sm text-blue-800">Drive unambiguous clarity to executive stakeholders within 3 minutes of engagement.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg">
|
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-lg">
|
||||||
<h4 className="font-semibold text-emerald-900 m-0 mb-2">Success Metric</h4>
|
<h4 className="font-semibold text-emerald-900 mb-2">Success Metric</h4>
|
||||||
<p className="text-sm text-emerald-800 m-0">Achieve client alignment before the first break in the agenda.</p>
|
<p className="text-sm text-emerald-800">Achieve client alignment before the first break in the agenda.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-semibold mb-4 mt-8 text-slate-800">1. Executive Summary</h3>
|
<h3 className="text-2xl font-semibold mb-4 mt-8 text-slate-800">1. Executive Summary</h3>
|
||||||
<p className="text-slate-600 leading-relaxed mb-4">
|
<p className="text-slate-600 leading-relaxed mb-4">The imperative here is not just structural elegance, but actionable intelligence. When framing the issue, prioritise the financial delta and the organisational capacity required to capture it.</p>
|
||||||
The imperative here is not just structural elegance, but actionable intelligence. When framing the issue, prioritize the financial delta and the organizational capacity required to capture it.
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-2 list-disc pl-5 text-slate-600 mb-8">
|
<ul className="space-y-2 list-disc pl-5 text-slate-600 mb-8">
|
||||||
<li>Identify the core constraint (Capital, Talent, or Regulatory).</li>
|
<li>Identify the core constraint (Capital, Talent, or Regulatory).</li>
|
||||||
<li>Map the adjacent capabilities required.</li>
|
<li>Map the adjacent capabilities required.</li>
|
||||||
@@ -337,18 +449,150 @@ const MSLoopWikiView = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
MAIN APP SHELL
|
MAIN APP SHELL — with real HTML5 audio player
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [activeView, setActiveView] = useState('missionControl');
|
const [activeView, setActiveView] = useState('missionControl');
|
||||||
const [activeAudio, setActiveAudio] = useState(null);
|
const [activeTrack, setActiveTrack] = useState(null); // currently loaded track object
|
||||||
|
const [trackIndex, setTrackIndex] = useState(0); // index into MOCK_AUDIO_TRACKS
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [ttsLoading, setTtsLoading] = useState(false);
|
||||||
|
|
||||||
const togglePlay = () => setIsPlaying(!isPlaying);
|
// We use Web Speech API for TTS
|
||||||
|
const utteranceRef = useRef(null);
|
||||||
|
const ttsIntervalRef = useRef(null);
|
||||||
|
const ttsStartTimeRef = useRef(0); // Date.now() when speech started
|
||||||
|
const ttsElapsedRef = useRef(0); // seconds elapsed before last pause
|
||||||
|
|
||||||
|
// ── TTS helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const stopTTS = useCallback(() => {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
clearInterval(ttsIntervalRef.current);
|
||||||
|
utteranceRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startTTS = useCallback((track, fromSeconds = 0) => {
|
||||||
|
if (!track?.ttsScript) return;
|
||||||
|
stopTTS();
|
||||||
|
|
||||||
|
// Estimate total duration from char count (~150 WPM, ~5 chars/word)
|
||||||
|
const estimatedDuration = track.ttsScript.length / 12.5;
|
||||||
|
setDuration(estimatedDuration);
|
||||||
|
|
||||||
|
// Trim script to approximate position
|
||||||
|
const charOffset = Math.floor((fromSeconds / estimatedDuration) * track.ttsScript.length);
|
||||||
|
const scriptSlice = track.ttsScript.slice(charOffset);
|
||||||
|
|
||||||
|
const utt = new SpeechSynthesisUtterance(scriptSlice);
|
||||||
|
utt.rate = 0.92;
|
||||||
|
utt.pitch = 1.0;
|
||||||
|
utt.volume = 1.0;
|
||||||
|
// Try to pick a natural English voice
|
||||||
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
const preferred = voices.find((v) => v.lang.startsWith('en') && v.name.toLowerCase().includes('google'))
|
||||||
|
|| voices.find((v) => v.lang.startsWith('en'))
|
||||||
|
|| null;
|
||||||
|
if (preferred) utt.voice = preferred;
|
||||||
|
|
||||||
|
ttsStartTimeRef.current = Date.now();
|
||||||
|
ttsElapsedRef.current = fromSeconds;
|
||||||
|
|
||||||
|
utt.onend = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
ttsElapsedRef.current = 0;
|
||||||
|
clearInterval(ttsIntervalRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
utt.onerror = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
clearInterval(ttsIntervalRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
utteranceRef.current = utt;
|
||||||
|
window.speechSynthesis.speak(utt);
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
// Poll current time
|
||||||
|
ttsIntervalRef.current = setInterval(() => {
|
||||||
|
const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
|
||||||
|
setCurrentTime(Math.min(elapsed, estimatedDuration));
|
||||||
|
}, 250);
|
||||||
|
}, [stopTTS]);
|
||||||
|
|
||||||
|
// ── Load a track ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const loadTrack = useCallback((track) => {
|
||||||
|
const idx = MOCK_AUDIO_TRACKS.findIndex((t) => t.id === track.id);
|
||||||
|
stopTTS();
|
||||||
|
setActiveTrack(track);
|
||||||
|
setTrackIndex(idx >= 0 ? idx : 0);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(0);
|
||||||
|
setIsPlaying(false);
|
||||||
|
ttsElapsedRef.current = 0;
|
||||||
|
// Auto-play
|
||||||
|
setTimeout(() => startTTS(track, 0), 200);
|
||||||
|
}, [stopTTS, startTTS]);
|
||||||
|
|
||||||
|
// Keep setActiveTrack in sync with loadTrack for child components
|
||||||
|
const handleSetActiveTrack = useCallback((track) => {
|
||||||
|
loadTrack(track);
|
||||||
|
}, [loadTrack]);
|
||||||
|
|
||||||
|
// ── Play / Pause ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (!activeTrack) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
// Pause: capture elapsed time and cancel
|
||||||
|
const elapsed = ttsElapsedRef.current + (Date.now() - ttsStartTimeRef.current) / 1000;
|
||||||
|
ttsElapsedRef.current = elapsed;
|
||||||
|
stopTTS();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
// Resume from elapsed position
|
||||||
|
startTTS(activeTrack, ttsElapsedRef.current);
|
||||||
|
}
|
||||||
|
}, [activeTrack, isPlaying, stopTTS, startTTS]);
|
||||||
|
|
||||||
|
// ── Skip next / previous ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const skipNext = useCallback(() => {
|
||||||
|
const next = (trackIndex + 1) % MOCK_AUDIO_TRACKS.length;
|
||||||
|
loadTrack(MOCK_AUDIO_TRACKS[next]);
|
||||||
|
}, [trackIndex, loadTrack]);
|
||||||
|
|
||||||
|
const skipPrev = useCallback(() => {
|
||||||
|
const prev = (trackIndex - 1 + MOCK_AUDIO_TRACKS.length) % MOCK_AUDIO_TRACKS.length;
|
||||||
|
loadTrack(MOCK_AUDIO_TRACKS[prev]);
|
||||||
|
}, [trackIndex, loadTrack]);
|
||||||
|
|
||||||
|
// ── Seek ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const seek = useCallback((e) => {
|
||||||
|
if (!activeTrack || !duration) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||||
|
const target = frac * duration;
|
||||||
|
ttsElapsedRef.current = target;
|
||||||
|
setCurrentTime(target);
|
||||||
|
if (isPlaying) {
|
||||||
|
stopTTS();
|
||||||
|
setTimeout(() => startTTS(activeTrack, target), 100);
|
||||||
|
}
|
||||||
|
}, [activeTrack, duration, isPlaying, stopTTS, startTTS]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => { stopTTS(); }, [stopTTS]);
|
||||||
|
|
||||||
|
// ── Nav items ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard },
|
{ id: 'missionControl', label: 'Mission Control', icon: LayoutDashboard },
|
||||||
@@ -362,19 +606,34 @@ export default function App() {
|
|||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AudioPlayer = () => (
|
// ── Audio Player UI (reused in sidebar + mobile strip) ───────────────────
|
||||||
activeAudio ? (
|
|
||||||
|
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
const SidebarPlayer = () => (
|
||||||
|
activeTrack ? (
|
||||||
<div className="animate-fade-in-up">
|
<div className="animate-fade-in-up">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider">Now Playing</span>
|
<span className="text-[10px] font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1">
|
||||||
<span className="text-xs text-slate-500 font-mono">{isPlaying ? '02:14' : '00:00'} / {activeAudio.duration}</span>
|
<Volume2 size={10} /> Now Playing
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-mono">{formatTime(currentTime)} / {activeTrack.duration}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-sm text-white truncate mb-1" title={activeAudio.title}>
|
<p className="font-semibold text-sm text-white truncate mb-0.5" title={activeTrack.title}>{activeTrack.title}</p>
|
||||||
{activeAudio.title}
|
<p className="text-xs text-slate-400 mb-3 truncate">{activeTrack.author}</p>
|
||||||
</p>
|
{/* Seekable progress bar */}
|
||||||
<p className="text-xs text-slate-400 mb-3 truncate">{activeAudio.author}</p>
|
<div
|
||||||
<div className="flex items-center justify-between mt-3 px-2">
|
className="w-full bg-slate-800 h-1.5 rounded-full overflow-hidden cursor-pointer mb-3"
|
||||||
<button aria-label="Previous track" className="text-slate-400 hover:text-white transition-colors"><SkipBack size={18} /></button>
|
onClick={seek}
|
||||||
|
title="Click to seek"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-emerald-500 h-full rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<button aria-label="Previous track" onClick={skipPrev} className="text-slate-400 hover:text-white transition-colors"><SkipBack size={18} /></button>
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
@@ -382,10 +641,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{isPlaying ? <PauseCircle size={24} className="text-slate-950" /> : <PlayCircle size={24} className="text-slate-950" />}
|
{isPlaying ? <PauseCircle size={24} className="text-slate-950" /> : <PlayCircle size={24} className="text-slate-950" />}
|
||||||
</button>
|
</button>
|
||||||
<button aria-label="Next track" className="text-slate-400 hover:text-white transition-colors"><SkipForward size={18} /></button>
|
<button aria-label="Next track" onClick={skipNext} className="text-slate-400 hover:text-white transition-colors"><SkipForward size={18} /></button>
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-800 h-1 mt-4 rounded-full overflow-hidden">
|
|
||||||
<div className="bg-emerald-500 h-full w-1/4 rounded-full transition-all duration-1000 ease-linear"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -396,25 +652,23 @@ export default function App() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-slate-950 text-slate-100 font-sans overflow-hidden selection:bg-blue-500/30">
|
<div className="flex h-screen bg-slate-950 text-slate-100 font-sans overflow-hidden selection:bg-blue-500/30">
|
||||||
|
|
||||||
{/* Mobile overlay backdrop */}
|
{/* Mobile overlay backdrop */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div className="fixed inset-0 z-30 bg-black/60 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
className="fixed inset-0 z-30 bg-black/60 md:hidden"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar — slide-over on mobile, fixed column on desktop */}
|
{/* Sidebar */}
|
||||||
<div className={`
|
<div className={`
|
||||||
fixed inset-y-0 left-0 z-40 w-72 border-r border-slate-800 flex flex-col bg-slate-900 shadow-2xl
|
fixed inset-y-0 left-0 z-40 w-72 border-r border-slate-800 flex flex-col bg-slate-900 shadow-2xl
|
||||||
transform transition-transform duration-300 ease-in-out
|
transform transition-transform duration-300 ease-in-out
|
||||||
md:relative md:translate-x-0
|
md:relative md:translate-x-0
|
||||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
|
${sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
|
||||||
`}>
|
`}>
|
||||||
{/* Brand */}
|
|
||||||
<div className="p-6 flex items-start justify-between">
|
<div className="p-6 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
@@ -425,34 +679,24 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] uppercase font-bold tracking-[0.2em] text-slate-500 ml-11">Coach Portal</p>
|
<p className="text-[10px] uppercase font-bold tracking-[0.2em] text-slate-500 ml-11">Coach Portal</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-slate-500 hover:text-white p-1 mt-1" aria-label="Close menu">
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="md:hidden text-slate-500 hover:text-white p-1 mt-1"
|
|
||||||
aria-label="Close menu"
|
|
||||||
>
|
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 px-4 mt-2 space-y-2 overflow-y-auto">
|
<nav className="flex-1 px-4 mt-2 space-y-2 overflow-y-auto">
|
||||||
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 ml-3">Platform</div>
|
<div className="text-xs font-semibold text-slate-600 uppercase tracking-wider mb-4 ml-3">Platform</div>
|
||||||
{navItems.map((item) => {
|
{navItems.map(({ id, label, icon: Icon }) => {
|
||||||
const Icon = item.icon;
|
const isActive = activeView === id;
|
||||||
const isActive = activeView === item.id;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={id}
|
||||||
onClick={() => handleNavClick(item.id)}
|
onClick={() => handleNavClick(id)}
|
||||||
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group ${
|
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg transition-all duration-200 group ${isActive ? 'bg-blue-600 shadow-md shadow-blue-500/20' : 'hover:bg-slate-800 text-slate-400 hover:text-slate-100'}`}
|
||||||
isActive
|
|
||||||
? 'bg-blue-600 shadow-md shadow-blue-500/20'
|
|
||||||
: 'hover:bg-slate-800 text-slate-400 hover:text-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Icon size={18} className={isActive ? 'text-white' : 'text-slate-500 group-hover:text-blue-400 transition-colors'} />
|
<Icon size={18} className={isActive ? 'text-white' : 'text-slate-500 group-hover:text-blue-400 transition-colors'} />
|
||||||
<span className={`font-medium ${isActive ? 'text-white' : ''}`}>{item.label}</span>
|
<span className={`font-medium ${isActive ? 'text-white' : ''}`}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{isActive && <ChevronRight size={16} className="text-blue-200" />}
|
{isActive && <ChevronRight size={16} className="text-blue-200" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -460,9 +704,8 @@ export default function App() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Global Audio Player */}
|
|
||||||
<div className="mt-auto border-t border-slate-800 p-4 bg-slate-950/50 backdrop-blur-md">
|
<div className="mt-auto border-t border-slate-800 p-4 bg-slate-950/50 backdrop-blur-md">
|
||||||
<AudioPlayer />
|
<SidebarPlayer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -471,11 +714,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* Mobile top bar */}
|
{/* Mobile top bar */}
|
||||||
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shrink-0 z-20">
|
<header className="md:hidden flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800 shrink-0 z-20">
|
||||||
<button
|
<button onClick={() => setSidebarOpen(true)} aria-label="Open menu" className="text-slate-400 hover:text-white p-1">
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
aria-label="Open menu"
|
|
||||||
className="text-slate-400 hover:text-white p-1"
|
|
||||||
>
|
|
||||||
<Menu size={22} />
|
<Menu size={22} />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -485,67 +724,57 @@ export default function App() {
|
|||||||
<span className="font-bold text-sm tracking-wide text-white uppercase">FALAH</span>
|
<span className="font-bold text-sm tracking-wide text-white uppercase">FALAH</span>
|
||||||
<span className="text-[9px] uppercase font-bold tracking-widest text-slate-500">Coach Portal</span>
|
<span className="text-[9px] uppercase font-bold tracking-widest text-slate-500">Coach Portal</span>
|
||||||
</div>
|
</div>
|
||||||
{activeAudio ? (
|
{activeTrack ? (
|
||||||
<button
|
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'} className="w-8 h-8 bg-emerald-500 hover:bg-emerald-400 rounded-full flex items-center justify-center transition-all">
|
||||||
onClick={togglePlay}
|
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
className="w-8 h-8 bg-emerald-500 hover:bg-emerald-400 rounded-full flex items-center justify-center transition-all"
|
|
||||||
>
|
|
||||||
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
|
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : <div className="w-8" />}
|
||||||
<div className="w-8" />
|
|
||||||
)}
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Scrollable main content */}
|
{/* Scrollable main */}
|
||||||
<main className="flex-1 overflow-y-auto p-4 md:p-8 relative scroll-smooth overflow-x-hidden">
|
<main className="flex-1 overflow-y-auto p-4 md:p-8 relative scroll-smooth overflow-x-hidden">
|
||||||
<div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none" />
|
<div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-blue-600/5 rounded-full blur-[120px] pointer-events-none" />
|
||||||
<div className="max-w-6xl mx-auto h-full relative z-10">
|
<div className="max-w-6xl mx-auto h-full relative z-10">
|
||||||
{activeView === 'missionControl' && <MissionControlView setActiveView={setActiveView} setActiveAudio={setActiveAudio} />}
|
{activeView === 'missionControl' && <MissionControlView setActiveView={setActiveView} setActiveTrack={handleSetActiveTrack} />}
|
||||||
{activeView === 'simulator' && <AICoPilotView />}
|
{activeView === 'simulator' && <AICoPilotView />}
|
||||||
{activeView === 'audio' && <AudioLibraryView setActiveAudio={setActiveAudio} />}
|
{activeView === 'audio' && <AudioLibraryView setActiveTrack={handleSetActiveTrack} />}
|
||||||
{activeView === 'wiki' && <MSLoopWikiView />}
|
{activeView === 'wiki' && <MSLoopWikiView />}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Mobile mini audio strip */}
|
{/* Mobile mini audio strip */}
|
||||||
{activeAudio && (
|
{activeTrack && (
|
||||||
<div className="md:hidden shrink-0 bg-slate-900 border-t border-emerald-500/30 px-4 py-2 flex items-center gap-3 z-20 animate-fade-in-up">
|
<div className="md:hidden shrink-0 bg-slate-900 border-t border-emerald-500/30 px-4 py-2 z-20 animate-fade-in-up">
|
||||||
|
<div
|
||||||
|
className="w-full bg-slate-800 h-1 rounded-full overflow-hidden cursor-pointer mb-2"
|
||||||
|
onClick={seek}
|
||||||
|
>
|
||||||
|
<div className="bg-emerald-500 h-full rounded-full transition-all duration-300" style={{ width: `${progressPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-xs font-semibold text-white truncate">{activeAudio.title}</p>
|
<p className="text-xs font-semibold text-white truncate">{activeTrack.title}</p>
|
||||||
<p className="text-[10px] text-emerald-400 font-bold uppercase tracking-wider">Now Playing</p>
|
<p className="text-[10px] text-emerald-400 font-bold uppercase tracking-wider">{isPlaying ? 'Now Playing' : 'Paused'} · {formatTime(currentTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<button aria-label="Previous" className="text-slate-400"><SkipBack size={16} /></button>
|
<button aria-label="Previous" onClick={skipPrev} className="text-slate-400"><SkipBack size={16} /></button>
|
||||||
<button
|
<button onClick={togglePlay} aria-label={isPlaying ? 'Pause' : 'Play'} className="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center">
|
||||||
onClick={togglePlay}
|
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
|
||||||
className="w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
|
{isPlaying ? <PauseCircle size={18} className="text-slate-950" /> : <PlayCircle size={18} className="text-slate-950" />}
|
||||||
</button>
|
</button>
|
||||||
<button aria-label="Next" className="text-slate-400"><SkipForward size={16} /></button>
|
<button aria-label="Next" onClick={skipNext} className="text-slate-400"><SkipForward size={16} /></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile bottom nav bar */}
|
{/* Mobile bottom nav */}
|
||||||
<nav className="md:hidden shrink-0 bg-slate-900 border-t border-slate-800 flex items-center justify-around px-2 py-2 z-20">
|
<nav className="md:hidden shrink-0 bg-slate-900 border-t border-slate-800 flex items-center justify-around px-2 py-2 z-20">
|
||||||
{navItems.map((item) => {
|
{navItems.map(({ id, label, icon: Icon }) => {
|
||||||
const Icon = item.icon;
|
const isActive = activeView === id;
|
||||||
const isActive = activeView === item.id;
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button key={id} onClick={() => handleNavClick(id)} aria-label={label} className={`flex flex-col items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${isActive ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300'}`}>
|
||||||
key={item.id}
|
|
||||||
onClick={() => handleNavClick(item.id)}
|
|
||||||
aria-label={item.label}
|
|
||||||
className={`flex flex-col items-center gap-1 px-3 py-1.5 rounded-lg transition-colors ${
|
|
||||||
isActive ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
<span className="text-[10px] font-medium leading-none">{item.label.split(' ')[0]}</span>
|
<span className="text-[10px] font-medium leading-none">{label.split(' ')[0]}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user