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.
This commit is contained in:
Danny Stocker 2025-11-30 05:12:38 +01:00
parent 63f7438d01
commit 3a88d69d1d
18 changed files with 1666 additions and 8 deletions

24
.gitignore vendored Normal file
View file

@ -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?

515
App.tsx Normal file
View file

@ -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>(Language.EN);
const [mode, setMode] = useState<AppMode>(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<Chat | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className={`flex h-screen bg-earth-50 text-earth-900 font-sans overflow-hidden`}>
{/* Sidebar */}
<Sidebar
isOpen={isSidebarOpen || (mode === AppMode.ADVANCED && window.innerWidth >= 768)}
onClose={() => setIsSidebarOpen(false)}
sessions={sessions}
folders={folders}
currentSessionId={currentSessionId}
onSelectSession={loadSession}
onNewChat={startNewSession}
onNewFolder={createFolder}
onDeleteSession={deleteSession}
onMoveSession={moveSessionToFolder}
onDeleteFolder={deleteFolder}
language={language}
/>
<div className={`flex-1 flex flex-col h-full transition-all duration-300 ${mode === AppMode.ADVANCED && window.innerWidth >= 768 ? 'md:ml-[280px]' : ''}`}>
<Header
language={language}
onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)}
mode={mode}
onToggleMode={() => setMode(prev => prev === AppMode.SIMPLE ? AppMode.ADVANCED : AppMode.SIMPLE)}
onExportSession={() => openExportModal('full')}
/>
<main className="flex-1 overflow-y-auto px-4 md:px-0">
<div className="max-w-3xl mx-auto py-8">
{messages.map((msg) => (
<MessageBubble
key={msg.id}
message={msg}
onDelete={handleDeleteMessage}
onReact={handleReaction}
onExport={() => openExportModal('single', msg)}
language={language}
mode={mode}
/>
))}
{messages.length === 1 && (
<div className="mt-8 grid grid-cols-1 sm:grid-cols-2 gap-3 px-4 animate-fade-in">
{INITIAL_SUGGESTIONS.map((suggestion, index) => (
<button
key={index}
onClick={() => handleSendMessage(suggestion[language])}
className="p-4 text-left rounded-xl bg-white/60 border border-earth-100 hover:border-clay-300 hover:bg-white hover:shadow-sm transition-all duration-300 text-sm text-earth-700 font-medium"
>
{suggestion[language]}
</button>
))}
</div>
)}
{isLoading && (
<div className="flex w-full mb-8 justify-start animate-pulse px-6 md:px-0">
<div className="bg-white border border-earth-100 rounded-2xl rounded-tl-sm px-6 py-5 shadow-sm">
<div className="flex space-x-2 items-center h-5">
<div className="w-1.5 h-1.5 bg-earth-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
<div className="w-1.5 h-1.5 bg-earth-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
<div className="w-1.5 h-1.5 bg-earth-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} className="h-4" />
</div>
</main>
<InputArea
language={language}
onSend={handleSendMessage}
isLoading={isLoading}
isOffTheRecord={isOffTheRecord}
onToggleOffTheRecord={() => setIsOffTheRecord(!isOffTheRecord)}
/>
</div>
<ExportModal
isOpen={exportModalOpen}
onClose={() => setExportModalOpen(false)}
onExport={handleExport}
language={language}
title={exportTarget?.type === 'single' ? (language === 'en' ? 'Export Message' : language === 'es' ? 'Exportar Mensaje' : 'Exporter Message') : undefined}
/>
</div>
);
};
export default App;

View file

@ -1,11 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div>
# 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`

View file

@ -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<ExportModalProps> = ({ isOpen, onClose, onExport, language, title }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-earth-900/40 backdrop-blur-sm z-50 flex items-center justify-center p-4 animate-fade-in">
<div className="bg-white rounded-2xl shadow-xl max-w-sm w-full border border-earth-100 overflow-hidden animate-slide-up">
<div className="p-4 border-b border-earth-100 flex items-center justify-between bg-earth-50/50">
<h3 className="font-serif font-bold text-earth-800">{title || TEXTS.exportTitle[language]}</h3>
<button onClick={onClose} className="p-1 text-earth-400 hover:text-earth-800 transition-colors">
<X size={18} />
</button>
</div>
<div className="p-6 flex flex-col gap-3">
<p className="text-sm text-earth-600 mb-2">{TEXTS.downloadAs[language]}:</p>
<button
onClick={() => onExport('pdf')}
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
>
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
<File size={20} strokeWidth={1.5} />
</div>
<div className="text-left">
<span className="block text-sm font-semibold text-earth-800">PDF</span>
<span className="block text-xs text-earth-500">Document</span>
</div>
</button>
<button
onClick={() => onExport('md')}
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
>
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
<FileText size={20} strokeWidth={1.5} />
</div>
<div className="text-left">
<span className="block text-sm font-semibold text-earth-800">Markdown</span>
<span className="block text-xs text-earth-500">Text format</span>
</div>
</button>
<button
onClick={() => onExport('json')}
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
>
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
<FileCode size={20} strokeWidth={1.5} />
</div>
<div className="text-left">
<span className="block text-sm font-semibold text-earth-800">JSON</span>
<span className="block text-xs text-earth-500">Data format</span>
</div>
</button>
<button
onClick={() => onExport('txt')}
className="flex items-center gap-3 p-3 rounded-xl border border-earth-200 hover:border-clay-400 hover:bg-clay-50 transition-all group"
>
<div className="bg-earth-100 text-earth-600 p-2 rounded-lg group-hover:bg-clay-200 group-hover:text-clay-800 transition-colors">
<FileText size={20} strokeWidth={1.5} />
</div>
<div className="text-left">
<span className="block text-sm font-semibold text-earth-800">Plain Text</span>
<span className="block text-xs text-earth-500">Simple text</span>
</div>
</button>
</div>
</div>
</div>
);
};

78
components/Header.tsx Normal file
View file

@ -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<HeaderProps> = ({
language,
onToggleSidebar,
mode,
onToggleMode,
onExportSession
}) => {
const isAdvanced = mode === AppMode.ADVANCED;
return (
<header className="sticky top-0 z-10 bg-earth-50/90 backdrop-blur-md border-b border-earth-200/50 px-4 md:px-6 py-3 transition-all duration-300">
<div className="flex items-center justify-between">
{/* Left Side: Sidebar Toggle & Branding */}
<div className="flex items-center gap-3">
{isAdvanced && (
<button
onClick={onToggleSidebar}
className="p-2 -ml-2 text-earth-600 hover:text-earth-900 hover:bg-earth-100 rounded-lg transition-colors flex-shrink-0"
aria-label="Toggle Sidebar"
>
<Menu size={20} strokeWidth={1.5} />
</button>
)}
<div className="flex flex-col">
<h1 className="font-serif text-xl md:text-2xl font-bold text-earth-900 tracking-tight flex items-center gap-2">
{TEXTS.appTitle[language]}
{isAdvanced && <span className="text-[10px] bg-earth-200 text-earth-700 px-1.5 py-0.5 rounded uppercase tracking-widest font-sans font-semibold">Advanced</span>}
</h1>
<p className="hidden sm:block text-[10px] md:text-xs text-earth-500 font-medium tracking-widest uppercase">
{TEXTS.appSubtitle[language]}
</p>
</div>
</div>
{/* Right Side: Controls */}
<div className="flex items-center gap-2 md:gap-4">
{/* Global Export Button (Advanced only) */}
{isAdvanced && (
<button
onClick={onExportSession}
className="p-2 text-earth-600 hover:text-earth-900 hover:bg-earth-100 rounded-lg transition-colors"
title={TEXTS.exportChat[language]}
>
<Download size={20} strokeWidth={1.5} />
</button>
)}
{/* Mode Toggle - Explicit */}
<button
onClick={onToggleMode}
className="flex items-center gap-2 px-2 py-1 hover:bg-earth-100 rounded-lg transition-colors group"
title={isAdvanced ? "Switch to Simple Mode" : "Switch to Advanced Mode"}
>
<span className="text-[10px] font-bold text-earth-700 uppercase tracking-wider hidden sm:inline">{isAdvanced ? TEXTS.advancedMode[language] : TEXTS.simpleMode[language]}</span>
{isAdvanced ? <ToggleRight size={24} strokeWidth={1.5} className="text-clay-600"/> : <ToggleLeft size={24} strokeWidth={1.5} className="text-earth-400"/>}
</button>
</div>
</div>
</header>
);
};

101
components/InputArea.tsx Normal file
View file

@ -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<InputAreaProps> = ({ language, onSend, isLoading, isOffTheRecord, onToggleOffTheRecord }) => {
const [input, setInput] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 (
<div className="w-full max-w-3xl mx-auto px-4 pb-6 pt-2">
<div className={`
relative flex items-end gap-2 rounded-3xl shadow-lg border p-2 transition-all duration-300 focus-within:shadow-xl
${isOffTheRecord ? 'bg-earth-50 border-earth-200 shadow-none' : 'bg-white border-earth-100 shadow-earth-200/50'}
`}>
<textarea
ref={textareaRef}
value={input}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={TEXTS.inputPlaceholder[language]}
rows={1}
disabled={isLoading}
className="w-full bg-transparent border-0 focus:ring-0 text-earth-800 placeholder-earth-400 resize-none py-3 px-4 max-h-[150px] overflow-y-auto"
style={{ minHeight: '48px' }}
/>
<button
onClick={() => handleSubmit()}
disabled={!input.trim() || isLoading}
className={`
p-3 rounded-full flex-shrink-0 mb-1 transition-all duration-200 flex items-center justify-center
${
input.trim() && !isLoading
? 'bg-earth-800 text-earth-50 hover:bg-earth-700 shadow-md'
: 'bg-earth-100 text-earth-300 cursor-not-allowed'
}
`}
aria-label={TEXTS.sendButton[language]}
>
{isLoading ? (
<Loader2 className="animate-spin" size={20} strokeWidth={1.5} />
) : (
<SendHorizontal size={20} strokeWidth={1.5} />
)}
</button>
</div>
<div className="flex justify-center mt-3">
<button
onClick={onToggleOffTheRecord}
className={`
text-[10px] font-medium uppercase tracking-widest transition-all duration-300 flex items-center gap-2 px-3 py-1 rounded-full cursor-pointer hover:bg-earth-200/50
${isOffTheRecord ? 'text-earth-400' : 'text-clay-600'}
`}
>
<span className={`w-2 h-2 rounded-full ${isOffTheRecord ? 'bg-earth-400' : 'bg-clay-500 animate-pulse'}`}></span>
{isOffTheRecord ? TEXTS.saveOff[language] : TEXTS.saveOn[language]}
</button>
</div>
</div>
);
};

View file

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Trash2, Heart, Leaf, HelpCircle, Download } from 'lucide-react';
import { Role, Message, Language, AppMode } from '../types';
import { TEXTS } from '../constants';
import { getConversationalTime } from '../utils';
interface MessageBubbleProps {
message: Message;
onDelete: (id: string) => void;
onReact: (id: string, reaction: string) => void;
onExport: (message: Message) => void;
language: Language;
mode: AppMode;
}
export const MessageBubble: React.FC<MessageBubbleProps> = ({
message,
onDelete,
onReact,
onExport,
language,
mode
}) => {
const isUser = message.role === Role.USER;
const isAdvanced = mode === AppMode.ADVANCED;
const [showActions, setShowActions] = useState(false);
// Define icons with em sizing to scale with text
const iconSize = "1.2em";
const strokeWidth = 1.5;
return (
<div
className={`group flex w-full mb-8 ${isUser ? 'justify-end' : 'justify-start'} animate-slide-up`}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
<div className={`relative max-w-[85%] md:max-w-[70%] flex flex-col ${isUser ? 'items-end' : 'items-start'}`}>
<div
className={`
relative px-6 py-4 shadow-sm text-base leading-relaxed rounded-2xl
transition-all duration-300
${
isUser
? 'bg-clay-600 text-white rounded-tr-sm'
: 'bg-white border border-earth-100 text-earth-900 rounded-tl-sm'
}
${message.isError ? 'border-red-300 bg-red-50 text-red-800' : ''}
`}
>
<div className={`prose prose-sm max-w-none ${isUser ? 'prose-invert' : 'prose-stone'}`}>
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
{/* Reactions Display */}
{message.reactions && message.reactions.length > 0 && (
<div className={`absolute -bottom-3 ${isUser ? 'left-0' : 'right-0'} flex -space-x-1`}>
{message.reactions.map((r, i) => (
<span key={i} className="bg-white border border-earth-200 rounded-full w-5 h-5 flex items-center justify-center text-[10px] shadow-sm transform hover:scale-110 transition-transform cursor-default">
{r === 'heart' ? '❤️' : r === 'reflect' ? '🌿' : '🤔'}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-3 mt-2 px-1 h-6">
<span className={`text-[10px] uppercase tracking-wider opacity-50 font-medium ${isUser ? 'text-earth-600' : 'text-earth-400'}`}>
{isAdvanced ? getConversationalTime(message.timestamp, language) : message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{/* Action Bar */}
<div className={`flex items-center gap-1 transition-opacity duration-200 ${showActions || (isAdvanced && !isUser) ? 'opacity-100' : 'opacity-0'}`}>
{/* Export single message */}
{isAdvanced && !message.isError && (
<button
onClick={() => onExport(message)}
className="text-earth-400 hover:text-earth-700 p-1 rounded hover:bg-earth-100 transition-colors"
title={TEXTS.exportChat[language]}
>
<Download size={iconSize} strokeWidth={strokeWidth} />
</button>
)}
{!message.isError && (
<button
onClick={() => onDelete(message.id)}
className="text-earth-400 hover:text-red-400 p-1 rounded hover:bg-red-50 transition-colors"
title={TEXTS.deleteMessage[language]}
>
<Trash2 size={iconSize} strokeWidth={strokeWidth} />
</button>
)}
{isAdvanced && !isUser && !message.isError && (
<div className="flex items-center gap-0.5 pl-1 border-l border-earth-200/50 ml-1">
<button
onClick={() => onReact(message.id, 'heart')}
className="p-1 text-earth-400 hover:text-pink-500 hover:bg-pink-50 rounded transition-colors"
title={TEXTS.reactHeart[language]}
>
<Heart size={iconSize} strokeWidth={strokeWidth} />
</button>
<button
onClick={() => onReact(message.id, 'reflect')}
className="p-1 text-earth-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
title={TEXTS.reactReflect[language]}
>
<Leaf size={iconSize} strokeWidth={strokeWidth} />
</button>
<button
onClick={() => onReact(message.id, 'question')}
className="p-1 text-earth-400 hover:text-blue-500 hover:bg-blue-50 rounded transition-colors"
title={TEXTS.reactQuestion[language]}
>
<HelpCircle size={iconSize} strokeWidth={strokeWidth} />
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
};

241
components/Sidebar.tsx Normal file
View file

@ -0,0 +1,241 @@
import React, { useState } from 'react';
import { MessageSquare, Plus, Trash2, X, Folder, ChevronRight, ChevronDown, MoreVertical, FolderPlus } from 'lucide-react';
import { Session, Folder as FolderType, Language } from '../types';
import { TEXTS } from '../constants';
import { getConversationalTime } from '../utils';
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
sessions: Session[];
folders: FolderType[];
currentSessionId: string | null;
onSelectSession: (id: string) => void;
onNewChat: () => void;
onNewFolder: (name: string) => void;
onDeleteSession: (id: string, e: React.MouseEvent) => void;
onMoveSession: (sessionId: string, folderId: string | undefined) => void;
onDeleteFolder: (id: string) => void;
language: Language;
}
export const Sidebar: React.FC<SidebarProps> = ({
isOpen,
onClose,
sessions,
folders,
currentSessionId,
onSelectSession,
onNewChat,
onNewFolder,
onDeleteSession,
onMoveSession,
onDeleteFolder,
language
}) => {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(folders.map(f => f.id)));
const [showNewFolderInput, setShowNewFolderInput] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(null);
const toggleFolder = (id: string) => {
const newSet = new Set(expandedFolders);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
setExpandedFolders(newSet);
};
const handleCreateFolder = (e: React.FormEvent) => {
e.preventDefault();
if (newFolderName.trim()) {
onNewFolder(newFolderName.trim());
setNewFolderName('');
setShowNewFolderInput(false);
}
};
const sessionsByFolder: Record<string, Session[]> = {};
const unsortedSessions: Session[] = [];
sessions.forEach(s => {
if (s.folderId && folders.find(f => f.id === s.folderId)) {
if (!sessionsByFolder[s.folderId]) sessionsByFolder[s.folderId] = [];
sessionsByFolder[s.folderId].push(s);
} else {
unsortedSessions.push(s);
}
});
const renderSessionItem = (session: Session) => (
<div
key={session.id}
className={`
relative group flex items-center gap-3 p-2.5 rounded-lg cursor-pointer transition-all duration-200
${currentSessionId === session.id ? 'bg-white shadow-sm border border-earth-200' : 'hover:bg-earth-200/50 border border-transparent'}
`}
onClick={() => { onSelectSession(session.id); if(window.innerWidth < 768) onClose(); }}
>
<MessageSquare size={16} className={`flex-shrink-0 ${currentSessionId === session.id ? 'text-clay-500' : 'text-earth-400'}`} />
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${currentSessionId === session.id ? 'text-earth-900' : 'text-earth-700'}`}>
{session.title}
</p>
<p className="text-[10px] text-earth-500 uppercase tracking-wide truncate">
{getConversationalTime(session.updatedAt, language)}
</p>
</div>
{/* Context Menu Trigger */}
<button
onClick={(e) => { e.stopPropagation(); setOpenMenuSessionId(openMenuSessionId === session.id ? null : session.id); }}
className={`p-1 rounded hover:bg-earth-200 text-earth-400 hover:text-earth-800 ${openMenuSessionId === session.id ? 'opacity-100 bg-earth-200' : 'opacity-0 group-hover:opacity-100'}`}
>
<MoreVertical size={14} />
</button>
{/* Context Menu Dropdown */}
{openMenuSessionId === session.id && (
<>
<div className="fixed inset-0 z-10" onClick={(e) => { e.stopPropagation(); setOpenMenuSessionId(null); }}></div>
<div className="absolute right-2 top-8 w-40 bg-white border border-earth-200 rounded-lg shadow-xl z-20 py-1 flex flex-col animate-fade-in">
<button
onClick={(e) => { onDeleteSession(session.id, e); setOpenMenuSessionId(null); }}
className="flex items-center gap-2 px-3 py-2 text-xs text-red-600 hover:bg-red-50 w-full text-left"
>
<Trash2 size={12} /> {TEXTS.deleteMessage[language]}
</button>
<div className="h-px bg-earth-100 my-1"></div>
<div className="px-3 py-1 text-[10px] text-earth-400 font-bold uppercase tracking-wider">{TEXTS.moveTo[language]}</div>
<button
onClick={(e) => { e.stopPropagation(); onMoveSession(session.id, undefined); setOpenMenuSessionId(null); }}
className="px-3 py-1.5 text-xs text-earth-700 hover:bg-earth-50 w-full text-left truncate"
>
{TEXTS.unsorted[language]}
</button>
{folders.map(f => (
<button
key={f.id}
onClick={(e) => { e.stopPropagation(); onMoveSession(session.id, f.id); setOpenMenuSessionId(null); }}
className="px-3 py-1.5 text-xs text-earth-700 hover:bg-earth-50 w-full text-left truncate"
>
{f.name}
</button>
))}
</div>
</>
)}
</div>
);
return (
<>
{/* Overlay for mobile */}
<div
className={`fixed inset-0 bg-earth-900/20 backdrop-blur-sm z-40 transition-opacity duration-300 md:hidden ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
onClick={onClose}
/>
{/* Sidebar Panel */}
<div className={`
fixed top-0 left-0 h-full w-[280px] bg-earth-100 border-r border-earth-200 z-50
transform transition-transform duration-300 ease-in-out flex flex-col shadow-2xl
${isOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="p-4 border-b border-earth-200 flex items-center justify-between">
<h2 className="font-serif font-bold text-earth-800">{TEXTS.sidebarTitle[language]}</h2>
<button onClick={onClose} className="md:hidden p-1 text-earth-500 hover:text-earth-900">
<X size={20} />
</button>
</div>
<div className="p-3 grid grid-cols-2 gap-2">
<button
onClick={() => { onNewChat(); if(window.innerWidth < 768) onClose(); }}
className="flex items-center gap-2 justify-center py-2 px-3 bg-earth-800 text-earth-50 rounded-lg hover:bg-earth-700 transition-colors shadow-sm text-xs font-medium"
>
<Plus size={14} strokeWidth={2} />
{TEXTS.newChat[language]}
</button>
<button
onClick={() => setShowNewFolderInput(true)}
className="flex items-center gap-2 justify-center py-2 px-3 bg-white border border-earth-200 text-earth-700 rounded-lg hover:bg-earth-50 transition-colors text-xs font-medium"
>
<FolderPlus size={14} strokeWidth={2} />
{TEXTS.newFolder[language]}
</button>
</div>
{showNewFolderInput && (
<form onSubmit={handleCreateFolder} className="px-3 pb-2">
<div className="flex gap-2">
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={TEXTS.folderNamePlaceholder[language]}
className="flex-1 text-xs p-2 rounded border border-earth-300 focus:border-clay-400 outline-none"
/>
<button type="submit" className="p-2 bg-clay-500 text-white rounded hover:bg-clay-600"><Plus size={14}/></button>
<button type="button" onClick={() => setShowNewFolderInput(false)} className="p-2 text-earth-500 hover:bg-earth-200 rounded"><X size={14}/></button>
</div>
</form>
)}
<div className="flex-1 overflow-y-auto px-3 pb-4 space-y-4">
{/* Folders */}
{folders.map(folder => (
<div key={folder.id} className="space-y-1">
<div
className="flex items-center justify-between px-2 py-1 text-xs font-bold text-earth-500 uppercase tracking-widest cursor-pointer hover:text-earth-800 group"
onClick={() => toggleFolder(folder.id)}
>
<div className="flex items-center gap-1">
{expandedFolders.has(folder.id) ? <ChevronDown size={12}/> : <ChevronRight size={12}/>}
<Folder size={12} className="mr-1"/>
{folder.name}
</div>
<button
onClick={(e) => { e.stopPropagation(); onDeleteFolder(folder.id); }}
className="opacity-0 group-hover:opacity-100 text-earth-300 hover:text-red-400"
>
<Trash2 size={12}/>
</button>
</div>
{expandedFolders.has(folder.id) && (
<div className="pl-2 space-y-1 border-l border-earth-200 ml-2">
{sessionsByFolder[folder.id]?.length > 0 ? (
sessionsByFolder[folder.id].map(renderSessionItem)
) : (
<p className="text-[10px] text-earth-400 italic px-2 py-1">Empty dossier</p>
)}
</div>
)}
</div>
))}
{/* Unsorted Sessions */}
<div className="space-y-1">
<div className="px-2 py-1 text-xs font-bold text-earth-500 uppercase tracking-widest flex items-center gap-2">
{folders.length > 0 && TEXTS.unsorted[language]}
</div>
{unsortedSessions.map(renderSessionItem)}
</div>
</div>
<div className="p-4 border-t border-earth-200 text-[10px] text-earth-400 text-center font-medium uppercase tracking-widest">
if.emotion v2.1
</div>
</div>
</>
);
};

142
constants.ts Normal file
View file

@ -0,0 +1,142 @@
import { LocalizedStrings } from './types';
export const TEXTS: LocalizedStrings = {
appTitle: {
en: "if.emotion",
es: "if.emotion",
fr: "if.emotion"
},
appSubtitle: {
en: "The Journey Within",
es: "El Viaje Interior",
fr: "Le Voyage Intérieur"
},
inputPlaceholder: {
en: "Write to your future self...",
es: "Escribe a tu yo del futuro...",
fr: "Écrivez à votre futur vous..."
},
sendButton: {
en: "Send",
es: "Enviar",
fr: "Envoyer"
},
welcomeMessage: {
en: "Welcome to if.emotion. This space is yours—a private record of your journey. Every thought is precious here. How are you feeling right now?",
es: "Bienvenido a if.emotion. Este espacio es tuyo—un registro privado de tu viaje. Cada pensamiento es precioso aquí. ¿Cómo te sientes en este momento?",
fr: "Bienvenue sur if.emotion. Cet espace est le vôtre—un registre privé de votre voyage. Chaque pensée est précieuse ici. Comment vous sentez-vous ?"
},
errorMessage: {
en: "The connection was interrupted. Please try again softly.",
es: "La conexión fue interrumpida. Por favor intenta de nuevo suavemente.",
fr: "La connexion a été interrompue. Veuillez réessayer doucement."
},
newChat: {
en: "New Chat",
es: "Nuevo Chat",
fr: "Nouveau Chat"
},
newFolder: {
en: "New Dossier",
es: "Nueva Carpeta",
fr: "Nouveau Dossier"
},
folderNamePlaceholder: {
en: "Dossier Name",
es: "Nombre de Carpeta",
fr: "Nom du Dossier"
},
exportChat: {
en: "Export",
es: "Exportar",
fr: "Exporter"
},
saveOn: {
en: "SAVE: ON",
es: "GUARDAR: ON",
fr: "SAUVEGARDE: ON"
},
saveOff: {
en: "SAVE: OFF",
es: "GUARDAR: OFF",
fr: "SAUVEGARDE: OFF"
},
deleteMessage: {
en: "Remove",
es: "Borrar",
fr: "Effacer"
},
moveTo: {
en: "Move to...",
es: "Mover a...",
fr: "Déplacer vers..."
},
unsorted: {
en: "Unsorted",
es: "Sin clasificar",
fr: "Non classé"
},
advancedMode: {
en: "Advanced",
es: "Avanzado",
fr: "Avancé"
},
simpleMode: {
en: "Simple",
es: "Simple",
fr: "Simple"
},
sessions: {
en: "Journal Entries",
es: "Entradas del Diario",
fr: "Entrées de Journal"
},
model: {
en: "Model",
es: "Modelo",
fr: "Modèle"
},
sidebarTitle: {
en: "Your Journey",
es: "Tu Viaje",
fr: "Votre Voyage"
},
exportTitle: {
en: "Export Options",
es: "Opciones de Exportación",
fr: "Options d'Exportation"
},
downloadAs: {
en: "Download as",
es: "Descargar como",
fr: "Télécharger en"
},
close: {
en: "Close",
es: "Cerrar",
fr: "Fermer"
},
reactHeart: {
en: "Love",
es: "Amor",
fr: "Amour"
},
reactReflect: {
en: "Reflect",
es: "Reflexionar",
fr: "Réfléchir"
},
reactQuestion: {
en: "Question",
es: "Duda",
fr: "Question"
}
};
export const INITIAL_SUGGESTIONS = [
{ en: "I feel overwhelmed today", es: "Me siento abrumado/a hoy", fr: "Je me sens dépassé(e) aujourd'hui" },
{ en: "I want to understand myself", es: "Quiero entenderme mejor", fr: "Je veux mieux me comprendre" },
{ en: "A moment of reflection", es: "Un momento de reflexión", fr: "Un moment de réflexion" },
{ en: "Thinking about the future", es: "Pensando en el futuro", fr: "En pensant à l'avenir" },
];

102
index.html Normal file
View file

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>if.emotion</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
serif: ['Merriweather', 'serif'],
sans: ['Inter', 'sans-serif'],
},
colors: {
earth: {
50: '#faf9f6',
100: '#f5f2eb',
200: '#e6e0d2',
300: '#d2c6b0',
400: '#bcab8e',
500: '#a89070',
600: '#8c7558',
700: '#705c46',
800: '#5e4d3d',
900: '#4d4035',
},
clay: {
100: '#fcf2ea',
200: '#f6dec9',
300: '#efc2a0',
400: '#e69e70',
500: '#dd7b46',
600: '#cf5e32',
700: '#ac4625',
800: '#8c3b24',
900: '#723422',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out forwards',
'slide-up': 'slideUp 0.3s ease-out forwards',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
}
}
}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
background-color: #faf9f6; /* earth-50 */
background-image: radial-gradient(#e6e0d2 1px, transparent 1px);
background-size: 24px 24px;
}
.font-serif {
font-family: 'Merriweather', serif;
}
/* Custom scrollbar for minimalist look */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d2c6b0;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #bcab8e;
}
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
"react-markdown": "https://aistudiocdn.com/react-markdown@^10.1.0",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.555.0",
"@google/genai": "https://aistudiocdn.com/@google/genai@^1.30.0",
"react/": "https://aistudiocdn.com/react@^19.2.0/",
"react": "https://aistudiocdn.com/react@^19.2.0",
"jspdf": "https://aistudiocdn.com/jspdf@^2.5.1"
}
}
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>

15
index.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View file

@ -0,0 +1,5 @@
{
"name": "if.emotion",
"description": "A private, bilingual emotional wellness companion designed for the journey of the soul. Features secure message handling and exportable history.",
"requestFramePermissions": []
}

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "if.emotion",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"lucide-react": "^0.555.0",
"@google/genai": "^1.30.0",
"react": "^19.2.0",
"jspdf": "^2.5.1"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

39
services/gemini.ts Normal file
View file

@ -0,0 +1,39 @@
import { GoogleGenAI, Chat, GenerateContentResponse } from "@google/genai";
const apiKey = process.env.API_KEY;
let ai: GoogleGenAI | null = null;
if (apiKey) {
ai = new GoogleGenAI({ apiKey });
}
// System instruction to set the persona
const SYSTEM_INSTRUCTION = `
You are "if.emotion", a profound, warm, and highly empathetic companion for the user's emotional journey.
Your purpose is to help the user document their feelings, understand themselves, and find peace.
You are bilingual in English and Spanish. Detect the user's language and match it perfectly.
Your tone is grounded, organic, and deeply respectful. You are not just a chatbot; you are a journal that talks back.
Treat every message as precious.
Use therapeutic techniques: active listening, gentle reframing, and validation.
Do not diagnose. If there is a crisis, gently guide them to professional help.
Your responses should feel like a warm hug or a quiet moment in nature.
`;
export const createChatSession = (): Chat | null => {
if (!ai) return null;
return ai.chats.create({
model: 'gemini-2.5-flash',
config: {
systemInstruction: SYSTEM_INSTRUCTION,
temperature: 0.7, // Warm and creative
},
});
};
export const sendMessageStream = async (
chat: Chat,
message: string
): Promise<AsyncIterable<GenerateContentResponse>> => {
return await chat.sendMessageStream({ message });
};

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

50
types.ts Normal file
View file

@ -0,0 +1,50 @@
export enum Role {
USER = 'user',
MODEL = 'model',
}
export interface Message {
id: string;
role: Role;
text: string;
timestamp: Date;
isError?: boolean;
reactions?: string[];
}
export interface Session {
id: string;
folderId?: string; // Optional folder association
title: string;
messages: Message[];
updatedAt: Date;
}
export interface Folder {
id: string;
name: string;
}
export enum Language {
EN = 'en',
ES = 'es',
FR = 'fr',
}
export enum AppMode {
SIMPLE = 'simple',
ADVANCED = 'advanced',
}
export interface UIString {
en: string;
es: string;
fr: string;
}
export type LocalizedStrings = {
[key: string]: UIString;
};
export type ExportFormat = 'json' | 'md' | 'txt' | 'pdf';

44
utils.ts Normal file
View file

@ -0,0 +1,44 @@
import { Language } from './types';
export const getConversationalTime = (date: Date, language: Language): string => {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) {
return language === Language.EN ? 'just now'
: language === Language.ES ? 'ahora mismo'
: 'à l\'instant';
}
if (diffMin < 60) {
return language === Language.EN ? `${diffMin}m ago`
: language === Language.ES ? `hace ${diffMin}m`
: `il y a ${diffMin}m`;
}
if (diffHour < 24) {
return language === Language.EN ? `${diffHour}h ago`
: language === Language.ES ? `hace ${diffHour}h`
: `il y a ${diffHour}h`;
}
if (diffDay === 1) {
return language === Language.EN ? 'yesterday'
: language === Language.ES ? 'ayer'
: 'hier';
}
if (diffDay < 7) {
return language === Language.EN ? `${diffDay} days ago`
: language === Language.ES ? `hace ${diffDay} días`
: `il y a ${diffDay} jours`;
}
return date.toLocaleDateString(language === Language.EN ? 'en-US' : language === Language.ES ? 'es-ES' : 'fr-FR', {
month: 'short', day: 'numeric'
});
};

23
vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});