Snapshot: full project state
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
*.db
|
||||
*.sqlite*
|
||||
*.log
|
||||
.DS_Store
|
||||
out/
|
||||
work/
|
||||
.pio/
|
||||
briefs/
|
||||
dns-backup/
|
||||
archive/
|
||||
*.png
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# PetTalk
|
||||
|
||||
React Native / Expo app skeleton: pet-community app with auth (context +
|
||||
provider), auth pages v1/v2, Express-style server with auth routes and SQLite
|
||||
DB layer. Screen iterations (`pettalk-App-new.tsx`, `-v2.tsx`) and live-import
|
||||
shims included.
|
||||
|
||||
197
pettalk-App-new.tsx
Normal file
197
pettalk-App-new.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pet, AnalysisResult, PetTimelineItem, DictionarySignal, TalkBackLog } from './types';
|
||||
import { INITIAL_PET_LUNA, INITIAL_PET_MILO, INITIAL_TIMELINE_LUNA, INITIAL_DICTIONARY_LUNA } from './data/seedData';
|
||||
import { Navigation } from './components/Navigation';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
import { OnboardingFlow } from './components/OnboardingFlow';
|
||||
import { LiveListenScreen } from './components/LiveListenScreen';
|
||||
import { TalkBackScreen } from './components/TalkBackScreen';
|
||||
import { PetTimeline } from './components/PetTimeline';
|
||||
import { InsightsScreen } from './components/InsightsScreen';
|
||||
import { AuthPage } from './components/AuthPage';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
|
||||
export default function App() {
|
||||
const { user, isLoading, logout } = useAuth();
|
||||
const [pets, setPets] = useState<Pet[]>([INITIAL_PET_LUNA, INITIAL_PET_MILO]);
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>(INITIAL_PET_LUNA.id);
|
||||
const [currentView, setCurrentView] = useState<'landing' | 'onboarding' | 'listen' | 'talkback' | 'timeline' | 'insights'>('landing');
|
||||
|
||||
const [timelineItems, setTimelineItems] = useState<PetTimelineItem[]>(INITIAL_TIMELINE_LUNA);
|
||||
const [dictionarySignals, setDictionarySignals] = useState<DictionarySignal[]>(INITIAL_DICTIONARY_LUNA);
|
||||
|
||||
const selectedPet = pets.find(p => p.id === selectedPetId) || pets[0];
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F9F7F2] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#F08C7D]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show auth page if not authenticated
|
||||
if (!user) {
|
||||
return <AuthPage />;
|
||||
}
|
||||
|
||||
// Callback when analysis completes from LiveListenScreen
|
||||
const handleAnalysisComplete = (result: AnalysisResult) => {
|
||||
const newTimelineItem: PetTimelineItem = {
|
||||
id: `tl_${Date.now()}`,
|
||||
petId: result.petId,
|
||||
type: 'interpretation',
|
||||
title: result.primaryIntent.title,
|
||||
description: result.reasoning.summary,
|
||||
timestamp: result.timestamp,
|
||||
intentCategory: result.primaryIntent.category,
|
||||
confidence: result.primaryIntent.confidence,
|
||||
feedbackStatus: 'pending',
|
||||
zoneName: result.zoneName
|
||||
};
|
||||
|
||||
setTimelineItems(prev => [newTimelineItem, ...prev]);
|
||||
};
|
||||
|
||||
// Callback when pet model updates from training
|
||||
const handleUpdatePet = (updatedPet: Pet) => {
|
||||
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
|
||||
};
|
||||
|
||||
// Callback when Talk Back reaction is logged
|
||||
const handleLogTalkBackReaction = (log: TalkBackLog) => {
|
||||
const newTimelineItem: PetTimelineItem = {
|
||||
id: `tl_tb_${Date.now()}`,
|
||||
petId: log.petId,
|
||||
type: 'talk_back',
|
||||
title: `Played "${log.messageLabel}" Tone`,
|
||||
description: `Pet reaction observed: ${log.petReaction ? log.petReaction.replace('_', ' ') : 'played'}`,
|
||||
timestamp: log.timestamp
|
||||
};
|
||||
|
||||
setTimelineItems(prev => [newTimelineItem, ...prev]);
|
||||
};
|
||||
|
||||
// Callback when manual event is added
|
||||
const handleAddManualEvent = (event: PetTimelineItem) => {
|
||||
setTimelineItems(prev => [event, ...prev]);
|
||||
};
|
||||
|
||||
// Callback when onboarding completes
|
||||
const handleCompleteOnboarding = (newPet: Pet) => {
|
||||
setPets(prev => [...prev, newPet]);
|
||||
setSelectedPetId(newPet.id);
|
||||
setCurrentView('listen');
|
||||
};
|
||||
|
||||
// Callback to reset pet ML model
|
||||
const handleResetPetModel = () => {
|
||||
setPets(prev => prev.map(p => {
|
||||
if (p.id === selectedPetId) {
|
||||
return { ...p, confirmedSampleCount: 0, overallAccuracy: 75 };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
alert(`${selectedPet.name}'s profile memory has been reset.`);
|
||||
};
|
||||
|
||||
// Callback to delete pet account
|
||||
const handleDeletePetAccount = () => {
|
||||
const remainingPets = pets.filter(p => p.id !== selectedPetId);
|
||||
if (remainingPets.length > 0) {
|
||||
setPets(remainingPets);
|
||||
setSelectedPetId(remainingPets[0].id);
|
||||
setCurrentView('landing');
|
||||
} else {
|
||||
// Re-seed Luna if all pets deleted
|
||||
setPets([INITIAL_PET_LUNA]);
|
||||
setSelectedPetId(INITIAL_PET_LUNA.id);
|
||||
setCurrentView('landing');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F9F7F2] text-[#1A2A40] font-sans antialiased selection:bg-[#F08C7D]/30 selection:text-[#1A2A40]">
|
||||
|
||||
{/* Global Responsive Navigation Header & Bottom Bar */}
|
||||
<Navigation
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
pets={pets}
|
||||
selectedPet={selectedPet}
|
||||
setSelectedPetId={setSelectedPetId}
|
||||
onOpenAddPet={() => setCurrentView('onboarding')}
|
||||
/>
|
||||
|
||||
{/* Main Content View Switcher */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-6 sm:py-8">
|
||||
{currentView === 'landing' && (
|
||||
<LandingPage
|
||||
selectedPet={selectedPet}
|
||||
onStartListen={() => setCurrentView('listen')}
|
||||
onStartOnboarding={() => setCurrentView('onboarding')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'onboarding' && (
|
||||
<OnboardingFlow
|
||||
onComplete={handleCompleteOnboarding}
|
||||
onCancel={() => setCurrentView('landing')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'listen' && (
|
||||
<LiveListenScreen
|
||||
selectedPet={selectedPet}
|
||||
onAnalysisComplete={handleAnalysisComplete}
|
||||
onUpdatePet={handleUpdatePet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'talkback' && (
|
||||
<TalkBackScreen
|
||||
selectedPet={selectedPet}
|
||||
onLogTalkBackReaction={handleLogTalkBackReaction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'timeline' && (
|
||||
<PetTimeline
|
||||
selectedPet={selectedPet}
|
||||
timelineItems={timelineItems}
|
||||
onAddManualEvent={handleAddManualEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'insights' && (
|
||||
<InsightsScreen
|
||||
selectedPet={selectedPet}
|
||||
dictionarySignals={dictionarySignals}
|
||||
onResetPetModel={handleResetPetModel}
|
||||
onDeletePetAccount={handleDeletePetAccount}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#E5DFD3] py-8 bg-[#F4EFEC]/60 text-center text-xs text-[#5A6E85] space-y-2">
|
||||
<div className="max-w-4xl mx-auto px-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-serif font-bold text-[#1A2A40] text-sm tracking-tight">PetTalk</span>
|
||||
<span>• Probabilistic AI Pet Behavioral Interpretation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[#5A6E85]">
|
||||
<button onClick={() => setCurrentView('landing')} className="hover:text-[#1A2A40] transition-colors cursor-pointer">About</button>
|
||||
<button onClick={() => setCurrentView('insights')} className="hover:text-[#1A2A40] transition-colors cursor-pointer">Privacy & Data</button>
|
||||
<button onClick={() => setCurrentView('onboarding')} className="hover:text-[#1A2A40] transition-colors cursor-pointer">Add Pet</button>
|
||||
<button onClick={logout} className="hover:text-[#F08C7D] transition-colors cursor-pointer font-semibold">
|
||||
Sign Out ({user?.username})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
224
pettalk-App-v2.tsx
Normal file
224
pettalk-App-v2.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pet, AnalysisResult, PetTimelineItem, DictionarySignal, TalkBackLog } from './types';
|
||||
import { INITIAL_PET_LUNA, INITIAL_PET_MILO, INITIAL_TIMELINE_LUNA, INITIAL_DICTIONARY_LUNA } from './data/seedData';
|
||||
import { Navigation } from './components/Navigation';
|
||||
import { LandingPage } from './components/LandingPage';
|
||||
import { PublicLandingPage } from './components/PublicLandingPage';
|
||||
import { OnboardingFlow } from './components/OnboardingFlow';
|
||||
import { LiveListenScreen } from './components/LiveListenScreen';
|
||||
import { TalkBackScreen } from './components/TalkBackScreen';
|
||||
import { PetTimeline } from './components/PetTimeline';
|
||||
import { InsightsScreen } from './components/InsightsScreen';
|
||||
import { AuthPage } from './components/AuthPage';
|
||||
import { useAuth } from './context/AuthContext';
|
||||
|
||||
export default function App() {
|
||||
const { user, isLoading, logout } = useAuth();
|
||||
const [pets, setPets] = useState<Pet[]>([INITIAL_PET_LUNA, INITIAL_PET_MILO]);
|
||||
const [selectedPetId, setSelectedPetId] = useState<string>(INITIAL_PET_LUNA.id);
|
||||
const [currentView, setCurrentView] = useState<'landing' | 'onboarding' | 'listen' | 'talkback' | 'timeline' | 'insights' | 'auth'>('landing');
|
||||
const [authMode, setAuthMode] = useState<'login' | 'register'>('register');
|
||||
|
||||
const [timelineItems, setTimelineItems] = useState<PetTimelineItem[]>(INITIAL_TIMELINE_LUNA);
|
||||
const [dictionarySignals, setDictionarySignals] = useState<DictionarySignal[]>(INITIAL_DICTIONARY_LUNA);
|
||||
|
||||
const selectedPet = pets.find(p => p.id === selectedPetId) || pets[0];
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F9F7F2] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[#F08C7D]"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── UNAUTHENTICATED FLOW ──
|
||||
if (!user) {
|
||||
// User clicked "Get Started" or "Sign In" → show auth form
|
||||
if (currentView === 'auth') {
|
||||
return (
|
||||
<AuthPage
|
||||
initialMode={authMode}
|
||||
onBack={() => {
|
||||
setCurrentView('landing');
|
||||
setAuthMode('register');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Default: show public landing page
|
||||
return (
|
||||
<PublicLandingPage
|
||||
onGetStarted={() => {
|
||||
setAuthMode('register');
|
||||
setCurrentView('auth');
|
||||
}}
|
||||
onSignIn={() => {
|
||||
setAuthMode('login');
|
||||
setCurrentView('auth');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AUTHENTICATED FLOW ──
|
||||
// Callback when analysis completes from LiveListenScreen
|
||||
const handleAnalysisComplete = (result: AnalysisResult) => {
|
||||
const newTimelineItem: PetTimelineItem = {
|
||||
id: `tl_${Date.now()}`,
|
||||
petId: result.petId,
|
||||
type: 'interpretation',
|
||||
title: result.primaryIntent.title,
|
||||
description: result.reasoning.summary,
|
||||
timestamp: result.timestamp,
|
||||
intentCategory: result.primaryIntent.category,
|
||||
confidence: result.primaryIntent.confidence,
|
||||
feedbackStatus: 'pending',
|
||||
zoneName: result.zoneName
|
||||
};
|
||||
|
||||
setTimelineItems(prev => [newTimelineItem, ...prev]);
|
||||
};
|
||||
|
||||
// Callback when pet model updates from training
|
||||
const handleUpdatePet = (updatedPet: Pet) => {
|
||||
setPets(prev => prev.map(p => p.id === updatedPet.id ? updatedPet : p));
|
||||
};
|
||||
|
||||
// Callback when Talk Back reaction is logged
|
||||
const handleLogTalkBackReaction = (log: TalkBackLog) => {
|
||||
const newTimelineItem: PetTimelineItem = {
|
||||
id: `tl_tb_${Date.now()}`,
|
||||
petId: log.petId,
|
||||
type: 'talk_back',
|
||||
title: `Played "${log.messageLabel}" Tone`,
|
||||
description: `Pet reaction observed: ${log.petReaction ? log.petReaction.replace('_', ' ') : 'played'}`,
|
||||
timestamp: log.timestamp
|
||||
};
|
||||
|
||||
setTimelineItems(prev => [newTimelineItem, ...prev]);
|
||||
};
|
||||
|
||||
// Callback when manual event is added
|
||||
const handleAddManualEvent = (event: PetTimelineItem) => {
|
||||
setTimelineItems(prev => [event, ...prev]);
|
||||
};
|
||||
|
||||
// Callback when onboarding completes
|
||||
const handleCompleteOnboarding = (newPet: Pet) => {
|
||||
setPets(prev => [...prev, newPet]);
|
||||
setSelectedPetId(newPet.id);
|
||||
setCurrentView('listen');
|
||||
};
|
||||
|
||||
// Callback to reset pet ML model
|
||||
const handleResetPetModel = () => {
|
||||
setPets(prev => prev.map(p => {
|
||||
if (p.id === selectedPetId) {
|
||||
return { ...p, confirmedSampleCount: 0, overallAccuracy: 75 };
|
||||
}
|
||||
return p;
|
||||
}));
|
||||
alert(`${selectedPet.name}'s profile memory has been reset.`);
|
||||
};
|
||||
|
||||
// Callback to delete pet account
|
||||
const handleDeletePetAccount = () => {
|
||||
const remainingPets = pets.filter(p => p.id !== selectedPetId);
|
||||
if (remainingPets.length > 0) {
|
||||
setPets(remainingPets);
|
||||
setSelectedPetId(remainingPets[0].id);
|
||||
setCurrentView('landing');
|
||||
} else {
|
||||
// Re-seed Luna if all pets deleted
|
||||
setPets([INITIAL_PET_LUNA]);
|
||||
setSelectedPetId(INITIAL_PET_LUNA.id);
|
||||
setCurrentView('landing');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F9F7F2] text-[#1A2A40] font-sans antialiased selection:bg-[#F08C7D]/30 selection:text-[#1A2A40]">
|
||||
|
||||
{/* Global Responsive Navigation Header & Bottom Bar */}
|
||||
<Navigation
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
pets={pets}
|
||||
selectedPet={selectedPet}
|
||||
setSelectedPetId={setSelectedPetId}
|
||||
onOpenAddPet={() => setCurrentView('onboarding')}
|
||||
/>
|
||||
|
||||
{/* Main Content View Switcher */}
|
||||
<main className="max-w-5xl mx-auto px-4 py-6 sm:py-8">
|
||||
{currentView === 'landing' && (
|
||||
<LandingPage
|
||||
selectedPet={selectedPet}
|
||||
onStartListen={() => setCurrentView('listen')}
|
||||
onStartOnboarding={() => setCurrentView('onboarding')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'onboarding' && (
|
||||
<OnboardingFlow
|
||||
onComplete={handleCompleteOnboarding}
|
||||
onCancel={() => setCurrentView('landing')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'listen' && (
|
||||
<LiveListenScreen
|
||||
selectedPet={selectedPet}
|
||||
onAnalysisComplete={handleAnalysisComplete}
|
||||
onUpdatePet={handleUpdatePet}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'talkback' && (
|
||||
<TalkBackScreen
|
||||
selectedPet={selectedPet}
|
||||
onLogTalkBackReaction={handleLogTalkBackReaction}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'timeline' && (
|
||||
<PetTimeline
|
||||
selectedPet={selectedPet}
|
||||
timelineItems={timelineItems}
|
||||
onAddManualEvent={handleAddManualEvent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentView === 'insights' && (
|
||||
<InsightsScreen
|
||||
selectedPet={selectedPet}
|
||||
dictionarySignals={dictionarySignals}
|
||||
onResetPetModel={handleResetPetModel}
|
||||
onDeletePetAccount={handleDeletePetAccount}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-[#E5DFD3] py-8 bg-[#F4EFEC]/60 text-center text-xs text-[#5A6E85] space-y-2">
|
||||
<div className="max-w-4xl mx-auto px-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-serif font-bold text-[#1A2A40] text-sm tracking-tight">PetTalk</span>
|
||||
<span>• Probabilistic AI Pet Behavioral Interpretation</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-[#5A6E85]">
|
||||
<button onClick={() => setCurrentView('landing')} className="hover:text-[#1A2A40] transition-colors cursor-pointer">About</button>
|
||||
<button onClick={() => setCurrentView('insights')} className="hover:text-[#1A2A40] transition-colors cursor-pointer">Privacy & Data</button>
|
||||
<button onClick={() => setCurrentView('onboarding')} className="hover:text-[#1A2A40] transition-colors cursor-pointer">Add Pet</button>
|
||||
<button onClick={logout} className="hover:text-[#F08C7D] transition-colors cursor-pointer font-semibold">
|
||||
Sign Out ({user?.username})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
pettalk-AuthContext.tsx
Normal file
109
pettalk-AuthContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (username: string, email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
const API_BASE = "";
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem("pettalk_token"));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const fetchMe = useCallback(async (t: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/me", {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
} else {
|
||||
localStorage.removeItem("pettalk_token");
|
||||
setToken(null);
|
||||
}
|
||||
} catch {
|
||||
// Network error — keep token but mark as loaded
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
fetchMe(token).finally(() => setIsLoading(false));
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [token, fetchMe]);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
localStorage.setItem("pettalk_token", data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
};
|
||||
|
||||
const register = async (username: string, email: string, password: string) => {
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Registration failed");
|
||||
}
|
||||
localStorage.setItem("pettalk_token", data.token);
|
||||
setToken(data.token);
|
||||
setUser(data.user);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("pettalk_token");
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isLoading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Helper to get auth headers for fetch calls */
|
||||
export function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem("pettalk_token");
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
176
pettalk-AuthPage-v2.tsx
Normal file
176
pettalk-AuthPage-v2.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Sparkles, Mail, Lock, User, PawPrint, ArrowLeft } from "lucide-react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
interface AuthPageProps {
|
||||
initialMode?: "login" | "register";
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export const AuthPage: React.FC<AuthPageProps> = ({ initialMode = "login", onBack }) => {
|
||||
const { login, register } = useAuth();
|
||||
const [isRegistering, setIsRegistering] = useState(initialMode === "register");
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Sync with initialMode if it changes externally
|
||||
useEffect(() => {
|
||||
setIsRegistering(initialMode === "register");
|
||||
}, [initialMode]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isRegistering) {
|
||||
await register(username, email, password);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F9F7F2] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Back button */}
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mb-4 flex items-center gap-2 text-sm text-[#5A6E85] hover:text-[#1A2A40] transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to PetTAlk
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-[#1A2A40] text-[#F9F7F2] shadow-lg mb-4">
|
||||
<Sparkles className="w-8 h-8 text-[#F08C7D]" />
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl font-bold text-[#1A2A40] tracking-tight">
|
||||
PetTalk
|
||||
</h1>
|
||||
<p className="text-[#5A6E85] text-sm mt-1">
|
||||
Understanding pet intent softly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-white rounded-3xl border border-[#E5DFD3] shadow-2xs p-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<PawPrint className="w-5 h-5 text-[#F08C7D]" />
|
||||
<h2 className="font-serif text-xl font-bold text-[#1A2A40]">
|
||||
{isRegistering ? "Create Account" : "Welcome Back"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isRegistering && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5D78] mb-1.5">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5A6E85]" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your name"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5DFD3] bg-[#F9F7F2] text-[#1A2A40] text-sm placeholder:text-[#5A6E85] focus:outline-none focus:ring-2 focus:ring-[#F08C7D]/40 focus:border-[#F08C7D]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5D78] mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5A6E85]" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5DFD3] bg-[#F9F7F2] text-[#1A2A40] text-sm placeholder:text-[#5A6E85] focus:outline-none focus:ring-2 focus:ring-[#F08C7D]/40 focus:border-[#F08C7D]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5D78] mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5A6E85]" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 6 characters"
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5DFD3] bg-[#F9F7F2] text-[#1A2A40] text-sm placeholder:text-[#5A6E85] focus:outline-none focus:ring-2 focus:ring-[#F08C7D]/40 focus:border-[#F08C7D]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-2xl bg-[#F08C7D] hover:bg-[#E57B6C] text-white font-semibold text-sm flex items-center justify-center gap-2 transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">Please wait...</span>
|
||||
) : isRegistering ? (
|
||||
"Create Account"
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRegistering(!isRegistering);
|
||||
setError("");
|
||||
}}
|
||||
className="text-sm text-[#5A6E85] hover:text-[#1A2A40] underline underline-offset-2 cursor-pointer transition-colors"
|
||||
>
|
||||
{isRegistering
|
||||
? "Already have an account? Sign in"
|
||||
: "Don't have an account? Create one"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-[10px] text-[#5A6E85] mt-6">
|
||||
PetTalk AI · Probabilistic Pet Behavioral Interpretation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
155
pettalk-AuthPage.tsx
Normal file
155
pettalk-AuthPage.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState } from "react";
|
||||
import { Sparkles, Mail, Lock, User, PawPrint } from "lucide-react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export const AuthPage: React.FC = () => {
|
||||
const { login, register } = useAuth();
|
||||
const [isRegistering, setIsRegistering] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isRegistering) {
|
||||
await register(username, email, password);
|
||||
} else {
|
||||
await login(email, password);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F9F7F2] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-[#1A2A40] text-[#F9F7F2] shadow-lg mb-4">
|
||||
<Sparkles className="w-8 h-8 text-[#F08C7D]" />
|
||||
</div>
|
||||
<h1 className="font-serif text-3xl font-bold text-[#1A2A40] tracking-tight">
|
||||
PetTalk
|
||||
</h1>
|
||||
<p className="text-[#5A6E85] text-sm mt-1">
|
||||
Understanding pet intent softly
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-white rounded-3xl border border-[#E5DFD3] shadow-2xs p-8">
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<PawPrint className="w-5 h-5 text-[#F08C7D]" />
|
||||
<h2 className="font-serif text-xl font-bold text-[#1A2A40]">
|
||||
{isRegistering ? "Create Account" : "Welcome Back"}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-xl bg-red-50 border border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{isRegistering && (
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5D78] mb-1.5">
|
||||
Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5A6E85]" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Your name"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5DFD3] bg-[#F9F7F2] text-[#1A2A40] text-sm placeholder:text-[#5A6E85] focus:outline-none focus:ring-2 focus:ring-[#F08C7D]/40 focus:border-[#F08C7D]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5D78] mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5A6E85]" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5DFD3] bg-[#F9F7F2] text-[#1A2A40] text-sm placeholder:text-[#5A6E85] focus:outline-none focus:ring-2 focus:ring-[#F08C7D]/40 focus:border-[#F08C7D]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-[#4A5D78] mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#5A6E85]" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Min 6 characters"
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-[#E5DFD3] bg-[#F9F7F2] text-[#1A2A40] text-sm placeholder:text-[#5A6E85] focus:outline-none focus:ring-2 focus:ring-[#F08C7D]/40 focus:border-[#F08C7D]/50 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-2xl bg-[#F08C7D] hover:bg-[#E57B6C] text-white font-semibold text-sm flex items-center justify-center gap-2 transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="animate-pulse">Please wait...</span>
|
||||
) : isRegistering ? (
|
||||
"Create Account"
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRegistering(!isRegistering);
|
||||
setError("");
|
||||
}}
|
||||
className="text-sm text-[#5A6E85] hover:text-[#1A2A40] underline underline-offset-2 cursor-pointer transition-colors"
|
||||
>
|
||||
{isRegistering
|
||||
? "Already have an account? Sign in"
|
||||
: "Don't have an account? Create one"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-[10px] text-[#5A6E85] mt-6">
|
||||
PetTalk AI · Probabilistic Pet Behavioral Interpretation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
113
pettalk-auth-db.ts
Normal file
113
pettalk-auth-db.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const USERS_FILE = path.join(DATA_DIR, "users.json");
|
||||
|
||||
export interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserPublic {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readUsers(): UserRow[] {
|
||||
ensureDataDir();
|
||||
if (!fs.existsSync(USERS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(USERS_FILE, "utf-8");
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeUsers(users: UserRow[]) {
|
||||
ensureDataDir();
|
||||
fs.writeFileSync(USERS_FILE, JSON.stringify(users, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
function getNextId(): number {
|
||||
if (nextId === 0) {
|
||||
const users = readUsers();
|
||||
nextId = users.reduce((max, u) => Math.max(max, u.id), 0) + 1;
|
||||
}
|
||||
return nextId++;
|
||||
}
|
||||
|
||||
export function createUser(username: string, email: string, password: string): UserPublic {
|
||||
const users = readUsers();
|
||||
|
||||
if (users.find((u) => u.email === email || u.username === username)) {
|
||||
throw new Error("A user with that email or username already exists.");
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 10);
|
||||
const newUser: UserRow = {
|
||||
id: getNextId(),
|
||||
username,
|
||||
email,
|
||||
password_hash,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
writeUsers(users);
|
||||
|
||||
return {
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
created_at: newUser.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function authenticateUser(email: string, password: string): UserPublic {
|
||||
const users = readUsers();
|
||||
const user = users.find((u) => u.email === email);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid email or password.");
|
||||
}
|
||||
|
||||
if (!bcrypt.compareSync(password, user.password_hash)) {
|
||||
throw new Error("Invalid email or password.");
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function getUserById(id: number): UserPublic | null {
|
||||
const users = readUsers();
|
||||
const user = users.find((u) => u.id === id);
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
created_at: user.created_at,
|
||||
};
|
||||
}
|
||||
42
pettalk-auth-middleware.ts
Normal file
42
pettalk-auth-middleware.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "pettalk-dev-secret-change-in-production";
|
||||
|
||||
// Public routes that don't require authentication
|
||||
const PUBLIC_ROUTES = [
|
||||
{ method: "POST", path: "/api/register" },
|
||||
{ method: "POST", path: "/api/login" },
|
||||
{ method: "GET", path: "/api/health" },
|
||||
];
|
||||
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
// Skip auth for public routes
|
||||
const isPublic = PUBLIC_ROUTES.some(
|
||||
(r) => r.method === req.method && req.path === r.path
|
||||
);
|
||||
if (isPublic) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Also skip non-API routes (static files, SPA routes)
|
||||
if (!req.path.startsWith("/api/")) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Authentication required." });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; email: string };
|
||||
(req as any).userId = decoded.userId;
|
||||
(req as any).userEmail = decoded.email;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: "Invalid or expired token." });
|
||||
}
|
||||
}
|
||||
83
pettalk-auth-routes.ts
Normal file
83
pettalk-auth-routes.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { createUser, authenticateUser, getUserById, UserPublic } from "./database.js";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "pettalk-dev-secret-change-in-production";
|
||||
const TOKEN_EXPIRY = "7d";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function generateToken(user: UserPublic): string {
|
||||
return jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
|
||||
expiresIn: TOKEN_EXPIRY,
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/register
|
||||
router.post("/register", (req: Request, res: Response) => {
|
||||
try {
|
||||
const { username, email, password } = req.body;
|
||||
|
||||
if (!username || !email || !password) {
|
||||
return res.status(400).json({ error: "Username, email, and password are required." });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: "Password must be at least 6 characters." });
|
||||
}
|
||||
|
||||
const user = createUser(username, email, password);
|
||||
const token = generateToken(user);
|
||||
|
||||
return res.status(201).json({ user, token });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("already exists")) {
|
||||
return res.status(409).json({ error: err.message });
|
||||
}
|
||||
console.error("Register error:", err);
|
||||
return res.status(500).json({ error: "Registration failed." });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/login
|
||||
router.post("/login", (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: "Email and password are required." });
|
||||
}
|
||||
|
||||
const user = authenticateUser(email, password);
|
||||
const token = generateToken(user);
|
||||
|
||||
return res.json({ user, token });
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("Invalid")) {
|
||||
return res.status(401).json({ error: err.message });
|
||||
}
|
||||
console.error("Login error:", err);
|
||||
return res.status(500).json({ error: "Login failed." });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/me — protected by authMiddleware
|
||||
router.get("/me", (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required." });
|
||||
}
|
||||
|
||||
const user = getUserById(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: "User not found." });
|
||||
}
|
||||
|
||||
return res.json({ user });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: "Failed to fetch user." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
5
pettalk-live-imports.tsx
Normal file
5
pettalk-live-imports.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Pet, AnalysisResult } from '../types';
|
||||
import { audioService } from '../lib/audioService';
|
||||
import { getAuthHeaders } from '../context/AuthContext';
|
||||
import { Mic, MicOff, Camera, CameraOff, Check, X, MessageSquare, Sparkles, AlertCircle, RefreshCw, MapPin, Volume2 } from 'lucide-react';
|
||||
13
pettalk-main-new.tsx
Normal file
13
pettalk-main-new.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
142
pettalk-server-new.ts
Normal file
142
pettalk-server-new.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { GoogleGenAI } from "@google/genai";
|
||||
import { defaultMLProvider } from "./src/lib/mlProvider";
|
||||
import { Pet } from "./src/types";
|
||||
import { authMiddleware } from "./src/auth/middleware";
|
||||
import authRouter from "./src/auth/routes";
|
||||
|
||||
async function startServer() {
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
|
||||
// Auth middleware — protects /api/* except register, login, health
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Auth routes (register, login, me)
|
||||
app.use("/api", authRouter);
|
||||
|
||||
// API Routes
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ status: "ok", app: "PetTalk", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
/**
|
||||
* Analyze Pet Signal API endpoint.
|
||||
* Leverages Gemini API when GEMINI_API_KEY is available, or uses local ML Provider.
|
||||
*/
|
||||
app.post("/api/analyze", async (req, res) => {
|
||||
try {
|
||||
const { pet, selectedZoneId, audio, video, customNote } = req.body as {
|
||||
pet: Pet;
|
||||
selectedZoneId?: string;
|
||||
audio?: any;
|
||||
video?: any;
|
||||
customNote?: string;
|
||||
};
|
||||
|
||||
if (!pet) {
|
||||
return res.status(400).json({ error: "Pet profile is required for analysis." });
|
||||
}
|
||||
|
||||
// Base ML prediction
|
||||
const baseResult = defaultMLProvider.combineSignals({
|
||||
pet,
|
||||
timeOfDay: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
selectedZoneId,
|
||||
audio,
|
||||
video,
|
||||
customNote
|
||||
});
|
||||
|
||||
// If GEMINI_API_KEY is configured, enrich interpretation reasoning with Gemini
|
||||
if (process.env.GEMINI_API_KEY) {
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
|
||||
const zoneObj = pet.homeZones.find(z => z.id === selectedZoneId) || pet.homeZones[0];
|
||||
const zoneName = zoneObj ? zoneObj.name : "Home Area";
|
||||
|
||||
const prompt = `You are a certified veterinary behaviorist and pet communication expert assisting a pet owner on PetTalk.
|
||||
Pet Name: ${pet.name}
|
||||
Species: ${pet.species}
|
||||
Breed: ${pet.breed}
|
||||
Age: ${pet.ageYears} years old
|
||||
Personality: ${pet.personality.join(", ")}
|
||||
Feeding Schedule: ${pet.feedingSchedule}
|
||||
Current Home Zone: ${zoneName}
|
||||
Acoustic Trait Detected: ${audio?.pitchVariation || 'rising pitch'} sound (${audio?.averageVolume ? Math.round(audio.averageVolume * 100) : 50}% volume)
|
||||
Primary Predicted Intent: ${baseResult.primaryIntent.title} (${baseResult.primaryIntent.confidence}% confidence)
|
||||
|
||||
Write a short, empathetic, scientifically grounded "Why" reasoning breakdown (maximum 3 sentences).
|
||||
Follow this STRICT honesty rule:
|
||||
- Frame everything as a probabilistic likelihood based on sound, body language, routine, location, and owner feedback.
|
||||
- NEVER claim pets speak English or that this is a literal translation.
|
||||
- Maintain a warm, modern, intelligent tone.
|
||||
- Do NOT make medical diagnoses or claims.`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: prompt
|
||||
});
|
||||
|
||||
if (response.text) {
|
||||
baseResult.reasoning.summary = response.text.trim();
|
||||
}
|
||||
} catch (geminiErr) {
|
||||
console.warn("Gemini API enrichment skipped/failed, using ML provider output:", geminiErr);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: true, result: baseResult });
|
||||
} catch (err: any) {
|
||||
console.error("Error in /api/analyze:", err);
|
||||
return res.status(500).json({ error: "Failed to analyze pet signal", details: err?.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Train Pet Profile Feedback API endpoint
|
||||
*/
|
||||
app.post("/api/train", (req, res) => {
|
||||
try {
|
||||
const { pet, feedback, correctedCategory } = req.body as {
|
||||
pet: Pet;
|
||||
feedback: 'confirmed' | 'corrected';
|
||||
correctedCategory?: string;
|
||||
};
|
||||
|
||||
if (!pet || !feedback) {
|
||||
return res.status(400).json({ error: "Pet and feedback are required." });
|
||||
}
|
||||
|
||||
const updatedPet = defaultMLProvider.trainPetProfile(pet, feedback, correctedCategory);
|
||||
return res.json({ success: true, pet: updatedPet });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: "Failed to process training feedback" });
|
||||
}
|
||||
});
|
||||
|
||||
// Vite development vs production setup
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
const distPath = path.join(process.cwd(), "dist");
|
||||
app.use(express.static(distPath));
|
||||
app.get("*", (_req, res) => {
|
||||
res.sendFile(path.join(distPath, "index.html"));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`PetTalk Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer();
|
||||
Reference in New Issue
Block a user