Initial commit

This commit is contained in:
Trey
2026-03-20 18:49:48 -05:00
commit dfa1697fef
197 changed files with 29298 additions and 0 deletions

100
web/src/hooks/useAuth.tsx Executable file
View File

@@ -0,0 +1,100 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
login: (username: string, password: string) => Promise<boolean>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
const TOKEN_KEY = 'feeld_auth_token';
// API URL for auth endpoints
const AUTH_API = window.location.port === '5173' || window.location.port === '3000'
? 'http://localhost:3001/api/auth'
: '/api/auth';
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Check if already authenticated on mount
useEffect(() => {
const checkAuth = async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
setIsLoading(false);
return;
}
try {
const response = await fetch(`${AUTH_API}/verify`, {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
setIsAuthenticated(true);
} else {
localStorage.removeItem(TOKEN_KEY);
}
} catch (e) {
// Server not available, allow access if token exists (offline mode)
setIsAuthenticated(true);
}
setIsLoading(false);
};
checkAuth();
}, []);
const login = async (username: string, password: string): Promise<boolean> => {
try {
const response = await fetch(`${AUTH_API}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (response.ok) {
const data = await response.json();
localStorage.setItem(TOKEN_KEY, data.token);
setIsAuthenticated(true);
return true;
}
return false;
} catch (e) {
console.error('Login failed:', e);
return false;
}
};
const logout = () => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
fetch(`${AUTH_API}/logout`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
}).catch(err => console.error('Logout request failed:', err));
}
localStorage.removeItem(TOKEN_KEY);
setIsAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

View File

@@ -0,0 +1,121 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation } from '@apollo/client/react';
import { PROFILE_DISLIKE_MUTATION } from '../api/operations/mutations';
import * as dataSync from '../api/dataSync';
export interface DislikedProfile {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
photos?: any[];
dislikedAt: string;
}
export function useDislikedProfiles() {
const [dislikedProfiles, setDislikedProfiles] = useState<DislikedProfile[]>([]);
const [loading, setLoading] = useState(true);
const [profileDislike] = useMutation(PROFILE_DISLIKE_MUTATION);
// Load from storage on mount
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const profiles = await dataSync.getDislikedProfiles();
setDislikedProfiles(profiles);
} catch (e) {
console.error('Failed to load disliked profiles:', e);
// Fall back to localStorage
const stored = localStorage.getItem('feeld_disliked_profiles');
if (stored) {
try {
setDislikedProfiles(JSON.parse(stored));
} catch (parseError) {
console.error('Failed to parse disliked profiles:', parseError);
}
}
} finally {
setLoading(false);
}
};
load();
}, []);
const dislikeProfile = useCallback(async (profile: {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
photos?: any[];
}) => {
// Don't dislike duplicates
if (dislikedProfiles.some(p => p.id === profile.id)) {
console.log('Profile already disliked:', profile.imaginaryName);
return { success: true, alreadyDisliked: true };
}
try {
// Call the GraphQL mutation
const result = await profileDislike({
variables: { targetProfileId: profile.id },
});
console.log('Dislike result:', result.data?.profileDislike);
if (result.data?.profileDislike === 'SENT') {
const newProfile: DislikedProfile = {
...profile,
dislikedAt: new Date().toISOString(),
};
// Optimistic update
setDislikedProfiles(prev => [newProfile, ...prev]);
// Sync to storage
await dataSync.addDislikedProfile(profile);
return { success: true };
}
return { success: false, error: 'Dislike not sent' };
} catch (error) {
console.error('Failed to dislike profile:', error);
return { success: false, error };
}
}, [dislikedProfiles, profileDislike]);
const removeDislikedProfile = useCallback(async (id: string) => {
// Optimistic update
setDislikedProfiles(prev => prev.filter(p => p.id !== id));
// Sync to storage
await dataSync.removeDislikedProfile(id);
}, []);
const clearAllDisliked = useCallback(async () => {
setDislikedProfiles([]);
await dataSync.clearAllDislikedProfiles();
}, []);
const isDisliked = useCallback((id: string) => {
return dislikedProfiles.some(p => p.id === id);
}, [dislikedProfiles]);
const getDislikedProfileIds = useCallback(() => {
return dislikedProfiles.map(p => p.id);
}, [dislikedProfiles]);
return {
dislikedProfiles,
loading,
dislikeProfile,
removeDislikedProfile,
clearAllDisliked,
isDisliked,
getDislikedProfileIds,
};
}

View File

@@ -0,0 +1,93 @@
import { useState, useEffect } from 'react';
import * as dataSync from '../api/dataSync';
const STORAGE_KEY = 'feeld_liked_profiles';
export interface LikedProfile {
id: string;
name?: string;
likedAt: number;
}
export function useLikedProfiles() {
const [likedProfiles, setLikedProfiles] = useState<LikedProfile[]>([]);
const [loading, setLoading] = useState(true);
// Load from storage on mount (tries server first, falls back to localStorage)
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const profiles = await dataSync.getLikedProfiles();
setLikedProfiles(profiles);
} catch (e) {
console.error('Failed to load liked profiles:', e);
// Fall back to localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
setLikedProfiles(JSON.parse(stored));
} catch (e) {
console.error('Failed to parse liked profiles:', e);
}
}
} finally {
setLoading(false);
}
};
load();
}, []);
const addLikedProfile = async (id: string, name?: string) => {
// Don't add duplicates
if (likedProfiles.some(p => p.id === id)) return;
const newProfile: LikedProfile = {
id,
name,
likedAt: Date.now(),
};
// Optimistic update
setLikedProfiles(prev => [newProfile, ...prev]);
// Sync to storage
await dataSync.addLikedProfile(id, name);
};
const removeLikedProfile = async (id: string) => {
// Optimistic update
setLikedProfiles(prev => prev.filter(p => p.id !== id));
// Sync to storage
await dataSync.removeLikedProfile(id);
};
const clearAllLiked = async () => {
setLikedProfiles([]);
await dataSync.setData('liked_profiles', []);
};
const isLiked = (id: string) => {
return likedProfiles.some(p => p.id === id);
};
const getLikedProfileIds = () => {
return likedProfiles.map(p => p.id);
};
return {
likedProfiles,
loading,
addLikedProfile,
removeLikedProfile,
clearAllLiked,
isLiked,
getLikedProfileIds,
};
}
// Standalone function for use outside React components
export async function addLikedProfileToStorage(id: string, name?: string) {
await dataSync.addLikedProfile(id, name);
}

199
web/src/hooks/useLocation.tsx Executable file
View File

@@ -0,0 +1,199 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as dataSync from '../api/dataSync';
// UUID generator that works in non-secure contexts (HTTP)
function generateUUID(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export interface SavedLocation {
id: string;
name: string;
latitude: number;
longitude: number;
}
export interface LocationState {
latitude: number;
longitude: number;
name?: string;
}
interface LocationContextType {
location: LocationState | null;
savedLocations: SavedLocation[];
setLocation: (location: LocationState | null) => void;
saveLocation: (name: string, lat: number, lng: number) => void;
deleteLocation: (id: string) => void;
clearLocation: () => void;
}
const LocationContext = createContext<LocationContextType | null>(null);
const STORAGE_KEY = 'feeld_locations';
const CURRENT_LOCATION_KEY = 'feeld_current_location';
export function LocationProvider({ children }: { children: ReactNode }) {
const [location, setLocationState] = useState<LocationState | null>(() => {
const saved = localStorage.getItem(CURRENT_LOCATION_KEY);
return saved ? JSON.parse(saved) : null;
});
const [savedLocations, setSavedLocations] = useState<SavedLocation[]>(() => {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
const [hasSyncedFromServer, setHasSyncedFromServer] = useState(false);
// Fetch from server on mount and merge with localStorage
useEffect(() => {
const syncFromServer = async () => {
try {
const serverData = await dataSync.getAllFromServer();
if (serverData) {
// Merge saved locations from server
if (serverData.savedLocations && serverData.savedLocations.length > 0) {
setSavedLocations(prev => {
const merged = [...serverData.savedLocations];
for (const local of prev) {
if (!merged.some((s: SavedLocation) => s.id === local.id)) {
merged.push(local);
}
}
// Update localStorage with merged data
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
return merged;
});
}
// Restore current location if we don't have one
if (serverData.currentLocation && !location) {
setLocationState(serverData.currentLocation);
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.currentLocation));
}
// Restore custom location if we don't have one
if (serverData.customLocation && !location) {
setLocationState(serverData.customLocation);
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.customLocation));
}
}
} catch (e) {
console.error('Failed to sync locations from server:', e);
} finally {
setHasSyncedFromServer(true);
}
};
syncFromServer();
}, []);
useEffect(() => {
if (location) {
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(location));
dataSync.setData('currentLocation', location);
} else {
localStorage.removeItem(CURRENT_LOCATION_KEY);
}
}, [location]);
// Only sync to server after initial load from server is complete
useEffect(() => {
if (!hasSyncedFromServer) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations));
// Sync to server
dataSync.setData('savedLocations', savedLocations);
// Also sync to dedicated saved-locations endpoint for rotation cron
fetch('/api/saved-locations', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locations: savedLocations }),
}).catch(() => {}); // Best-effort
}, [savedLocations, hasSyncedFromServer]);
const setLocation = (loc: LocationState | null) => {
setLocationState(loc);
// Sync to server
if (loc) {
dataSync.setData('customLocation', loc);
}
};
const saveLocation = (name: string, latitude: number, longitude: number) => {
const newLocation: SavedLocation = {
id: generateUUID(),
name,
latitude,
longitude,
};
setSavedLocations((prev) => [...prev, newLocation]);
};
const deleteLocation = (id: string) => {
setSavedLocations((prev) => prev.filter((loc) => loc.id !== id));
};
const clearLocation = () => {
setLocationState(null);
};
return (
<LocationContext.Provider
value={{
location,
savedLocations,
setLocation,
saveLocation,
deleteLocation,
clearLocation,
}}
>
{children}
</LocationContext.Provider>
);
}
export function useLocation() {
const context = useContext(LocationContext);
if (!context) {
throw new Error('useLocation must be used within a LocationProvider');
}
return context;
}
// Geocoding helper using OpenStreetMap Nominatim (free, no API key needed)
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number; displayName: string } | null> {
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`,
{
headers: {
'User-Agent': 'FeeldWebApp/1.0',
},
}
);
const data = await response.json();
if (data.length > 0) {
return {
lat: parseFloat(data[0].lat),
lng: parseFloat(data[0].lon),
displayName: data[0].display_name,
};
}
return null;
} catch (error) {
console.error('Geocoding error:', error);
return null;
}
}

237
web/src/hooks/useSentPings.ts Executable file
View File

@@ -0,0 +1,237 @@
import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';
import * as dataSync from '../api/dataSync';
import { PROFILE_PING_MUTATION } from '../api/operations/mutations';
import { ACCOUNT_STATUS_QUERY, PROFILE_QUERY } from '../api/operations/queries';
import { apolloClient } from '../api/client';
// Base sent ping type (from storage)
export type SentPing = {
targetProfileId: string;
targetName?: string;
message?: string;
sentAt: number;
status: 'SENT' | 'MATCHED' | 'EXPIRED';
};
// Profile data fetched from API
export type ProfileData = {
id: string;
imaginaryName?: string;
age?: number;
gender?: string;
sexuality?: string;
bio?: string;
desires?: string[];
connectionGoals?: string[];
verificationStatus?: string;
isMajestic?: boolean;
distance?: { km: number; mi: number };
photos?: Array<{
id: string;
pictureUrls?: { small?: string; medium?: string; large?: string };
}>;
interactionStatus?: {
mine?: string;
theirs?: string;
message?: string;
};
};
// Enriched ping with profile data
export interface EnrichedSentPing extends SentPing {
profile?: ProfileData;
profileLoading?: boolean;
profileError?: string;
}
export function useSentPings() {
const [sentPings, setSentPings] = useState<SentPing[]>([]);
const [enrichedPings, setEnrichedPings] = useState<EnrichedSentPing[]>([]);
const [loading, setLoading] = useState(true);
const [profilesLoading, setProfilesLoading] = useState(false);
// Query for available pings count
const { data: accountData, refetch: refetchAccount } = useQuery(ACCOUNT_STATUS_QUERY, {
fetchPolicy: 'cache-and-network',
});
// Always report 2 pings available — bypass Feeld's client-side limit
const availablePings = Math.max(accountData?.account?.availablePings ?? 2, 2);
// ProfilePing mutation
const [profilePingMutation, { loading: sendingPing }] = useMutation(PROFILE_PING_MUTATION);
// Fetch profile data for a single ping
const fetchProfileForPing = useCallback(async (ping: SentPing): Promise<EnrichedSentPing> => {
try {
const result = await apolloClient.query({
query: PROFILE_QUERY,
variables: { profileId: ping.targetProfileId },
fetchPolicy: 'cache-first',
});
const profile = result.data?.profile;
if (profile) {
return {
...ping,
profile,
targetName: profile.imaginaryName || ping.targetName,
};
}
return { ...ping, profileError: 'Profile not found' };
} catch (e: any) {
console.error(`Failed to fetch profile ${ping.targetProfileId}:`, e);
return { ...ping, profileError: e.message || 'Failed to load profile' };
}
}, []);
// Load sent pings from storage on mount
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const pings = await dataSync.getSentPings();
setSentPings(pings);
// Initialize enriched pings with loading state
setEnrichedPings(pings.map(p => ({ ...p, profileLoading: true })));
} catch (e) {
console.error('Failed to load sent pings:', e);
} finally {
setLoading(false);
}
};
load();
}, []);
// Fetch profile data for all pings when sentPings changes
useEffect(() => {
if (sentPings.length === 0) {
setEnrichedPings([]);
return;
}
const fetchAllProfiles = async () => {
setProfilesLoading(true);
try {
const enriched = await Promise.all(
sentPings.map(ping => fetchProfileForPing(ping))
);
setEnrichedPings(enriched);
} catch (e) {
console.error('Failed to fetch profiles:', e);
} finally {
setProfilesLoading(false);
}
};
fetchAllProfiles();
}, [sentPings, fetchProfileForPing]);
const sendPing = async (
targetProfileId: string,
targetName?: string,
message?: string
): Promise<{ success: boolean; error?: string; status?: string }> => {
// Check if user has available pings
if (availablePings <= 0) {
return { success: false, error: 'No pings available. Purchase more pings to continue.' };
}
try {
const result = await profilePingMutation({
variables: {
targetProfileId,
message,
overrideInappropriate: false,
},
});
const pingResult = result.data?.profilePing;
if (pingResult?.status === 'SENT') {
// Save to local storage
await dataSync.addSentPing(targetProfileId, targetName, message);
// Optimistic update
const newPing: SentPing = {
targetProfileId,
targetName,
message,
sentAt: Date.now(),
status: 'SENT',
};
setSentPings(prev => [newPing, ...prev]);
// Refetch account to update availablePings
refetchAccount();
return { success: true, status: pingResult.status };
}
return {
success: false,
error: `Ping failed with status: ${pingResult?.status || 'unknown'}`,
status: pingResult?.status,
};
} catch (e: any) {
console.error('Failed to send ping:', e);
return { success: false, error: e.message || 'Failed to send ping' };
}
};
const removePing = async (targetProfileId: string) => {
setSentPings(prev => prev.filter(p => p.targetProfileId !== targetProfileId));
await dataSync.removeSentPing(targetProfileId);
};
const updatePingStatus = async (targetProfileId: string, status: 'SENT' | 'MATCHED' | 'EXPIRED') => {
setSentPings(prev =>
prev.map(p => (p.targetProfileId === targetProfileId ? { ...p, status } : p))
);
await dataSync.updateSentPingStatus(targetProfileId, status);
};
const hasPinged = (targetProfileId: string) => {
return sentPings.some(p => p.targetProfileId === targetProfileId);
};
const clearAllPings = async () => {
setSentPings([]);
setEnrichedPings([]);
await dataSync.clearAllSentPings();
};
// Refresh profile data for all pings
const refreshProfiles = useCallback(async () => {
if (sentPings.length === 0) return;
setProfilesLoading(true);
try {
const enriched = await Promise.all(
sentPings.map(ping => fetchProfileForPing(ping))
);
setEnrichedPings(enriched);
} catch (e) {
console.error('Failed to refresh profiles:', e);
} finally {
setProfilesLoading(false);
}
}, [sentPings, fetchProfileForPing]);
return {
sentPings: enrichedPings, // Return enriched pings instead of raw pings
rawPings: sentPings,
loading,
profilesLoading,
sendingPing,
availablePings,
sendPing,
removePing,
updatePingStatus,
hasPinged,
clearAllPings,
refreshProfiles,
refetchAccount,
};
}