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