From 3a88d69d1d58d7109cbf7e74cc68bc024e27c402 Mon Sep 17 00:00:00 2001 From: Danny Stocker Date: Sun, 30 Nov 2025 05:12:38 +0100 Subject: [PATCH] feat: Initialize project with Vite and React Sets up the project structure, dependencies, and configuration for a new Vite-based React application. Includes basic HTML, TypeScript configurations, and necessary build tools for local development and deployment. --- .gitignore | 24 ++ App.tsx | 515 +++++++++++++++++++++++++++++++++++ README.md | 25 +- components/ExportModal.tsx | 86 ++++++ components/Header.tsx | 78 ++++++ components/InputArea.tsx | 101 +++++++ components/MessageBubble.tsx | 130 +++++++++ components/Sidebar.tsx | 241 ++++++++++++++++ constants.ts | 142 ++++++++++ index.html | 102 +++++++ index.tsx | 15 + metadata.json | 5 + package.json | 25 ++ services/gemini.ts | 39 +++ tsconfig.json | 29 ++ types.ts | 50 ++++ utils.ts | 44 +++ vite.config.ts | 23 ++ 18 files changed, 1666 insertions(+), 8 deletions(-) create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 components/ExportModal.tsx create mode 100644 components/Header.tsx create mode 100644 components/InputArea.tsx create mode 100644 components/MessageBubble.tsx create mode 100644 components/Sidebar.tsx create mode 100644 constants.ts create mode 100644 index.html create mode 100644 index.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 services/gemini.ts create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 utils.ts create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..3a4d605 --- /dev/null +++ b/App.tsx @@ -0,0 +1,515 @@ + +import React, { useState, useEffect, useRef } from 'react'; +import { GenerateContentResponse, Chat } from '@google/genai'; +import { Header } from './components/Header'; +import { Sidebar } from './components/Sidebar'; +import { MessageBubble } from './components/MessageBubble'; +import { InputArea } from './components/InputArea'; +import { ExportModal } from './components/ExportModal'; +import { createChatSession, sendMessageStream } from './services/gemini'; +import { Message, Session, Folder, Role, Language, AppMode, ExportFormat } from './types'; +import { TEXTS, INITIAL_SUGGESTIONS } from './constants'; +import { jsPDF } from 'jspdf'; + +const SESSIONS_KEY = 'if.emotion.sessions'; +const FOLDERS_KEY = 'if.emotion.folders'; +const SETTINGS_KEY = 'if.emotion.settings'; + +const App: React.FC = () => { + // Settings & UI State + const [language, setLanguage] = useState(Language.EN); + const [mode, setMode] = useState(AppMode.SIMPLE); + const [isOffTheRecord, setIsOffTheRecord] = useState(false); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + // Modal State + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportTarget, setExportTarget] = useState<{ type: 'full' | 'single', data?: Message } | null>(null); + + // Chat Data + const [chatSession, setChatSession] = useState(null); + const [sessions, setSessions] = useState([]); + const [folders, setFolders] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(null); + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const messagesEndRef = useRef(null); + const isInitialized = useRef(false); + + // --- Initialization & Persistence --- + + // Detect Language Helper + const detectLanguage = (): Language => { + const browserLang = navigator.language.split('-')[0]; + if (browserLang === 'es') return Language.ES; + if (browserLang === 'fr') return Language.FR; + return Language.EN; + }; + + // Load initial state + useEffect(() => { + if (isInitialized.current) return; + isInitialized.current = true; + + // Load Settings + const savedSettings = localStorage.getItem(SETTINGS_KEY); + if (savedSettings) { + const parsed = JSON.parse(savedSettings); + if (parsed.mode) setMode(parsed.mode); + setLanguage(detectLanguage()); + } else { + setLanguage(detectLanguage()); + } + + // Load Folders + const savedFolders = localStorage.getItem(FOLDERS_KEY); + if (savedFolders) { + try { + setFolders(JSON.parse(savedFolders)); + } catch (e) { + console.error("Failed to parse folders", e); + } + } + + // Load Sessions + const savedSessions = localStorage.getItem(SESSIONS_KEY); + if (savedSessions) { + try { + const parsed: Session[] = JSON.parse(savedSessions).map((s: any) => ({ + ...s, + updatedAt: new Date(s.updatedAt), + messages: s.messages.map((m: any) => ({ + ...m, + timestamp: new Date(m.timestamp) + })) + })); + setSessions(parsed); + // Load most recent session if available + if (parsed.length > 0) { + loadSession(parsed[0].id, parsed); + } else { + startNewSession(); + } + } catch (e) { + console.error("Failed to parse sessions", e); + startNewSession(); + } + } else { + startNewSession(); + } + }, []); + + // Persist Settings + useEffect(() => { + if (isInitialized.current) { + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode })); + } + }, [mode]); + + // Persist Folders + useEffect(() => { + if (isInitialized.current) { + localStorage.setItem(FOLDERS_KEY, JSON.stringify(folders)); + } + }, [folders]); + + // Persist Sessions (Auto-save current messages to session) + useEffect(() => { + if (!isInitialized.current || !currentSessionId) return; + + // If Off the Record, we DO NOT update the session in storage with new messages + if (isOffTheRecord) return; + + setSessions(prevSessions => { + const updatedSessions = prevSessions.map(session => { + if (session.id === currentSessionId) { + // Update title based on first user message if still default + let title = session.title; + const firstUserMsg = messages.find(m => m.role === Role.USER); + if (firstUserMsg && (title === 'New Journey' || title === 'Nuevo Viaje' || title === 'Nouveau Voyage')) { + title = firstUserMsg.text.slice(0, 30) + (firstUserMsg.text.length > 30 ? '...' : ''); + } + + return { + ...session, + messages: messages, + title: title, + updatedAt: new Date() + }; + } + return session; + }); + + // Sort by recency + updatedSessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + + localStorage.setItem(SESSIONS_KEY, JSON.stringify(updatedSessions)); + return updatedSessions; + }); + }, [messages, currentSessionId, isOffTheRecord]); + + + // --- Session & Folder Logic --- + + const createFolder = (name: string) => { + const newFolder: Folder = { + id: Date.now().toString(), + name + }; + setFolders(prev => [...prev, newFolder]); + }; + + const deleteFolder = (id: string) => { + // Ungroup sessions in this folder + setSessions(prev => prev.map(s => s.folderId === id ? { ...s, folderId: undefined } : s)); + setFolders(prev => prev.filter(f => f.id !== id)); + }; + + const moveSessionToFolder = (sessionId: string, folderId: string | undefined) => { + setSessions(prev => { + const updated = prev.map(s => s.id === sessionId ? { ...s, folderId } : s); + localStorage.setItem(SESSIONS_KEY, JSON.stringify(updated)); + return updated; + }); + }; + + const startNewSession = () => { + const newId = Date.now().toString(); + const geminiSession = createChatSession(); + setChatSession(geminiSession); + + const welcomeMsg: Message = { + id: 'welcome-' + newId, + role: Role.MODEL, + text: TEXTS.welcomeMessage[language], + timestamp: new Date(), + }; + + const newSession: Session = { + id: newId, + title: language === Language.ES ? 'Nuevo Viaje' : language === Language.FR ? 'Nouveau Voyage' : 'New Journey', + messages: [welcomeMsg], + updatedAt: new Date() + }; + + if (!isOffTheRecord) { + setSessions(prev => [newSession, ...prev]); + localStorage.setItem(SESSIONS_KEY, JSON.stringify([newSession, ...sessions])); + } + + setCurrentSessionId(newId); + setMessages([welcomeMsg]); + setIsSidebarOpen(false); // Close sidebar on mobile/action + }; + + const loadSession = (id: string, allSessions = sessions) => { + const session = allSessions.find(s => s.id === id); + if (session) { + setChatSession(createChatSession()); // Reset Gemini context contextually + setMessages(session.messages); + setCurrentSessionId(id); + setIsSidebarOpen(false); + } + }; + + const deleteSession = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); // Prevent selection when deleting + const newSessions = sessions.filter(s => s.id !== id); + setSessions(newSessions); + localStorage.setItem(SESSIONS_KEY, JSON.stringify(newSessions)); + + if (currentSessionId === id) { + if (newSessions.length > 0) { + loadSession(newSessions[0].id, newSessions); + } else { + startNewSession(); + } + } + }; + + // --- Message Logic --- + + const handleSendMessage = async (text: string) => { + if (!chatSession) return; + + const userMsg: Message = { + id: Date.now().toString(), + role: Role.USER, + text, + timestamp: new Date(), + }; + + setMessages((prev) => [...prev, userMsg]); + setIsLoading(true); + + try { + const stream = await sendMessageStream(chatSession, text); + + const botMsgId = (Date.now() + 1).toString(); + setMessages((prev) => [ + ...prev, + { + id: botMsgId, + role: Role.MODEL, + text: '', + timestamp: new Date(), + }, + ]); + + let fullText = ''; + for await (const chunk of stream) { + const content = chunk as GenerateContentResponse; + const textChunk = content.text; + if (textChunk) { + fullText += textChunk; + setMessages((prev) => + prev.map((msg) => + msg.id === botMsgId ? { ...msg, text: fullText } : msg + ) + ); + } + } + } catch (error) { + console.error('Error generating response:', error); + setMessages((prev) => [ + ...prev, + { + id: Date.now().toString(), + role: Role.MODEL, + text: TEXTS.errorMessage[language], + timestamp: new Date(), + isError: true, + }, + ]); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteMessage = (id: string) => { + setMessages((prev) => prev.filter(msg => msg.id !== id)); + }; + + const handleReaction = (id: string, reaction: string) => { + setMessages(prev => prev.map(msg => { + if (msg.id === id) { + const currentReactions = msg.reactions || []; + if (currentReactions.includes(reaction)) { + return { ...msg, reactions: currentReactions.filter(r => r !== reaction) }; + } else { + return { ...msg, reactions: [...currentReactions, reaction] }; + } + } + return msg; + })); + }; + + // --- Export Logic --- + + const openExportModal = (type: 'full' | 'single', data?: Message) => { + setExportTarget({ type, data }); + setExportModalOpen(true); + }; + + const handleExport = (format: ExportFormat) => { + const timestamp = new Date().toISOString().split('T')[0]; + let fileName = `if-emotion-${timestamp}`; + + if (format === 'pdf') { + const doc = new jsPDF(); + const lineHeight = 10; + let y = 15; + const margin = 15; + const pageWidth = doc.internal.pageSize.getWidth(); + const maxLineWidth = pageWidth - margin * 2; + + doc.setFont("helvetica", "bold"); + doc.text("if.emotion Journal", margin, y); + y += lineHeight; + doc.setFont("helvetica", "normal"); + doc.setFontSize(10); + doc.text(`Date: ${new Date().toLocaleString()}`, margin, y); + y += lineHeight * 2; + + const msgsToExport = exportTarget?.type === 'single' && exportTarget.data ? [exportTarget.data] : messages; + + msgsToExport.forEach(msg => { + const role = msg.role === Role.USER ? 'Me' : 'if.emotion'; + const time = msg.timestamp.toLocaleTimeString(); + + // Header + doc.setFont("helvetica", "bold"); + doc.text(`${role} [${time}]`, margin, y); + y += 7; + + // Body + doc.setFont("helvetica", "normal"); + const splitText = doc.splitTextToSize(msg.text, maxLineWidth); + + // Check page break + if (y + (splitText.length * 7) > doc.internal.pageSize.getHeight() - margin) { + doc.addPage(); + y = 15; + } + + doc.text(splitText, margin, y); + y += (splitText.length * 7) + 10; + }); + + doc.save(`${fileName}.pdf`); + setExportModalOpen(false); + return; + } + + // Text based formats + let content = ''; + let mimeType = 'text/plain'; + + if (exportTarget?.type === 'single' && exportTarget.data) { + // Export Single Message + const m = exportTarget.data; + fileName += `-message`; + + if (format === 'json') { + content = JSON.stringify(m, null, 2); + mimeType = 'application/json'; + } else if (format === 'md') { + content = `**${m.role.toUpperCase()}** (${m.timestamp.toLocaleString()}):\n\n${m.text}`; + mimeType = 'text/markdown'; + } else { + content = `[${m.role.toUpperCase()}] ${m.text}`; + } + + } else { + // Export Full Chat + if (format === 'json') { + const exportData = { + app: "if.emotion", + title: sessions.find(s => s.id === currentSessionId)?.title || "Session", + date: new Date().toISOString(), + messages: messages + }; + content = JSON.stringify(exportData, null, 2); + mimeType = 'application/json'; + } else if (format === 'md') { + content = `# if.emotion Journal\nDate: ${new Date().toLocaleString()}\n\n---\n\n`; + content += messages.map(m => `### ${m.role === Role.USER ? 'Me' : 'if.emotion'}\n*${m.timestamp.toLocaleString()}*\n\n${m.text}\n\n---\n`).join('\n'); + mimeType = 'text/markdown'; + } else { + content = messages.map(m => `[${m.role.toUpperCase()} - ${m.timestamp.toLocaleString()}]: ${m.text}`).join('\n\n'); + } + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${fileName}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + setExportModalOpen(false); + }; + + // --- Render --- + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isLoading]); + + return ( +
+ + {/* Sidebar */} + = 768)} + onClose={() => setIsSidebarOpen(false)} + sessions={sessions} + folders={folders} + currentSessionId={currentSessionId} + onSelectSession={loadSession} + onNewChat={startNewSession} + onNewFolder={createFolder} + onDeleteSession={deleteSession} + onMoveSession={moveSessionToFolder} + onDeleteFolder={deleteFolder} + language={language} + /> + +
= 768 ? 'md:ml-[280px]' : ''}`}> +
setIsSidebarOpen(!isSidebarOpen)} + mode={mode} + onToggleMode={() => setMode(prev => prev === AppMode.SIMPLE ? AppMode.ADVANCED : AppMode.SIMPLE)} + onExportSession={() => openExportModal('full')} + /> + +
+
+ {messages.map((msg) => ( + openExportModal('single', msg)} + language={language} + mode={mode} + /> + ))} + + {messages.length === 1 && ( +
+ {INITIAL_SUGGESTIONS.map((suggestion, index) => ( + + ))} +
+ )} + + {isLoading && ( +
+
+
+
+
+
+
+
+
+ )} +
+
+
+ + setIsOffTheRecord(!isOffTheRecord)} + /> +
+ + setExportModalOpen(false)} + onExport={handleExport} + language={language} + title={exportTarget?.type === 'single' ? (language === 'en' ? 'Export Message' : language === 'es' ? 'Exportar Mensaje' : 'Exporter Message') : undefined} + /> +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/README.md b/README.md index 2241000..04aa288 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/drive/1XMeOxbvK61pebjVSzwTTnlqMGEJd7AHP + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key +3. Run the app: + `npm run dev` diff --git a/components/ExportModal.tsx b/components/ExportModal.tsx new file mode 100644 index 0000000..5f52eac --- /dev/null +++ b/components/ExportModal.tsx @@ -0,0 +1,86 @@ + +import React from 'react'; +import { FileJson, FileText, FileCode, X, File } from 'lucide-react'; +import { Language, ExportFormat } from '../types'; +import { TEXTS } from '../constants'; + +interface ExportModalProps { + isOpen: boolean; + onClose: () => void; + onExport: (format: ExportFormat) => void; + language: Language; + title?: string; +} + +export const ExportModal: React.FC = ({ isOpen, onClose, onExport, language, title }) => { + if (!isOpen) return null; + + return ( +
+
+
+

{title || TEXTS.exportTitle[language]}

+ +
+ +
+

{TEXTS.downloadAs[language]}:

+ + + + + + + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..7453a3e --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,78 @@ + +import React from 'react'; +import { Menu, ToggleRight, ToggleLeft, Download } from 'lucide-react'; +import { Language, AppMode } from '../types'; +import { TEXTS } from '../constants'; + +interface HeaderProps { + language: Language; + onToggleSidebar: () => void; + mode: AppMode; + onToggleMode: () => void; + onExportSession: () => void; +} + +export const Header: React.FC = ({ + language, + onToggleSidebar, + mode, + onToggleMode, + onExportSession +}) => { + const isAdvanced = mode === AppMode.ADVANCED; + + return ( +
+
+ {/* Left Side: Sidebar Toggle & Branding */} +
+ {isAdvanced && ( + + )} + +
+

+ {TEXTS.appTitle[language]} + {isAdvanced && Advanced} +

+

+ {TEXTS.appSubtitle[language]} +

+
+
+ + {/* Right Side: Controls */} +
+ + {/* Global Export Button (Advanced only) */} + {isAdvanced && ( + + )} + + {/* Mode Toggle - Explicit */} + + +
+
+
+ ); +}; \ No newline at end of file diff --git a/components/InputArea.tsx b/components/InputArea.tsx new file mode 100644 index 0000000..d4d2d0a --- /dev/null +++ b/components/InputArea.tsx @@ -0,0 +1,101 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { SendHorizontal, Loader2 } from 'lucide-react'; +import { Language } from '../types'; +import { TEXTS } from '../constants'; + +interface InputAreaProps { + language: Language; + onSend: (text: string) => void; + isLoading: boolean; + isOffTheRecord: boolean; + onToggleOffTheRecord: () => void; +} + +export const InputArea: React.FC = ({ language, onSend, isLoading, isOffTheRecord, onToggleOffTheRecord }) => { + const [input, setInput] = useState(''); + const textareaRef = useRef(null); + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault(); + if (input.trim() && !isLoading) { + onSend(input); + setInput(''); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleInput = (e: React.ChangeEvent) => { + setInput(e.target.value); + // Auto-resize + e.target.style.height = 'auto'; + e.target.style.height = `${Math.min(e.target.scrollHeight, 150)}px`; + }; + + // Focus input on load + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + return ( +
+
+