From a12388ff39beb64d04d8cfa1b8dc1be443fd10c2 Mon Sep 17 00:00:00 2001 From: Jason Kneen Date: Fri, 17 Jan 2025 08:06:12 +0000 Subject: [PATCH 1/2] implement theme provider and toggle for dark mode support --- src/app/App.tsx | 25 ++-- src/app/components/BottomToolbar.tsx | 34 ++--- src/app/components/Events.tsx | 37 +++--- src/app/components/ThemeProvider.tsx | 76 ++++++++++++ src/app/components/ThemeToggle.tsx | 60 +++++++++ src/app/components/Transcript.tsx | 42 ++++--- src/app/globals.css | 179 ++++++++++++++++++++++++++- src/app/layout.tsx | 32 ++++- tailwind.config.ts | 5 + 9 files changed, 424 insertions(+), 66 deletions(-) create mode 100644 src/app/components/ThemeProvider.tsx create mode 100644 src/app/components/ThemeToggle.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index e785ca7..d5593d9 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,8 @@ import Image from "next/image"; import Transcript from "./components/Transcript"; import Events from "./components/Events"; import BottomToolbar from "./components/BottomToolbar"; +import { ThemeToggle } from "./components/ThemeToggle"; +import { ThemeProvider } from "./components/ThemeProvider"; // Types import { AgentConfig, SessionStatus } from "@/app/types"; @@ -26,6 +28,15 @@ import { createRealtimeConnection } from "./lib/realtimeConnection"; import { allAgentSets, defaultAgentSetKey } from "@/app/agentConfigs"; function App() { + return ( + + + + + ); +} + +function AppContent() { const searchParams = useSearchParams(); const { transcriptItems, addTranscriptMessage, addTranscriptBreadcrumb } = @@ -404,8 +415,8 @@ function App() { const agentSetKey = searchParams.get("agentConfig") || "default"; return ( -
-
+
+
window.location.reload()} style={{ cursor: 'pointer' }}> OpenAI Logo
- Realtime API Agents + Realtime API Agents
@@ -428,7 +439,7 @@ function App() { {selectedAgentConfigSet?.map(agent => (
-
+
label "Disconnect" -> red - return `bg-red-600 hover:bg-red-700 ${cursorClass} ${baseClasses}`; + return `${cursorClass} ${baseClasses}`; } - // Disconnected or connecting -> label is either "Connect" or "Connecting" -> black - return `bg-black hover:bg-gray-900 ${cursorClass} ${baseClasses}`; + return `bg-surface dark:bg-surface hover:bg-surface/90 dark:hover:bg-surface/90 ${cursorClass} ${baseClasses}`; } return ( -
+
@@ -94,9 +94,9 @@ function BottomToolbar({ checked={isAudioPlaybackEnabled} onChange={e => setIsAudioPlaybackEnabled(e.target.checked)} disabled={!isConnected} - className="w-4 h-4" + className="w-4 h-4 accent-primary dark:accent-primary cursor-pointer" /> -
@@ -107,9 +107,9 @@ function BottomToolbar({ type="checkbox" checked={isEventsPaneExpanded} onChange={e => setIsEventsPaneExpanded(e.target.checked)} - className="w-4 h-4" + className="w-4 h-4 accent-primary dark:accent-primary cursor-pointer" /> -
diff --git a/src/app/components/Events.tsx b/src/app/components/Events.tsx index 22a1a34..7994e4e 100644 --- a/src/app/components/Events.tsx +++ b/src/app/components/Events.tsx @@ -15,9 +15,9 @@ function Events({ isExpanded }: EventsProps) { const { loggedEvents, toggleExpand } = useEvent(); const getDirectionArrow = (direction: string) => { - if (direction === "client") return { symbol: "▲", color: "#7f5af0" }; - if (direction === "server") return { symbol: "▼", color: "#2cb67d" }; - return { symbol: "•", color: "#555" }; + if (direction === "client") return { symbol: "▲", color: "var(--primary)" }; + if (direction === "server") return { symbol: "▼", color: "var(--accent)" }; + return { symbol: "•", color: "var(--muted)" }; }; useEffect(() => { @@ -33,15 +33,16 @@ function Events({ isExpanded }: EventsProps) { return (
{isExpanded && (
-
+
Logs
@@ -54,7 +55,7 @@ function Events({ isExpanded }: EventsProps) { return (
toggleExpand(log.id)} @@ -65,25 +66,27 @@ function Events({ isExpanded }: EventsProps) { style={{ color: arrowInfo.color }} className="ml-1 mr-2" > - {arrowInfo.symbol} + {arrowInfo.symbol} {log.eventName}
-
+
{log.timestamp}
{log.expanded && log.eventData && ( -
-
+                    
+
                         {JSON.stringify(log.eventData, null, 2)}
                       
diff --git a/src/app/components/ThemeProvider.tsx b/src/app/components/ThemeProvider.tsx new file mode 100644 index 0000000..e603546 --- /dev/null +++ b/src/app/components/ThemeProvider.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'blue' | 'green' | 'purple' | 'default'; +type Mode = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + mode: Mode; + setTheme: (theme: Theme) => void; + setMode: (mode: Mode) => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState('default'); + const [mode, setMode] = useState('light'); + + useEffect(() => { + // Load saved preferences + const savedTheme = localStorage.getItem('theme') as Theme; + const savedMode = localStorage.getItem('mode') as Mode; + + if (savedTheme) setTheme(savedTheme); + if (savedMode) setMode(savedMode); + }, []); + + useEffect(() => { + // Update document classes and data attributes + const root = document.documentElement; + + // Update dark mode class + if (mode === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + // Update theme + if (theme !== 'default') { + root.setAttribute('data-theme', `${theme}-${mode}`); + } else { + root.removeAttribute('data-theme'); + } + + // Apply background color transition + document.body.style.transition = 'background-color 0.3s ease'; + + // Save preferences + localStorage.setItem('theme', theme); + localStorage.setItem('mode', mode); + }, [theme, mode]); + + const value = { + theme, + mode, + setTheme: (newTheme: Theme) => setTheme(newTheme), + setMode: (newMode: Mode) => setMode(newMode), + }; + + return ( + + {children} + + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/app/components/ThemeToggle.tsx b/src/app/components/ThemeToggle.tsx new file mode 100644 index 0000000..2c8a2ef --- /dev/null +++ b/src/app/components/ThemeToggle.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React, { useState } from 'react'; +import { useTheme } from './ThemeProvider'; + +export function ThemeToggle() { + const { theme, mode, setTheme, setMode } = useTheme(); + const [showThemeSelector, setShowThemeSelector] = useState(false); + + const themes = [ + { id: 'default', name: 'Default' }, + { id: 'blue', name: 'Blue' }, + { id: 'green', name: 'Green' }, + { id: 'purple', name: 'Purple' }, + ]; + + return ( + <> +
+ {/* Mode Toggle */} + + + {/* Theme Selector Toggle */} + +
+ + {/* Theme Selector Dropdown */} + {showThemeSelector && ( +
+ {themes.map((t) => ( + + ))} +
+ )} + + ); +} \ No newline at end of file diff --git a/src/app/components/Transcript.tsx b/src/app/components/Transcript.tsx index bf64c67..c178c6f 100644 --- a/src/app/components/Transcript.tsx +++ b/src/app/components/Transcript.tsx @@ -48,7 +48,6 @@ function Transcript({ setPrevLogs(transcriptItems); }, [transcriptItems]); - // Autofocus on text box input on load useEffect(() => { if (canSend && inputRef.current) { inputRef.current.focus(); @@ -67,11 +66,11 @@ function Transcript({ }; return ( -
+
@@ -91,15 +90,19 @@ function Transcript({ const isUser = role === "user"; const baseContainer = "flex justify-end flex-col"; const containerClasses = `${baseContainer} ${isUser ? "items-end" : "items-start"}`; - const bubbleBase = `max-w-lg p-3 rounded-xl ${isUser ? "bg-gray-900 text-gray-100" : "bg-gray-100 text-black"}`; + const bubbleBase = `max-w-lg p-3 rounded-lg transition-all duration-200 shadow-md ${ + isUser + ? "bg-[#4a4a4a] dark:bg-accent/20 text-white dark:text-white border border-[#2a2a2a] dark:border-accent/20" + : "bg-[#4a2a2a]/10 bg-accent/20 text-foreground dark:text-foreground border border-[#C4C4C4] dark:border-[#404040]" + }`; const isBracketedMessage = title.startsWith("[") && title.endsWith("]"); - const messageStyle = isBracketedMessage ? "italic text-gray-400" : ""; + const messageStyle = isBracketedMessage ? "italic text-muted dark:text-muted" : ""; const displayTitle = isBracketedMessage ? title.slice(1, -1) : title; return (
-
+
{timestamp}
@@ -112,18 +115,18 @@ function Transcript({ return (
{timestamp}
data && toggleTranscriptItemExpand(itemId)} > {data && ( @@ -133,8 +136,8 @@ function Transcript({ {title}
{expanded && data && ( -
-
+                    
+
                         {JSON.stringify(data, null, 2)}
                       
@@ -142,11 +145,10 @@ function Transcript({
); } else { - // Fallback if type is neither MESSAGE nor BREADCRUMB return (
Unknown item type: {type}{" "} {timestamp} @@ -157,7 +159,7 @@ function Transcript({
-
+
diff --git a/src/app/globals.css b/src/app/globals.css index 30f9f4b..1a12897 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,21 +2,188 @@ @tailwind components; @tailwind utilities; +/* Default Light Theme */ :root { --background: #fafafa; --foreground: #171717; + --primary: #2563eb; + --secondary: #4f46e5; + --accent: #0ea5e9; + --muted: #6b7280; + --border: #6b7280; + --divider: #282828; } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; +/* Default Dark Theme */ +/* Default Dark Theme */ +.dark { + --background: #1a1a1a; + --foreground: #fffff; + --primary: #60a5fa; + --secondary: #818cf8; + --accent: #38bdf8; + --muted: #9ca3af; + --border: #2a2a2a; + --panel-bg: #202020; + --panel-border: #303030; + --panel-border-light: #6a2a2a; + --panel-border-dark: #404040; + --disconnect: #dc2626; + --disconnect-hover: #b91c1c; + --surface: #242424; + --divider: #282828; +} + +/* Material Design Shadows and Transitions */ +.dark .panel { + background: var(--panel-bg); + border: 1px solid var(--panel-border); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +/* Custom shadows for dark mode */ +.dark .shadow-md { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3), + 0 4px 8px rgba(0, 0, 0, 0.2); +} + +/* Custom shadows for light mode */ +.shadow-md { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1), + 0 4px 8px rgba(0, 0, 0, 0.1); +} + +/* Smooth transitions */ +* { + transition: background-color 0.1s ease-in-out, + color 0.1s ease-in-out, + border-color 0.1s ease-in-out, + box-shadow 0.1s ease-in-out; +} + +/* Dark mode specific button styles */ +.dark .disconnect-btn { + background: var(--disconnect); + border: 1px solid var(--panel-border); +} + +.dark .disconnect-btn:hover { + background: var(--disconnect-hover); +} +/* Blue Theme - Light */ +[data-theme="blue-light"] { + --background: #f0f9ff; + --foreground: #0c4a6e; + --primary: #0284c7; + --secondary: #0369a1; + --accent: #0ea5e9; + --muted: #64748b; +} + +/* Green Theme - Light */ +[data-theme="green-light"] { + --background: #f0fdf4; + --foreground: #14532d; + --primary: #16a34a; + --secondary: #15803d; + --accent: #22c55e; + --muted: #64748b; +} + +/* Purple Theme - Light */ +[data-theme="purple-light"] { + --background: #faf5ff; + --foreground: #581c87; + --primary: #9333ea; + --secondary: #7e22ce; + --accent: #a855f7; + --muted: #64748b; +} + +/* Dark Theme Variants */ +.dark { + --background: #0a0a0a; + --foreground: #ededed; + --primary: #60a5fa; + --secondary: #818cf8; + --accent: #38bdf8; + --muted: #9ca3af; +} + +/* Blue Theme - Dark */ +.dark[data-theme="blue-dark"] { + --background: #0c4a6e; + --foreground: #e0f2fe; + --primary: #38bdf8; + --secondary: #0ea5e9; + --accent: #7dd3fc; + --muted: #94a3b8; +} + +/* Green Theme - Dark */ +.dark[data-theme="green-dark"] { + --background: #14532d; + --foreground: #dcfce7; + --primary: #4ade80; + --secondary: #22c55e; + --accent: #86efac; + --muted: #94a3b8; +} + +/* Purple Theme - Dark */ +.dark[data-theme="purple-dark"] { + --background: #581c87; + --foreground: #f3e8ff; + --primary: #c084fc; + --secondary: #a855f7; + --accent: #d8b4fe; + --muted: #94a3b8; +} + +@layer base { + body { + @apply text-foreground bg-background transition-colors duration-100; + } + + /* Improved dark mode transitions */ + * { + @apply transition-[background-color,color,border-color,box-shadow] duration-100; } } body { - color: var(--foreground); - background: var(--background); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif; + min-height: 100vh; + margin: 0; +} + +/* Dark mode scrollbar */ +.dark ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.dark ::-webkit-scrollbar-track { + background: var(--background); +} + +.dark ::-webkit-scrollbar-thumb { + background: var(--primary); + border-radius: 5px; +} + +.dark ::-webkit-scrollbar-thumb:hover { + background: var(--accent); +} + +/* Responsive Design Adjustments */ +@layer utilities { + .theme-toggle { + @apply fixed bottom-4 right-4 md:bottom-8 md:right-8; + } + + .theme-selector { + @apply fixed bottom-16 right-4 md:bottom-20 md:right-8 bg-background border border-muted rounded-lg p-2 shadow-lg; + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index acb7c79..d4086be 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,19 +1,47 @@ import type { Metadata } from "next"; import "./globals.css"; +import { ThemeProvider } from "./components/ThemeProvider"; +import { ThemeToggle } from "./components/ThemeToggle"; export const metadata: Metadata = { title: "Realtime API Agents", description: "A demo app from OpenAI.", }; +function ThemeScript() { + return ( +