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:
parent
63f7438d01
commit
3a88d69d1d
18 changed files with 1666 additions and 8 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
515
App.tsx
Normal 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;
|
||||
25
README.md
25
README.md
|
|
@ -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`
|
||||
|
|
|
|||
86
components/ExportModal.tsx
Normal file
86
components/ExportModal.tsx
Normal 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
78
components/Header.tsx
Normal 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
101
components/InputArea.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
130
components/MessageBubble.tsx
Normal file
130
components/MessageBubble.tsx
Normal 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
241
components/Sidebar.tsx
Normal 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
142
constants.ts
Normal 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
102
index.html
Normal 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
15
index.tsx
Normal 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
5
metadata.json
Normal 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
25
package.json
Normal 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
39
services/gemini.ts
Normal 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
29
tsconfig.json
Normal 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
50
types.ts
Normal 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
44
utils.ts
Normal 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
23
vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue