This commit is contained in:
Jamie Brunton committed 2025-12-31 15:51:12 +00:00
1 parent d18f9d32b0
commit f7eaf0adcf
24 files changed
+1826 -393

No files matched your search

@@ -0,0 +1,32 @@
import type { SubscriptionStatus } from '@/hooks/useRealtimeSubscription';
interface RealtimeStatusIndicatorProps {
status: SubscriptionStatus;
showLabel?: boolean;
}
const statusConfig: Record<SubscriptionStatus, { color: string; pulseColor: string; label: string }> = {
SUBSCRIBED: { color: 'bg-green-500', pulseColor: 'bg-green-400', label: 'Live' },
SUBSCRIBING: { color: 'bg-yellow-500', pulseColor: 'bg-yellow-400', label: 'Connecting...' },
CHANNEL_ERROR: { color: 'bg-red-500', pulseColor: 'bg-red-400', label: 'Error' },
TIMED_OUT: { color: 'bg-orange-500', pulseColor: 'bg-orange-400', label: 'Timed out' },
CLOSED: { color: 'bg-gray-500', pulseColor: 'bg-gray-400', label: 'Disconnected' },
};
export function RealtimeStatusIndicator({ status, showLabel = false }: RealtimeStatusIndicatorProps) {
const config = statusConfig[status];
return (
<div className="flex items-center gap-2">
<span className="relative flex h-2.5 w-2.5">
{status === 'SUBSCRIBED' && (
<span
className={`absolute inline-flex h-full w-full animate-ping rounded-full ${config.pulseColor} opacity-75`}
/>
)}
<span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${config.color}`} />
</span>
{showLabel && <span className="text-xs text-muted-foreground">{config.label}</span>}
</div>
);
}
@@ -0,0 +1,281 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
import { supabase } from '@/config';
import { toast } from 'sonner';
interface ExtractTopicsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
pack: {
id: string;
name: string;
description: string | null;
};
onUpdated: () => void;
}
export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: ExtractTopicsDialogProps) {
const [analyzing, setAnalyzing] = useState(false);
const [topics, setTopics] = useState<ExtractedTopic[]>([]);
const [reasoning, setReasoning] = useState('');
const [selectedTopics, setSelectedTopics] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false);
const handleAnalyze = async () => {
setAnalyzing(true);
try {
// Fetch questions for this pack
const { data: questions, error: questionsError } = await supabase
.from('questions')
.select('text')
.eq('pack_id', pack.id);
if (questionsError) throw questionsError;
if (!questions || questions.length === 0) {
toast.error('This pack has no questions to analyze');
return;
}
// Fetch existing topics
const { data: existingTopics, error: topicsError } = await supabase
.from('topics')
.select('id, name')
.order('name');
if (topicsError) throw topicsError;
const result = await extractTopicsFromPack(
pack.name,
pack.description,
questions.map(q => q.text),
existingTopics || []
);
setTopics(result.topics);
setReasoning(result.reasoning);
// Default select all
setSelectedTopics(new Set(result.topics.map(t => t.name)));
setStep('review');
} catch (error) {
console.error(error);
toast.error('Failed to analyze pack');
} finally {
setAnalyzing(false);
}
};
const handleApply = async () => {
setApplying(true);
try {
const selectedList = topics.filter(t => selectedTopics.has(t.name));
if (selectedList.length === 0) {
onOpenChange(false);
return;
}
// First, create any new topics
const newTopics = selectedList.filter(t => t.isNew);
const existingTopicIds: string[] = selectedList
.filter(t => !t.isNew && t.existingTopicId)
.map(t => t.existingTopicId!);
if (newTopics.length > 0) {
const { data: createdTopics, error: createError } = await supabase
.from('topics')
.insert(newTopics.map(t => ({ name: t.name })))
.select('id');
if (createError) throw createError;
if (createdTopics) {
existingTopicIds.push(...createdTopics.map(t => t.id));
}
}
// Remove existing pack_topics for this pack
await supabase
.from('pack_topics')
.delete()
.eq('pack_id', pack.id);
// Insert new pack_topics relationships
if (existingTopicIds.length > 0) {
const { error: linkError } = await supabase
.from('pack_topics')
.insert(existingTopicIds.map(topicId => ({
pack_id: pack.id,
topic_id: topicId,
})));
if (linkError) throw linkError;
}
toast.success(`Added ${selectedList.length} topic${selectedList.length !== 1 ? 's' : ''} to pack`);
onUpdated();
handleClose();
} catch (error) {
console.error(error);
toast.error('Failed to apply topics');
} finally {
setApplying(false);
}
};
const handleClose = () => {
onOpenChange(false);
// Reset state after transition
setTimeout(() => {
setStep('initial');
setTopics([]);
setReasoning('');
setSelectedTopics(new Set());
}, 300);
};
const toggleSelection = (name: string) => {
const next = new Set(selectedTopics);
if (next.has(name)) next.delete(name);
else next.add(name);
setSelectedTopics(next);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Tags className="h-5 w-5 text-blue-500" />
Extract Topics
</DialogTitle>
<DialogDescription>
Use AI to identify topics and kinks for "{pack.name}"
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-blue-500/20 p-4 rounded-full">
<Tags className="h-12 w-12 text-blue-400" />
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Analyze pack for topics</h3>
<p className="text-muted-foreground max-w-md mx-auto">
The AI will analyze the questions in this pack and suggest relevant topics
like bondage, exhibitionism, romance, etc. It will prefer matching existing
topics when possible.
</p>
</div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-blue-600 hover:bg-blue-700">
{analyzing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Analyzing...
</>
) : (
'Start Analysis'
)}
</Button>
</div>
) : (
<div className="space-y-6">
{topics.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<X className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
No topics could be identified for this pack.
</div>
) : (
<>
<div className="space-y-3">
<h4 className="text-sm font-medium">Suggested Topics</h4>
<div className="flex flex-wrap gap-2">
{topics.map((topic) => (
<Badge
key={topic.name}
variant={selectedTopics.has(topic.name) ? 'default' : 'outline'}
className={`cursor-pointer text-sm py-1.5 px-3 ${
selectedTopics.has(topic.name)
? topic.isNew
? 'bg-green-600 hover:bg-green-700'
: 'bg-blue-600 hover:bg-blue-700'
: 'hover:bg-muted'
}`}
onClick={() => toggleSelection(topic.name)}
>
{selectedTopics.has(topic.name) ? (
<Check className="h-3 w-3 mr-1" />
) : null}
{topic.name}
{topic.isNew && (
<Plus className="h-3 w-3 ml-1" />
)}
</Badge>
))}
</div>
<div className="flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-blue-600" />
Existing topic
</span>
<span className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-green-600" />
<Plus className="h-3 w-3" />
New topic
</span>
</div>
</div>
{reasoning && (
<div className="space-y-2">
<h4 className="text-sm font-medium">AI Reasoning</h4>
<p className="text-sm text-muted-foreground bg-muted/50 p-3 rounded-md">
{reasoning}
</p>
</div>
)}
</>
)}
</div>
)}
</div>
<DialogFooter>
{step === 'review' && (
<>
<Button variant="outline" onClick={() => setStep('initial')}>
Back
</Button>
<Button
onClick={handleApply}
disabled={applying || topics.length === 0 || selectedTopics.size === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{applying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Applying...
</>
) : (
`Apply ${selectedTopics.size} Topic${selectedTopics.size !== 1 ? 's' : ''}`
)}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -170,8 +170,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? ( {step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4"> <div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-indigo-50 p-4 rounded-full"> <div className="bg-indigo-500/20 p-4 rounded-full">
<ShieldCheck className="h-12 w-12 text-indigo-500" /> <ShieldCheck className="h-12 w-12 text-indigo-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3> <h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
@@ -247,18 +247,18 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
{formatTargets(question.allowed_couple_genders)} {formatTargets(question.allowed_couple_genders)}
</Badge> </Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" /> <ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100"> <Badge variant="secondary" className="bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
{formatTargets(suggestion.suggested_targets)} {formatTargets(suggestion.suggested_targets)}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs"> <TableCell className="text-xs">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Badge variant="outline" className="text-muted-foreground border-orange-300"> <Badge variant="outline" className="text-muted-foreground border-orange-500/50">
{formatInitiator(question.target_user_genders)} {formatInitiator(question.target_user_genders)}
</Badge> </Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" /> <ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge variant="secondary" className="bg-orange-100 text-orange-700 hover:bg-orange-100"> <Badge variant="secondary" className="bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
{formatInitiator(suggestion.suggested_initiator)} {formatInitiator(suggestion.suggested_initiator)}
</Badge> </Badge>
</div> </div>
@@ -113,8 +113,8 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? ( {step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-4"> <div className="flex flex-col items-center justify-center py-12 text-center space-y-4">
<div className="bg-purple-50 p-4 rounded-full"> <div className="bg-purple-500/20 p-4 rounded-full">
<Wand2 className="h-12 w-12 text-purple-500" /> <Wand2 className="h-12 w-12 text-purple-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3> <h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
@@ -189,9 +189,9 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
<ArrowRight className="h-4 w-4 text-muted-foreground" /> <ArrowRight className="h-4 w-4 text-muted-foreground" />
</TableCell> </TableCell>
<TableCell className="max-w-xs"> <TableCell className="max-w-xs">
<div className="text-sm font-medium text-purple-700">{suggestion.suggested_text}</div> <div className="text-sm font-medium text-purple-300">{suggestion.suggested_text}</div>
{suggestion.suggested_partner_text && ( {suggestion.suggested_partner_text && (
<div className="text-xs text-purple-600 mt-1 italic"> <div className="text-xs text-purple-400 mt-1 italic">
Partner: {suggestion.suggested_partner_text} Partner: {suggestion.suggested_partner_text}
</div> </div>
)} )}
@@ -252,8 +252,8 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
<div className="flex-1 overflow-y-auto py-4"> <div className="flex-1 overflow-y-auto py-4">
{step === 'initial' ? ( {step === 'initial' ? (
<div className="flex flex-col items-center justify-center py-12 text-center space-y-6"> <div className="flex flex-col items-center justify-center py-12 text-center space-y-6">
<div className="bg-amber-50 p-4 rounded-full"> <div className="bg-amber-500/20 p-4 rounded-full">
<Sparkles className="h-12 w-12 text-amber-500" /> <Sparkles className="h-12 w-12 text-amber-400" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3> <h3 className="text-lg font-medium">Ready to analyze {questions.length} questions</h3>
@@ -262,13 +262,13 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
</p> </p>
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex items-center gap-2 bg-purple-50 px-4 py-2 rounded-lg"> <div className="flex items-center gap-2 bg-purple-500/20 px-4 py-2 rounded-lg">
<Wand2 className="h-5 w-5 text-purple-500" /> <Wand2 className="h-5 w-5 text-purple-400" />
<span className="text-sm font-medium">Text Phrasing</span> <span className="text-sm font-medium text-purple-300">Text Phrasing</span>
</div> </div>
<div className="flex items-center gap-2 bg-indigo-50 px-4 py-2 rounded-lg"> <div className="flex items-center gap-2 bg-indigo-500/20 px-4 py-2 rounded-lg">
<ShieldCheck className="h-5 w-5 text-indigo-500" /> <ShieldCheck className="h-5 w-5 text-indigo-400" />
<span className="text-sm font-medium">Audience Targeting</span> <span className="text-sm font-medium text-indigo-300">Audience Targeting</span>
</div> </div>
</div> </div>
<Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700"> <Button onClick={handleAnalyze} disabled={analyzing} size="lg" className="bg-amber-600 hover:bg-amber-700">
@@ -366,7 +366,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
{suggestion.textSuggestion ? ( {suggestion.textSuggestion ? (
<div <div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId) className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTextIds.has(suggestion.questionId)
? 'bg-purple-50 border-purple-300' ? 'bg-purple-500/20 border-purple-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50' : 'bg-muted/30 border-transparent hover:bg-muted/50'
}`} }`}
onClick={() => toggleTextSelection(suggestion.questionId)} onClick={() => toggleTextSelection(suggestion.questionId)}
@@ -378,11 +378,11 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
className="mt-0.5" className="mt-0.5"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-purple-700 font-medium"> <div className="text-sm text-purple-300 font-medium">
{suggestion.textSuggestion.suggested_text} {suggestion.textSuggestion.suggested_text}
</div> </div>
{suggestion.textSuggestion.suggested_partner_text && ( {suggestion.textSuggestion.suggested_partner_text && (
<div className="text-xs text-purple-600 mt-1 italic"> <div className="text-xs text-purple-400 mt-1 italic">
Partner: {suggestion.textSuggestion.suggested_partner_text} Partner: {suggestion.textSuggestion.suggested_partner_text}
</div> </div>
)} )}
@@ -405,7 +405,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
{suggestion.targetSuggestion ? ( {suggestion.targetSuggestion ? (
<div <div
className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId) className={`p-2 rounded-lg border cursor-pointer transition-colors ${selectedTargetIds.has(suggestion.questionId)
? 'bg-indigo-50 border-indigo-300' ? 'bg-indigo-500/20 border-indigo-500/50'
: 'bg-muted/30 border-transparent hover:bg-muted/50' : 'bg-muted/30 border-transparent hover:bg-muted/50'
}`} }`}
onClick={() => toggleTargetSelection(suggestion.questionId)} onClick={() => toggleTargetSelection(suggestion.questionId)}
@@ -423,17 +423,17 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, isExplici
{formatTargets(suggestion.question.allowed_couple_genders)} {formatTargets(suggestion.question.allowed_couple_genders)}
</Badge> </Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" /> <ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-indigo-100 text-indigo-700 hover:bg-indigo-100"> <Badge className="text-[10px] bg-indigo-500/30 text-indigo-300 hover:bg-indigo-500/30">
{formatTargets(suggestion.targetSuggestion.suggested_targets)} {formatTargets(suggestion.targetSuggestion.suggested_targets)}
</Badge> </Badge>
</div> </div>
{/* Initiator */} {/* Initiator */}
<div className="flex items-center gap-1 flex-wrap"> <div className="flex items-center gap-1 flex-wrap">
<Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-300"> <Badge variant="outline" className="text-[10px] text-muted-foreground border-orange-500/50">
{formatInitiator(suggestion.question.target_user_genders)} {formatInitiator(suggestion.question.target_user_genders)}
</Badge> </Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" /> <ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge className="text-[10px] bg-orange-100 text-orange-700 hover:bg-orange-100"> <Badge className="text-[10px] bg-orange-500/30 text-orange-300 hover:bg-orange-500/30">
{formatInitiator(suggestion.targetSuggestion.suggested_initiator)} {formatInitiator(suggestion.targetSuggestion.suggested_initiator)}
</Badge> </Badge>
</div> </div>
@@ -0,0 +1,243 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { supabase } from '@/config';
import { toast } from 'sonner';
import type { RealtimeChannel } from '@supabase/supabase-js';
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
export interface UseRealtimeSubscriptionOptions<T> {
table: string;
schema?: string;
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
filter?: string;
onInsert?: (payload: T) => void;
onUpdate?: (payload: { old: T; new: T }) => void;
onDelete?: (payload: T) => void;
insertToast?: {
enabled: boolean;
message: string | ((payload: T) => string);
type?: 'success' | 'info' | 'warning';
};
updateToast?: {
enabled: boolean;
message: string | ((payload: { old: T; new: T }) => string);
type?: 'success' | 'info' | 'warning';
};
deleteToast?: {
enabled: boolean;
message: string | ((payload: T) => string);
type?: 'success' | 'info' | 'warning';
};
debounceMs?: number;
enabled?: boolean;
}
export interface UseRealtimeSubscriptionReturn {
status: SubscriptionStatus;
error: Error | null;
}
const MAX_RETRIES = 3;
export function useRealtimeSubscription<T>(
options: UseRealtimeSubscriptionOptions<T>
): UseRealtimeSubscriptionReturn {
const {
table,
schema = 'public',
event = '*',
filter,
onInsert,
onUpdate,
onDelete,
insertToast,
updateToast,
deleteToast,
debounceMs = 0,
enabled = true,
} = options;
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
const [error, setError] = useState<Error | null>(null);
const channelRef = useRef<RealtimeChannel | null>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryCountRef = useRef(0);
const isMountedRef = useRef(true);
// Use refs for callbacks and toast options to avoid re-subscription on every render
const onInsertRef = useRef(onInsert);
const onUpdateRef = useRef(onUpdate);
const onDeleteRef = useRef(onDelete);
const insertToastRef = useRef(insertToast);
const updateToastRef = useRef(updateToast);
const deleteToastRef = useRef(deleteToast);
// Keep refs up to date
useEffect(() => {
onInsertRef.current = onInsert;
onUpdateRef.current = onUpdate;
onDeleteRef.current = onDelete;
insertToastRef.current = insertToast;
updateToastRef.current = updateToast;
deleteToastRef.current = deleteToast;
});
// Debounced callback wrapper
const debouncedCallback = useCallback(
(callback: () => void) => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (debounceMs > 0) {
debounceTimerRef.current = setTimeout(() => {
if (isMountedRef.current) {
callback();
}
}, debounceMs);
} else {
callback();
}
},
[debounceMs]
);
// Handle incoming changes - use refs to avoid dependency changes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleChange = useCallback(
(payload: { eventType: string; new: any; old: any }) => {
if (!isMountedRef.current) return;
const eventType = payload.eventType;
if (eventType === 'INSERT') {
const newRecord = payload.new as T;
if (insertToastRef.current?.enabled) {
const message =
typeof insertToastRef.current.message === 'function'
? insertToastRef.current.message(newRecord)
: insertToastRef.current.message;
toast[insertToastRef.current.type || 'info'](message);
}
if (onInsertRef.current) {
debouncedCallback(() => onInsertRef.current!(newRecord));
}
}
if (eventType === 'UPDATE') {
const updatePayload = { old: payload.old as T, new: payload.new as T };
if (updateToastRef.current?.enabled) {
const message =
typeof updateToastRef.current.message === 'function'
? updateToastRef.current.message(updatePayload)
: updateToastRef.current.message;
toast[updateToastRef.current.type || 'info'](message);
}
if (onUpdateRef.current) {
debouncedCallback(() => onUpdateRef.current!(updatePayload));
}
}
if (eventType === 'DELETE') {
const deletedRecord = payload.old as T;
if (deleteToastRef.current?.enabled) {
const message =
typeof deleteToastRef.current.message === 'function'
? deleteToastRef.current.message(deletedRecord)
: deleteToastRef.current.message;
toast[deleteToastRef.current.type || 'info'](message);
}
if (onDeleteRef.current) {
debouncedCallback(() => onDeleteRef.current!(deletedRecord));
}
}
},
[debouncedCallback]
);
useEffect(() => {
isMountedRef.current = true;
if (!enabled) {
setStatus('CLOSED');
return;
}
const subscribe = () => {
// Create unique channel name
const channelName = `admin-${table}-${filter || 'all'}-${Date.now()}`;
// Build subscription config
const subscriptionConfig: {
event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
schema: string;
table: string;
filter?: string;
} = {
event,
schema,
table,
};
if (filter) {
subscriptionConfig.filter = filter;
}
setStatus('SUBSCRIBING');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const channel = (supabase.channel(channelName) as any)
.on('postgres_changes', subscriptionConfig, handleChange)
.subscribe((subscriptionStatus: string) => {
if (!isMountedRef.current) return;
if (subscriptionStatus === 'SUBSCRIBED') {
setStatus('SUBSCRIBED');
setError(null);
retryCountRef.current = 0;
} else if (subscriptionStatus === 'CHANNEL_ERROR') {
setStatus('CHANNEL_ERROR');
setError(new Error(`Failed to subscribe to ${table}`));
// Auto-retry with exponential backoff
if (retryCountRef.current < MAX_RETRIES) {
const timeout = Math.pow(2, retryCountRef.current) * 1000;
retryCountRef.current++;
setTimeout(() => {
if (isMountedRef.current && channelRef.current) {
supabase.removeChannel(channelRef.current);
subscribe();
}
}, timeout);
}
} else if (subscriptionStatus === 'TIMED_OUT') {
setStatus('TIMED_OUT');
setError(new Error(`Subscription to ${table} timed out`));
} else if (subscriptionStatus === 'CLOSED') {
setStatus('CLOSED');
}
});
channelRef.current = channel;
};
subscribe();
return () => {
isMountedRef.current = false;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (channelRef.current) {
supabase.removeChannel(channelRef.current);
}
};
}, [enabled, table, schema, event, filter, handleChange]);
return { status, error };
}
+121 -15
View File
@@ -241,7 +241,7 @@ export async function generateQuestions(
2: 'TONE: Playful and flirty. Light teasing, fun activities, and cheeky suggestions. Mildly suggestive but NOTHING explicit or sexual. Think playful banter and innocent mischief.', 2: 'TONE: Playful and flirty. Light teasing, fun activities, and cheeky suggestions. Mildly suggestive but NOTHING explicit or sexual. Think playful banter and innocent mischief.',
3: 'TONE: Sensual and intimate. Passionate and suggestive content that implies intimacy without being graphic. Use tasteful language - "make love" rather than crude terms. Romantic but with heat. AVOID explicit sexual acts or crude terminology.', 3: 'TONE: Sensual and intimate. Passionate and suggestive content that implies intimacy without being graphic. Use tasteful language - "make love" rather than crude terms. Romantic but with heat. AVOID explicit sexual acts or crude terminology.',
4: 'TONE: Spicy and bold. Adventurous sexual content with direct, natural language. Use everyday terms like "oral sex", "finger", "go down on", "handjob", "turn on". AVOID clinical/medical terms like "stimulate", "arousal", "genitals", "penetration". Write like real people talk about sex.', 4: 'TONE: Spicy and bold. Adventurous sexual content with direct, natural language. Use everyday terms like "oral sex", "finger", "go down on", "handjob", "turn on". AVOID clinical/medical terms like "stimulate", "arousal", "genitals", "penetration". Write like real people talk about sex.',
5: 'TONE: Explicit and raw. NSFW content with blunt, dirty talk language. Use words like "fuck", "cock", "pussy", "cum", "suck", "eat out". NEVER use clinical terms like "intercourse", "stimulate", "genitalia", "fellatio", "cunnilingus". Write like uncensored sexting - direct, crude, and hot.', 5: 'TONE: Explicit adult content with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT use crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s what it\'s called), "Edge your partner until they beg to cum". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.',
}; };
const toneInstruction = toneInstructions[tone]; const toneInstruction = toneInstructions[tone];
@@ -302,6 +302,14 @@ CRITICAL LANGUAGE RULES:
- GOOD: "Send your partner a photo", "Spank your partner", "Give your partner..." - GOOD: "Send your partner a photo", "Spank your partner", "Give your partner..."
- The card reader is the DOER. Their partner is "your partner". - The card reader is the DOER. Their partner is "your partner".
ANATOMICAL CONSISTENCY RULE:
- NEVER combine male-specific and female-specific acts as alternatives in the same question.
- A partner has ONE set of anatomy - don't offer "or" alternatives that require different anatomy.
- BAD: "Finger or give your partner a handjob" (fingering = vagina, handjob = penis)
- BAD: "Suck their cock or eat them out" (cock = male, eating out = female)
- GOOD: "Give your partner a handjob" (single activity for target anatomy)
- GOOD: "Go down on your partner" (gender-neutral oral phrasing)
For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other): For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to the other):
1. SYMMETRIC Activities (partner_text = null): 1. SYMMETRIC Activities (partner_text = null):
@@ -310,11 +318,19 @@ For each question, decide if it is SYMMETRIC (shared) or ASYMMETRIC (one does to
- BAD: "Have you ever thought about...", "Do you think we should..." - BAD: "Have you ever thought about...", "Do you think we should..."
2. ASYMMETRIC Actions (requires partner_text): 2. ASYMMETRIC Actions (requires partner_text):
- One partner does something TO/FOR the other. - One partner does something TO/FOR the other, or roles differ.
- text = what the DOER does: "Give your partner X", "Do Y to your partner" - text = what the INITIATOR does (their action/perspective)
- partner_text = what the RECEIVER gets: "Receive X from your partner", "Have your partner do Y to you" - partner_text = what the RECEIVER does/experiences (their action/perspective)
- ${asymmetricExamples} - ${asymmetricExamples}
- BOTH texts must use "your partner" - never "me" or "I" - BOTH texts must use "your partner" - never "me" or "I"
- IMPORTANT: partner_text should describe the RECEIVER's experience clearly:
- text: "Send your partner to the bathroom and follow a minute later"
- partner_text: "Go to the bathroom and wait for your partner to join you" (clear receiver instructions)
- NOT: "Wait for your partner to follow you" (confusing/wrong)
- When initiator CAUSES a response (moan, cum, beg, etc.), partner_text should frame as ALLOWING/RECEIVING, not performing:
- text: "Make your partner moan in public" → partner_text: "Let your partner make you moan in public"
- text: "Edge your partner until they beg" → partner_text: "Let your partner edge you until you beg"
- NOT: "Moan for your partner in public" (sounds like deliberate performance, not natural response)
Required JSON structure: Required JSON structure:
- text: The proposal (DOER's perspective, uses "your partner") - text: The proposal (DOER's perspective, uses "your partner")
@@ -330,7 +346,7 @@ Mix symmetric and asymmetric proposals.`;
2: 'You are a playful content writer for a couples relationship app. Generate fun, flirty content that helps couples have fun together. Always respond with valid JSON only.', 2: 'You are a playful content writer for a couples relationship app. Generate fun, flirty content that helps couples have fun together. Always respond with valid JSON only.',
3: 'You are a sensual content writer for a couples relationship app. Generate passionate, intimate content that helps couples explore their desires tastefully. Always respond with valid JSON only.', 3: 'You are a sensual content writer for a couples relationship app. Generate passionate, intimate content that helps couples explore their desires tastefully. Always respond with valid JSON only.',
4: 'You are a bold content writer for an adult couples app. Write like a sex-positive friend giving suggestions - natural, direct, not clinical. Avoid medical terminology. Always respond with valid JSON only.', 4: 'You are a bold content writer for an adult couples app. Write like a sex-positive friend giving suggestions - natural, direct, not clinical. Avoid medical terminology. Always respond with valid JSON only.',
5: 'You are an uninhibited adult content writer. Write like dirty talk or sexting - raw, blunt, using everyday explicit words. Never use clinical terms like intercourse, stimulate, genitalia. Always respond with valid JSON only.', 5: 'You are an adult content writer for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Never use clinical terms. Always respond with valid JSON only.',
}; };
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
@@ -541,7 +557,7 @@ export async function polishContent(
const openai = getOpenAI(); const openai = getOpenAI();
const explicitInstruction = explicit const explicitInstruction = explicit
? 'Do not censor yourself. If the content is already explicit, keep it that way or enhance usage of bolder terms if appropriate.' ? 'Use nuanced language: tasteful phrasing for general acts ("have sex", "perform oral") but crude/specific terms when relevant to the activity ("cum on", "cock ring", "edge"). Do not over-sanitize - keep explicit terms that are essential to the activity. NEVER change "cum" to "come" - they have different meanings.'
: 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.'; : 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
const contextMap = { const contextMap = {
@@ -564,6 +580,10 @@ export async function polishContent(
- DO NOT change it to something unrelated like "Go to the movies". - DO NOT change it to something unrelated like "Go to the movies".
- Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL. - Polish the phrasing, grammar, and tone, but keep the core action IDENTICAL.
3. TONE: Engaging, romantic, or spicy (depending on context), but direct. 3. TONE: Engaging, romantic, or spicy (depending on context), but direct.
4. ANATOMICAL CONSISTENCY: NEVER combine male-specific and female-specific acts as alternatives.
- BAD: "Finger or give your partner a handjob" (requires different anatomy)
- BAD: "Suck their cock or eat them out" (male vs female anatomy)
- If text has mixed anatomy alternatives, keep ONE activity and remove the incompatible one.
`; `;
} }
@@ -630,19 +650,22 @@ INITIATOR OPTIONS: 'male', 'female', 'non-binary'
RULES FOR COUPLE TARGETS (allowed_couple_genders): RULES FOR COUPLE TARGETS (allowed_couple_genders):
- Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal). - Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
- Limit ONLY if there is a clear anatomical requirement (e.g. genitalia specific). - Limit ONLY if there is a clear, explicit anatomical requirement (e.g. genitalia-specific terms).
- SEX TOYS ARE GENDER-NEUTRAL: Vibrators, dildos, plugs, etc. can be used by ANYONE. Do NOT restrict based on toys.
- "Control your partner's vibrator" -> ALL couples, ANY initiator (vibrators work on any body)
- "Use a butt plug" -> ALL couples (everyone has a butt)
Examples: Examples of ACTUAL restrictions:
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male'] - "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female'] - "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
- "Vibrator", "dildo", "toy" -> NO restriction, works for everyone
RULES FOR INITIATOR (target_user_genders): RULES FOR INITIATOR (target_user_genders):
- ONLY relevant for TWO-PART questions (those with partner_text set). - ONLY relevant for TWO-PART questions (those with partner_text set).
- Analyze the "text" field to determine who should see this card FIRST as the initiator. - Analyze the "text" field to determine who should see this card FIRST as the initiator.
- Consider WHO the action is TYPICALLY intended for, not just who CAN physically perform it. - ONLY set initiator if there is an EXPLICIT anatomical requirement in the text.
- In M+F couples, consider the typical heterosexual context of sexual acts. - SEX TOYS DO NOT IMPLY GENDER: "vibrator", "dildo", "toy", "plug" -> initiator: null (anyone can use/control toys)
- If text implies a specific gender role, set initiator accordingly. - If text is gender-neutral, leave as null. When in doubt, use null.
- If text is truly gender-neutral AND role-neutral, leave as null.
Key considerations for initiator: Key considerations for initiator:
1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context) 1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
@@ -699,6 +722,76 @@ export interface TextAnalysis {
reason: string; reason: string;
} }
export interface ExtractedTopic {
name: string;
isNew: boolean;
existingTopicId?: string;
}
export interface TopicExtractionResult {
topics: ExtractedTopic[];
reasoning: string;
}
export async function extractTopicsFromPack(
packName: string,
packDescription: string | null,
questions: string[],
existingTopics: { id: string; name: string }[]
): Promise<TopicExtractionResult> {
const openai = getOpenAI();
const existingTopicNames = existingTopics.map(t => t.name);
const existingTopicsJson = JSON.stringify(existingTopics);
const prompt = `Analyze the following question pack and extract relevant topics/kinks/interests that describe its content.
PACK NAME: "${packName}"
${packDescription ? `PACK DESCRIPTION: "${packDescription}"` : ''}
QUESTIONS IN THIS PACK:
${questions.map((q, i) => `${i + 1}. ${q}`).join('\n')}
EXISTING TOPICS IN THE SYSTEM (prefer matching these when appropriate):
${existingTopicNames.length > 0 ? existingTopicNames.join(', ') : 'None yet'}
EXISTING TOPICS WITH IDs:
${existingTopicsJson}
INSTRUCTIONS:
1. Identify 1-5 topics that best describe this pack's content
2. Topics should be specific kinks, interests, or themes (e.g., "Bondage", "Exhibitionism", "Voyeurism", "Role Play", "Sensory Play", "Oral", "Anal", "Communication", "Romance", "Adventure")
3. ALWAYS prefer matching an existing topic if one fits - use the EXACT name and include the existingTopicId
4. Only suggest a new topic if nothing in the existing list is appropriate
5. Topic names should be title case (e.g., "Bondage" not "bondage")
6. Keep topics broad enough to be reusable across packs
Return a JSON object with:
- topics: Array of objects with:
- name: The topic name (use exact existing name if matching)
- isNew: false if matching an existing topic, true if suggesting a new one
- existingTopicId: The ID of the existing topic if isNew is false (from the EXISTING TOPICS WITH IDs list)
- reasoning: Brief explanation of why these topics were chosen`;
const response = await openai.chat.completions.create({
model: getModel('fix'),
messages: [
{
role: 'system',
content: 'You are a content categorization expert for a couples intimacy app. You analyze question packs and identify relevant topics, kinks, and themes. Always prefer matching existing topics when possible. Always respond with valid JSON only.',
},
{ role: 'user', content: prompt },
],
response_format: { type: 'json_object' },
temperature: 0.3,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
return JSON.parse(content) as TopicExtractionResult;
}
export async function analyzeQuestionText( export async function analyzeQuestionText(
questions: { id: string; text: string; partner_text?: string | null }[], questions: { id: string; text: string; partner_text?: string | null }[],
isExplicit: boolean = false isExplicit: boolean = false
@@ -712,7 +805,7 @@ export async function analyzeQuestionText(
})); }));
const toneInstruction = isExplicit const toneInstruction = isExplicit
? 'TONE: This is an EXPLICIT pack. Use raw, direct, graphic language. Do NOT sanitize or euphemize explicit content. Keep crude terms like "cum", "fuck", "cock", etc. if they appear in the original.' ? 'TONE: This is an EXPLICIT pack with NUANCED language. Use tasteful phrasing for common acts: "Have sex in X" (not "fuck in X"), "Perform oral" or "go down on" (not crude oral terms). BUT keep crude/specific terms when they ARE the activity: "Cum on your partner\'s tits" (cum is the act), "Use a cock ring" (that\'s the name), "Edge until they beg". The rule: crude terms for specific acts/objects, tasteful terms for general sex/oral. NEVER sanitize "cum" to "come" - they have different meanings.'
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.'; : 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
// Use different examples based on explicit/non-explicit // Use different examples based on explicit/non-explicit
@@ -744,8 +837,16 @@ For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC a
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..." - BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text): 2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
- Use for actions where one partner does something TO the other. - Use for actions where one partner does something TO/FOR the other, or where roles differ.
- text = what the INITIATOR does (their action/perspective)
- partner_text = what the RECEIVER does/experiences (their action/perspective)
- ${asymmetricExamples} - ${asymmetricExamples}
- IMPORTANT: partner_text should describe the RECEIVER's experience clearly:
- text: "Send your partner to the bathroom and follow a minute later"
- partner_text: "Go to the bathroom and wait for your partner to join you" (NOT "Wait for your partner to follow you")
- When initiator CAUSES a response (moan, cum, beg, etc.), partner_text should frame as ALLOWING/RECEIVING:
- text: "Make your partner moan in public" → partner_text: "Let your partner make you moan in public"
- NOT: "Moan for your partner in public" (sounds like deliberate performance, not natural response)
- BAD: "Would you like to...?" (Ambiguous who "me" is) - BAD: "Would you like to...?" (Ambiguous who "me" is)
CRITICAL RULES: CRITICAL RULES:
@@ -757,6 +858,11 @@ CRITICAL RULES:
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral. 6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
7. If a question is ALREADY well-phrased, do NOT include it in the results. 7. If a question is ALREADY well-phrased, do NOT include it in the results.
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple. 8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
9. NEVER MIX ANATOMICALLY INCOMPATIBLE ACTIVITIES - Do NOT combine male-specific and female-specific acts as alternatives in the same question. A partner has ONE set of anatomy.
- BAD: "Finger or give your partner a handjob" (fingering = female anatomy, handjob = male anatomy)
- BAD: "Suck your partner's cock or eat them out" (cock = male, eating out = female)
- GOOD: Pick ONE activity appropriate for the question's target audience
- If a question has mixed anatomy alternatives, choose the one that fits the pack's theme or keep the first one
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement. Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
Each object should have: Each object should have:
@@ -770,7 +876,7 @@ ${JSON.stringify(simplifiedQuestions)}
`; `;
const systemMessage = isExplicit const systemMessage = isExplicit
? 'You are an uninhibited content editor for an adult couples app. You improve question phrasing to be direct, raw, and explicit while preserving the original intent. Do not censor or euphemize. Always respond with valid JSON only.' ? 'You are an adult content editor for a couples intimacy app. Use nuanced language: tasteful phrasing for general acts (have sex, perform oral) but crude specific terms when relevant (cum, cock ring, etc). Preserve original intent. Always respond with valid JSON only.'
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.'; : 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
+4 -4
View File
@@ -115,22 +115,22 @@ export function AuditLogsPage() {
</TableCell> </TableCell>
<TableCell className="max-w-md"> <TableCell className="max-w-md">
{log.action === 'DELETE' && log.old_values && ( {log.action === 'DELETE' && log.old_values && (
<code className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded"> <code className="text-xs bg-red-500/20 text-red-300 px-2 py-1 rounded">
Deleted: {JSON.stringify(log.old_values).substring(0, 100)}... Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
</code> </code>
)} )}
{log.action === 'INSERT' && log.new_values && ( {log.action === 'INSERT' && log.new_values && (
<code className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded"> <code className="text-xs bg-green-500/20 text-green-300 px-2 py-1 rounded">
Created: {JSON.stringify(log.new_values).substring(0, 100)}... Created: {JSON.stringify(log.new_values).substring(0, 100)}...
</code> </code>
)} )}
{log.action === 'UPDATE' && log.changed_fields && ( {log.action === 'UPDATE' && log.changed_fields && (
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded"> <code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
Changed: {log.changed_fields.join(', ')} Changed: {log.changed_fields.join(', ')}
</code> </code>
)} )}
{log.action === 'UPDATE' && !log.changed_fields && ( {log.action === 'UPDATE' && !log.changed_fields && (
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded"> <code className="text-xs bg-blue-500/20 text-blue-300 px-2 py-1 rounded">
Updated record Updated record
</code> </code>
)} )}
+106 -63
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { supabase } from '@/config'; import { supabase } from '@/config';
@@ -7,6 +7,8 @@ import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, Trendin
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'; import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
import { format, subDays, startOfDay, endOfDay } from 'date-fns'; import { format, subDays, startOfDay, endOfDay } from 'date-fns';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
export function DashboardPage() { export function DashboardPage() {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -16,75 +18,110 @@ export function DashboardPage() {
questions: 0, questions: 0,
users: 0, users: 0,
}); });
const [messageStats, setMessageStats] = useState<any[]>([]); const [messageStats, setMessageStats] = useState<{ date: string; count: number }[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { const fetchStats = useCallback(async () => {
const fetchStats = async () => { try {
try { // Fetch counts
// Fetch counts const [
const [ { count: categoriesCount },
{ count: categoriesCount }, { count: packsCount },
{ count: packsCount }, { count: questionsCount },
{ count: questionsCount }, { count: usersCount },
{ count: usersCount }, ] = await Promise.all([
] = await Promise.all([ supabase.from('categories').select('*', { count: 'exact', head: true }),
supabase.from('categories').select('*', { count: 'exact', head: true }), supabase.from('question_packs').select('*', { count: 'exact', head: true }),
supabase.from('question_packs').select('*', { count: 'exact', head: true }), supabase.from('questions').select('*', { count: 'exact', head: true }),
supabase.from('questions').select('*', { count: 'exact', head: true }), supabase.from('profiles').select('*', { count: 'exact', head: true }),
supabase.from('profiles').select('*', { count: 'exact', head: true }), ]);
]);
setStats({ setStats({
categories: categoriesCount || 0, categories: categoriesCount || 0,
packs: packsCount || 0, packs: packsCount || 0,
questions: questionsCount || 0, questions: questionsCount || 0,
users: usersCount || 0, users: usersCount || 0,
}); });
// Fetch message history for chart // Fetch message history for chart
const endDate = endOfDay(new Date()); const endDate = endOfDay(new Date());
const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today const startDate = subDays(startOfDay(new Date()), 6); // Last 7 days including today
const { data: messages } = await supabase const { data: messages } = await supabase
.from('messages') .from('messages')
.select('created_at') .select('created_at')
.gte('created_at', startDate.toISOString()) .gte('created_at', startDate.toISOString())
.lte('created_at', endDate.toISOString()); .lte('created_at', endDate.toISOString());
// Process messages for chart // Process messages for chart
const daysMap = new Map(); const daysMap = new Map();
// Initialize last 7 days with 0 // Initialize last 7 days with 0
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const date = subDays(new Date(), 6 - i); const date = subDays(new Date(), 6 - i);
const key = format(date, 'MMM d'); const key = format(date, 'MMM d');
daysMap.set(key, 0); daysMap.set(key, 0);
}
messages?.forEach(msg => {
const date = new Date(msg.created_at);
const key = format(date, 'MMM d');
if (daysMap.has(key)) {
daysMap.set(key, daysMap.get(key) + 1);
}
});
const chartData = Array.from(daysMap.entries()).map(([date, count]) => ({
date,
count
}));
setMessageStats(chartData);
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
} }
};
fetchStats(); messages?.forEach(msg => {
const date = new Date(msg.created_at);
const key = format(date, 'MMM d');
if (daysMap.has(key)) {
daysMap.set(key, daysMap.get(key) + 1);
}
});
const chartData = Array.from(daysMap.entries()).map(([date, count]) => ({
date,
count
}));
setMessageStats(chartData);
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
}, []); }, []);
// Real-time subscriptions for dashboard stats (with debouncing)
const { status: profilesStatus } = useRealtimeSubscription({
table: 'profiles',
onInsert: fetchStats,
onDelete: fetchStats,
debounceMs: 2000,
});
const { status: messagesStatus } = useRealtimeSubscription({
table: 'messages',
onInsert: fetchStats,
debounceMs: 2000,
});
useRealtimeSubscription({
table: 'categories',
onInsert: fetchStats,
onDelete: fetchStats,
debounceMs: 2000,
});
useRealtimeSubscription({
table: 'question_packs',
onInsert: fetchStats,
onDelete: fetchStats,
debounceMs: 2000,
});
useRealtimeSubscription({
table: 'questions',
onInsert: fetchStats,
onDelete: fetchStats,
debounceMs: 2000,
});
useEffect(() => {
fetchStats();
}, [fetchStats]);
const quickLinks = [ const quickLinks = [
{ {
title: 'Categories', title: 'Categories',
@@ -133,9 +170,15 @@ export function DashboardPage() {
<div className="relative"> <div className="relative">
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl" /> <div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
<div className="relative"> <div className="relative">
<h1 className="text-4xl font-bold tracking-tight mb-2"> <div className="flex items-center gap-3 mb-2">
Dashboard <h1 className="text-4xl font-bold tracking-tight">
</h1> Dashboard
</h1>
<RealtimeStatusIndicator
status={profilesStatus === 'SUBSCRIBED' && messagesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profilesStatus}
showLabel
/>
</div>
<p className="text-muted-foreground text-lg"> <p className="text-muted-foreground text-lg">
Welcome back. Here's what's happening with Sauci. Welcome back. Here's what's happening with Sauci.
</p> </p>
+34 -9
View File
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { import {
Table, Table,
TableBody, TableBody,
@@ -92,11 +94,7 @@ export function FeedbackPage() {
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new'); const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
const [editNotes, setEditNotes] = useState(''); const [editNotes, setEditNotes] = useState('');
useEffect(() => { const fetchFeedback = useCallback(async () => {
fetchFeedback();
}, []);
const fetchFeedback = async () => {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase const { data, error } = await supabase
@@ -110,13 +108,37 @@ export function FeedbackPage() {
if (error) throw error; if (error) throw error;
setFeedback(data || []); setFeedback(data || []);
} catch (error: any) { } catch (error: unknown) {
console.error('Failed to load feedback:', error); console.error('Failed to load feedback:', error);
toast.error("Failed to load feedback"); toast.error("Failed to load feedback");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
// Real-time subscription for feedback table
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
table: 'feedback',
onInsert: useCallback(() => {
// Refetch to get the full data with profile/question relations
fetchFeedback();
}, [fetchFeedback]),
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
}, []),
onDelete: useCallback((deleted: Feedback) => {
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
}, []),
insertToast: {
enabled: true,
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
type: 'info',
},
});
useEffect(() => {
fetchFeedback();
}, [fetchFeedback]);
const handleOpenDetail = (item: Feedback) => { const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item); setSelectedFeedback(item);
@@ -196,7 +218,10 @@ export function FeedbackPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Review and respond to user feedback submissions Review and respond to user feedback submissions
</p> </p>
+61 -43
View File
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -73,10 +75,63 @@ export function AdminsPage() {
const [editIsSuperAdmin, setEditIsSuperAdmin] = useState(false); const [editIsSuperAdmin, setEditIsSuperAdmin] = useState(false);
const [editPermissions, setEditPermissions] = useState<string[]>([]); const [editPermissions, setEditPermissions] = useState<string[]>([]);
const fetchAdmins = useCallback(async () => {
setLoading(true);
try {
// First fetch admin users with permissions
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
.select('id, user_id, role, permissions, created_at')
.order('created_at', { ascending: false });
if (adminError) throw adminError;
// Then fetch profiles for these users
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
if (adminData && adminData.length > 0) {
const userIds = adminData.map(a => a.user_id);
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('id, name, email, avatar_url')
.in('id', userIds);
if (profileError) throw profileError;
// Merge data
const joinedAdmins = adminData.map(admin => ({
...admin,
permissions: (admin.permissions as string[]) || [],
profile: profileData?.find(p => p.id === admin.user_id)
}));
setAdmins(joinedAdmins);
} else {
setAdmins([]);
}
} catch (error: unknown) {
console.error('Failed to load admins:', error);
toast.error("Failed to load admin users");
} finally {
setLoading(false);
}
}, []);
// Real-time subscription for admin_users table
const { status: realtimeStatus } = useRealtimeSubscription<AdminUser>({
table: 'admin_users',
onInsert: fetchAdmins,
onUpdate: fetchAdmins,
onDelete: fetchAdmins,
insertToast: {
enabled: true,
message: 'A new admin has been added',
type: 'info',
},
});
// Initial load // Initial load
useEffect(() => { useEffect(() => {
fetchAdmins(); fetchAdmins();
}, []); }, [fetchAdmins]);
// Search users for adding // Search users for adding
useEffect(() => { useEffect(() => {
@@ -113,46 +168,6 @@ export function AdminsPage() {
return () => clearTimeout(debounce); return () => clearTimeout(debounce);
}, [userSearch, admins]); }, [userSearch, admins]);
const fetchAdmins = async () => {
setLoading(true);
try {
// First fetch admin users with permissions
const { data: adminData, error: adminError } = await supabase
.from('admin_users')
.select('id, user_id, role, permissions, created_at')
.order('created_at', { ascending: false });
if (adminError) throw adminError;
// Then fetch profiles for these users
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
if (adminData && adminData.length > 0) {
const userIds = adminData.map(a => a.user_id);
const { data: profileData, error: profileError } = await supabase
.from('profiles')
.select('id, name, email, avatar_url')
.in('id', userIds);
if (profileError) throw profileError;
// Merge data
const joinedAdmins = adminData.map(admin => ({
...admin,
permissions: (admin.permissions as string[]) || [],
profile: profileData?.find(p => p.id === admin.user_id)
}));
setAdmins(joinedAdmins);
} else {
setAdmins([]);
}
} catch (error: any) {
console.error('Failed to load admins:', error);
toast.error("Failed to load admin users");
} finally {
setLoading(false);
}
};
const handleAddAdmin = async () => { const handleAddAdmin = async () => {
if (!selectedUser) return; if (!selectedUser) return;
@@ -269,7 +284,10 @@ export function AdminsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Admin Management</h1> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">Admin Management</h1>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage system administrators and their roles Manage system administrators and their roles
</p> </p>
+171 -17
View File
@@ -20,10 +20,11 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff, Flame, Heart } from 'lucide-react'; import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff, Flame, Heart, Tags, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton'; import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
interface Category { interface Category {
id: string; id: string;
@@ -31,6 +32,11 @@ interface Category {
icon: string | null; icon: string | null;
} }
interface Topic {
id: string;
name: string;
}
interface QuestionPack { interface QuestionPack {
id: string; id: string;
name: string; name: string;
@@ -43,6 +49,7 @@ interface QuestionPack {
category_id: string | null; category_id: string | null;
created_at: string | null; created_at: string | null;
question_count?: number; question_count?: number;
topics?: Topic[];
} }
export function PacksPage() { export function PacksPage() {
@@ -54,6 +61,10 @@ export function PacksPage() {
const [aiDialogOpen, setAiDialogOpen] = useState(false); const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [editingPack, setEditingPack] = useState<QuestionPack | null>(null); const [editingPack, setEditingPack] = useState<QuestionPack | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [extractTopicsDialogOpen, setExtractTopicsDialogOpen] = useState(false);
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
const [allTopics, setAllTopics] = useState<Topic[]>([]);
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -104,10 +115,34 @@ export function PacksPage() {
questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1; questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1;
}); });
// Fetch all topics
const { data: topicsData } = await supabase
.from('topics')
.select('id, name')
.order('name');
setAllTopics(topicsData || []);
// Fetch pack_topics relationships
const { data: packTopicsData } = await supabase
.from('pack_topics')
.select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds);
const packTopicsMap: Record<string, Topic[]> = {};
packTopicsData?.forEach((pt: any) => {
if (!packTopicsMap[pt.pack_id]) {
packTopicsMap[pt.pack_id] = [];
}
if (pt.topics) {
packTopicsMap[pt.pack_id].push(pt.topics);
}
});
setPacks( setPacks(
(packData || []).map(p => ({ (packData || []).map(p => ({
...p, ...p,
question_count: questionCounts[p.id] || 0, question_count: questionCounts[p.id] || 0,
topics: packTopicsMap[p.id] || [],
})) }))
); );
} catch (error) { } catch (error) {
@@ -132,6 +167,7 @@ export function PacksPage() {
is_public: true, is_public: true,
is_explicit: false, is_explicit: false,
}); });
setSelectedTopicIds(new Set());
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -145,6 +181,7 @@ export function PacksPage() {
is_public: pack.is_public, is_public: pack.is_public,
is_explicit: pack.is_explicit, is_explicit: pack.is_explicit,
}); });
setSelectedTopicIds(new Set(pack.topics?.map(t => t.id) || []));
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -156,6 +193,8 @@ export function PacksPage() {
setSaving(true); setSaving(true);
try { try {
let packId: string;
if (editingPack) { if (editingPack) {
const { error } = await auditedSupabase.update('question_packs', editingPack.id, { const { error } = await auditedSupabase.update('question_packs', editingPack.id, {
name: formData.name, name: formData.name,
@@ -167,23 +206,47 @@ export function PacksPage() {
}); });
if (error) throw error; if (error) throw error;
toast.success('Pack updated'); packId = editingPack.id;
} else { } else {
const { error } = await auditedSupabase.insert('question_packs', { const { data, error } = await supabase
name: formData.name, .from('question_packs')
description: formData.description || null, .insert({
icon: formData.icon || null, name: formData.name,
is_premium: formData.is_premium, description: formData.description || null,
is_public: formData.is_public, icon: formData.icon || null,
is_explicit: formData.is_explicit, is_premium: formData.is_premium,
category_id: categoryId || null, is_public: formData.is_public,
sort_order: packs.length, is_explicit: formData.is_explicit,
}); category_id: categoryId || null,
sort_order: packs.length,
})
.select('id')
.single();
if (error) throw error; if (error) throw error;
toast.success('Pack created'); packId = data.id;
} }
// Update topics - delete existing and insert new
await supabase
.from('pack_topics')
.delete()
.eq('pack_id', packId);
if (selectedTopicIds.size > 0) {
const { error: topicsError } = await supabase
.from('pack_topics')
.insert(
Array.from(selectedTopicIds).map(topicId => ({
pack_id: packId,
topic_id: topicId,
}))
);
if (topicsError) throw topicsError;
}
toast.success(editingPack ? 'Pack updated' : 'Pack created');
setDialogOpen(false); setDialogOpen(false);
fetchData(); fetchData();
} catch (error) { } catch (error) {
@@ -373,6 +436,67 @@ export function PacksPage() {
} }
/> />
</div> </div>
{/* Topics */}
<div className="space-y-2">
<Label>Topics</Label>
<p className="text-sm text-muted-foreground">
Select topics/kinks for filtering
</p>
{/* Selected topics */}
{selectedTopicIds.size > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{Array.from(selectedTopicIds).map(id => {
const topic = allTopics.find(t => t.id === id);
if (!topic) return null;
return (
<Badge
key={id}
variant="secondary"
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground"
onClick={() => {
const next = new Set(selectedTopicIds);
next.delete(id);
setSelectedTopicIds(next);
}}
>
{topic.name}
<X className="h-3 w-3 ml-1" />
</Badge>
);
})}
</div>
)}
{/* Available topics */}
{allTopics.length > 0 ? (
<div className="flex flex-wrap gap-1 p-2 border rounded-md max-h-32 overflow-y-auto">
{allTopics
.filter(t => !selectedTopicIds.has(t.id))
.map(topic => (
<Badge
key={topic.id}
variant="outline"
className="cursor-pointer hover:bg-primary hover:text-primary-foreground"
onClick={() => {
const next = new Set(selectedTopicIds);
next.add(topic.id);
setSelectedTopicIds(next);
}}
>
<Plus className="h-3 w-3 mr-1" />
{topic.name}
</Badge>
))}
{allTopics.filter(t => !selectedTopicIds.has(t.id)).length === 0 && (
<span className="text-xs text-muted-foreground">All topics selected</span>
)}
</div>
) : (
<p className="text-xs text-muted-foreground italic">
No topics yet. Use "Extract Topics" on a pack to create some.
</p>
)}
</div>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -395,7 +519,6 @@ export function PacksPage() {
</div> </div>
</div> </div>
{/* AI Generator Dialog */}
{/* AI Generator Dialog */} {/* AI Generator Dialog */}
<AiGeneratorDialog <AiGeneratorDialog
open={aiDialogOpen} open={aiDialogOpen}
@@ -408,6 +531,16 @@ export function PacksPage() {
onGenerated={handleAiGenerated} onGenerated={handleAiGenerated}
/> />
{/* Extract Topics Dialog */}
{extractTopicsPack && (
<ExtractTopicsDialog
open={extractTopicsDialogOpen}
onOpenChange={setExtractTopicsDialogOpen}
pack={extractTopicsPack}
onUpdated={fetchData}
/>
)}
{/* Packs Grid */} {/* Packs Grid */}
{packs.length === 0 ? ( {packs.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12"> <Card className="flex flex-col items-center justify-center py-12">
@@ -442,7 +575,7 @@ export function PacksPage() {
</Badge> </Badge>
)} )}
{pack.is_public ? ( {pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-600"> <Badge variant="outline" className="border-green-500 text-green-400">
<Eye className="h-3 w-3 mr-1" /> <Eye className="h-3 w-3 mr-1" />
Published Published
</Badge> </Badge>
@@ -453,12 +586,12 @@ export function PacksPage() {
</Badge> </Badge>
)} )}
{pack.is_explicit ? ( {pack.is_explicit ? (
<Badge variant="outline" className="border-red-500 text-red-600"> <Badge variant="outline" className="border-red-500 text-red-400">
<Flame className="h-3 w-3 mr-1" /> <Flame className="h-3 w-3 mr-1" />
Explicit Explicit
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="border-pink-400 text-pink-500"> <Badge variant="outline" className="border-pink-400 text-pink-300">
<Heart className="h-3 w-3 mr-1" /> <Heart className="h-3 w-3 mr-1" />
Safe Safe
</Badge> </Badge>
@@ -468,6 +601,17 @@ export function PacksPage() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<CardTitle className="flex-1">{pack.name}</CardTitle> <CardTitle className="flex-1">{pack.name}</CardTitle>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"> <div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
setExtractTopicsPack(pack);
setExtractTopicsDialogOpen(true);
}}
title="Extract Topics"
>
<Tags className="h-4 w-4 text-blue-400" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -487,6 +631,16 @@ export function PacksPage() {
<CardDescription className="line-clamp-2"> <CardDescription className="line-clamp-2">
{pack.description || 'No description'} {pack.description || 'No description'}
</CardDescription> </CardDescription>
{/* Topics */}
{pack.topics && pack.topics.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{pack.topics.map(topic => (
<Badge key={topic.id} variant="outline" className="text-xs">
{topic.name}
</Badge>
))}
</div>
)}
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
+38 -12
View File
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -84,7 +86,7 @@ export function QuestionsPage() {
target_user_genders: [], target_user_genders: [],
}); });
const fetchData = async () => { const fetchData = useCallback(async () => {
if (!packId) return; if (!packId) return;
setLoading(true); setLoading(true);
@@ -113,11 +115,32 @@ export function QuestionsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [packId]);
// Real-time subscription for questions in this pack
const { status: realtimeStatus } = useRealtimeSubscription<Question>({
table: 'questions',
filter: packId ? `pack_id=eq.${packId}` : undefined,
enabled: !!packId,
onInsert: useCallback((newQuestion: Question) => {
setQuestions(prev => [...prev, newQuestion]);
}, []),
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => {
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q));
}, []),
onDelete: useCallback((deleted: Question) => {
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
setSelectedIds(prev => {
const next = new Set(prev);
next.delete(deleted.id);
return next;
});
}, []),
});
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [packId]); }, [fetchData]);
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingQuestion(null); setEditingQuestion(null);
@@ -284,14 +307,17 @@ export function QuestionsPage() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2"> <div className="flex items-center gap-3">
{pack && ( <h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
<> {pack && (
<span className="text-3xl">{pack.icon}</span> <>
{pack.name} <span className="text-3xl">{pack.icon}</span>
</> {pack.name}
)} </>
</h1> )}
</h1>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack {questions.length} question{questions.length !== 1 ? 's' : ''} in this pack
</p> </p>
+20 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -9,6 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, Check, CheckCheck, Eye } from 'lucide-react'; import { ArrowLeft, Check, CheckCheck, Eye } from 'lucide-react';
import { format, isToday, isYesterday } from 'date-fns'; import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
interface Profile { interface Profile {
id: string; id: string;
@@ -123,6 +125,22 @@ export function MatchChatPage() {
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Real-time subscription for messages in this chat
const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId,
onInsert: useCallback((newMessage: Message) => {
setMessages(prev => [...prev, newMessage]);
}, []),
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => {
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m));
}, []),
onDelete: useCallback((deleted: Message) => {
setMessages(prev => prev.filter(m => m.id !== deleted.id));
}, []),
});
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!userId || !matchId) return; if (!userId || !matchId) return;
@@ -221,6 +239,7 @@ export function MatchChatPage() {
<Badge variant="secondary"> <Badge variant="secondary">
{matchTypeLabels[match.match_type]} {matchTypeLabels[match.match_type]}
</Badge> </Badge>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div> </div>
<p className="text-sm text-muted-foreground line-clamp-1"> <p className="text-sm text-muted-foreground line-clamp-1">
{match.question?.text} {match.question?.text}
+109 -84
View File
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -216,54 +218,53 @@ export function UserDetailPage() {
} }
}; };
useEffect(() => { const fetchData = useCallback(async () => {
const fetchData = async () => { if (!userId) return;
if (!userId) return;
setLoading(true); setLoading(true);
try { try {
// Fetch profile // Fetch profile
const { data: profileData } = await supabase const { data: profileData } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.single();
setProfile(profileData);
// Fetch partner if coupled
if (profileData?.couple_id) {
const { data: partnerData } = await supabase
.from('profiles') .from('profiles')
.select('*') .select('id, name')
.eq('id', userId) .eq('couple_id', profileData.couple_id)
.neq('id', userId)
.single(); .single();
setProfile(profileData); setPartner(partnerData);
}
// Fetch partner if coupled // Fetch responses with question and pack info
if (profileData?.couple_id) { const { data: responseData } = await supabase
const { data: partnerData } = await supabase .from('responses')
.from('profiles') .select(`
.select('id, name')
.eq('couple_id', profileData.couple_id)
.neq('id', userId)
.single();
setPartner(partnerData);
}
// Fetch responses with question and pack info
const { data: responseData } = await supabase
.from('responses')
.select(`
id, answer, created_at, id, answer, created_at,
question:questions ( question:questions (
id, text, id, text,
pack:question_packs (name) pack:question_packs (name)
) )
`) `)
.eq('user_id', userId) .eq('user_id', userId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .limit(50);
setResponses((responseData || []) as unknown as Response[]); setResponses((responseData || []) as unknown as Response[]);
// Fetch matches if coupled // Fetch matches if coupled
if (profileData?.couple_id) { if (profileData?.couple_id) {
const { data: matchData } = await supabase const { data: matchData } = await supabase
.from('matches') .from('matches')
.select(` .select(`
id, match_type, is_new, created_at, id, match_type, is_new, created_at,
question:questions (id, text) question:questions (id, text)
`) `)
@@ -271,60 +272,80 @@ export function UserDetailPage() {
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .limit(50);
// Get message counts for each match // Get message counts for each match
const matchIds = (matchData || []).map(m => m.id); const matchIds = (matchData || []).map(m => m.id);
const { data: messages } = await supabase const { data: messages } = await supabase
.from('messages') .from('messages')
.select('match_id') .select('match_id')
.in('match_id', matchIds); .in('match_id', matchIds);
const messageCounts: Record<string, number> = {}; const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messages?.forEach(m => {
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1;
}); });
setMatches( setMatches(
(matchData || []).map(m => ({ (matchData || []).map(m => ({
...m, ...m,
message_count: messageCounts[m.id] || 0, message_count: messageCounts[m.id] || 0,
})) as unknown as Match[] })) as unknown as Match[]
); );
}
// Fetch media files uploaded by this user
const { data: mediaData } = await supabase
.rpc('get_user_media_files', { user_id: userId });
if (mediaData && mediaData.length > 0) {
// Calculate total storage
const total = mediaData.reduce((sum: number, file: MediaFile) =>
sum + (file.metadata?.size || 0), 0);
setTotalStorage(total);
// Get signed URLs for each file
const mediaWithUrls = await Promise.all(
mediaData.map(async (file: MediaFile) => {
const { data: signedData } = await supabase.storage
.from('chat-media')
.createSignedUrl(file.name, 3600); // 1 hour expiry
return {
...file,
signedUrl: signedData?.signedUrl,
};
})
);
setMedia(mediaWithUrls);
}
} catch (error) {
console.error('Failed to load user data:', error);
} finally {
setLoading(false);
} }
};
fetchData(); // Fetch media files uploaded by this user
const { data: mediaData } = await supabase
.rpc('get_user_media_files', { user_id: userId });
if (mediaData && mediaData.length > 0) {
// Calculate total storage
const total = mediaData.reduce((sum: number, file: MediaFile) =>
sum + (file.metadata?.size || 0), 0);
setTotalStorage(total);
// Get signed URLs for each file
const mediaWithUrls = await Promise.all(
mediaData.map(async (file: MediaFile) => {
const { data: signedData } = await supabase.storage
.from('chat-media')
.createSignedUrl(file.name, 3600); // 1 hour expiry
return {
...file,
signedUrl: signedData?.signedUrl,
};
})
);
setMedia(mediaWithUrls);
}
} catch (error) {
console.error('Failed to load user data:', error);
} finally {
setLoading(false);
}
}, [userId]); }, [userId]);
// Real-time subscription for this user's profile
const { status: profileStatus } = useRealtimeSubscription<Profile>({
table: 'profiles',
filter: userId ? `id=eq.${userId}` : undefined,
enabled: !!userId,
onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => {
setProfile(updated);
}, []),
});
// Real-time subscription for this user's responses
const { status: responsesStatus } = useRealtimeSubscription<Response>({
table: 'responses',
filter: userId ? `user_id=eq.${userId}` : undefined,
enabled: !!userId,
onInsert: fetchData, // Refetch to get question/pack relations
onDelete: fetchData,
});
useEffect(() => {
fetchData();
}, [fetchData]);
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -363,6 +384,10 @@ export function UserDetailPage() {
<h1 className="text-2xl font-bold"> <h1 className="text-2xl font-bold">
{profile.name || 'Unnamed User'} {profile.name || 'Unnamed User'}
</h1> </h1>
<RealtimeStatusIndicator
status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus}
showLabel
/>
{profile.is_premium && ( {profile.is_premium && (
<Badge className="bg-amber-500"> <Badge className="bg-amber-500">
<Crown className="h-3 w-3 mr-1" /> <Crown className="h-3 w-3 mr-1" />
+86 -58
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -6,6 +6,8 @@ import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { import {
Table, Table,
TableBody, TableBody,
@@ -43,71 +45,94 @@ export function UsersPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]); const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
useEffect(() => { const fetchProfiles = useCallback(async () => {
const fetchProfiles = async () => { setLoading(true);
setLoading(true); try {
try { // Fetch profiles
// Fetch profiles const { data: profilesData, error } = await supabase
const { data: profilesData, error } = await supabase .from('profiles')
.from('profiles') .select('*')
.select('*') .order('created_at', { ascending: false });
.order('created_at', { ascending: false });
if (error) throw error; if (error) throw error;
// Fetch storage usage per user from storage.objects // Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase const { data: storageData } = await supabase
.rpc('get_user_storage_usage'); .rpc('get_user_storage_usage');
// Create a map of user_id -> storage_bytes // Create a map of user_id -> storage_bytes
const storageMap: Record<string, number> = {}; const storageMap: Record<string, number> = {};
if (storageData) { if (storageData) {
storageData.forEach((row: { owner: string; total_bytes: number }) => { storageData.forEach((row: { owner: string; total_bytes: number }) => {
storageMap[row.owner] = row.total_bytes; storageMap[row.owner] = row.total_bytes;
});
}
// Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
(profilesData || []).forEach(profile => {
if (profile.couple_id) {
if (!couplePartners[profile.couple_id]) {
couplePartners[profile.couple_id] = [];
}
couplePartners[profile.couple_id].push({
id: profile.id,
is_premium: profile.is_premium || false,
}); });
} }
});
// Build a map of couple_id -> users with premium in that couple // Merge storage data and partner premium status with profiles
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {}; const profilesWithExtras = (profilesData || []).map(profile => {
(profilesData || []).forEach(profile => { // Check if partner has premium
if (profile.couple_id) { let partnerIsPremium = false;
if (!couplePartners[profile.couple_id]) { if (profile.couple_id && couplePartners[profile.couple_id]) {
couplePartners[profile.couple_id] = []; const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
} partnerIsPremium = partner?.is_premium || false;
couplePartners[profile.couple_id].push({ }
id: profile.id, return {
is_premium: profile.is_premium || false, ...profile,
}); storage_bytes: storageMap[profile.id] || 0,
} partner_is_premium: partnerIsPremium,
}); };
});
// Merge storage data and partner premium status with profiles setProfiles(profilesWithExtras);
const profilesWithExtras = (profilesData || []).map(profile => { setFilteredProfiles(profilesWithExtras);
// Check if partner has premium } catch (error) {
let partnerIsPremium = false; console.error('Failed to load profiles:', error);
if (profile.couple_id && couplePartners[profile.couple_id]) { } finally {
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id); setLoading(false);
partnerIsPremium = partner?.is_premium || false; }
}
return {
...profile,
storage_bytes: storageMap[profile.id] || 0,
partner_is_premium: partnerIsPremium,
};
});
setProfiles(profilesWithExtras);
setFilteredProfiles(profilesWithExtras);
} catch (error) {
console.error('Failed to load profiles:', error);
} finally {
setLoading(false);
}
};
fetchProfiles();
}, []); }, []);
// Real-time subscription for profiles table
const { status: realtimeStatus } = useRealtimeSubscription<Profile>({
table: 'profiles',
onInsert: useCallback(() => {
// Refetch to get storage and partner data
fetchProfiles();
}, [fetchProfiles]),
onUpdate: useCallback(() => {
// Refetch to update partner premium status
fetchProfiles();
}, [fetchProfiles]),
onDelete: useCallback((deleted: Profile) => {
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
}, []),
insertToast: {
enabled: true,
message: (payload) => `New user registered: ${payload.name || payload.email || 'Unknown'}`,
type: 'success',
},
debounceMs: 1000, // Debounce to avoid excessive refetches
});
useEffect(() => {
fetchProfiles();
}, [fetchProfiles]);
useEffect(() => { useEffect(() => {
if (!search.trim()) { if (!search.trim()) {
setFilteredProfiles(profiles); setFilteredProfiles(profiles);
@@ -142,7 +167,10 @@ export function UsersPage() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">Users</h1> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''} {profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
</p> </p>
+19 -22
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, Modal, Dimensions } from "react-native"; import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Alert, Modal, Dimensions } from "react-native";
import { Image } from "expo-image";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -15,6 +16,7 @@ import { Database } from "../../../src/types/supabase";
import { GradientBackground } from "../../../src/components/ui"; import { GradientBackground } from "../../../src/components/ui";
import { colors, gradients, spacing, radius, typography } from "../../../src/theme"; import { colors, gradients, spacing, radius, typography } from "../../../src/theme";
import { Events } from "../../../src/lib/analytics"; import { Events } from "../../../src/lib/analytics";
import { getCachedSignedUrl, getStoragePath } from "../../../src/lib/imageCache";
type Message = Database["public"]["Tables"]["messages"]["Row"]; type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -356,6 +358,8 @@ export default function ChatScreen() {
<Image <Image
source={{ uri: partner.avatar_url }} source={{ uri: partner.avatar_url }}
style={styles.headerAvatar} style={styles.headerAvatar}
cachePolicy="disk"
transition={200}
/> />
) : ( ) : (
<LinearGradient <LinearGradient
@@ -492,7 +496,8 @@ export default function ChatScreen() {
<Image <Image
source={{ uri: fullScreenImage }} source={{ uri: fullScreenImage }}
style={styles.fullScreenImage} style={styles.fullScreenImage}
resizeMode="contain" contentFit="contain"
cachePolicy="disk"
onLoadStart={() => setFullScreenImageLoading(true)} onLoadStart={() => setFullScreenImageLoading(true)}
onLoadEnd={() => setFullScreenImageLoading(false)} onLoadEnd={() => setFullScreenImageLoading(false)}
/> />
@@ -503,16 +508,6 @@ export default function ChatScreen() {
); );
} }
// Extract storage path from media_path (handles both old full URLs and new path-only format)
function getStoragePath(mediaPath: string): string {
if (mediaPath.startsWith('http')) {
// Old format: extract path from full URL
const match = mediaPath.match(/\/chat-media\/(.+)$/);
return match ? match[1] : mediaPath;
}
return mediaPath;
}
function MessageContent({ function MessageContent({
item, item,
isMe, isMe,
@@ -530,26 +525,26 @@ function MessageContent({
useEffect(() => { useEffect(() => {
if (!item.media_path) return; if (!item.media_path) return;
let isMounted = true;
const fetchSignedUrl = async () => { const fetchSignedUrl = async () => {
const storagePath = getStoragePath(item.media_path!); const storagePath = getStoragePath(item.media_path!);
const { data, error } = await supabase.storage const url = await getCachedSignedUrl(storagePath);
.from('chat-media')
.createSignedUrl(storagePath, 3600); // 1 hour expiry
if (error) { if (!isMounted) return;
console.error('Failed to get signed URL:', error);
setUrlError(true);
return;
}
if (data?.signedUrl) { if (url) {
setSignedUrl(data.signedUrl); setSignedUrl(url);
} else { } else {
setUrlError(true); setUrlError(true);
} }
}; };
fetchSignedUrl(); fetchSignedUrl();
return () => {
isMounted = false;
};
}, [item.media_path]); }, [item.media_path]);
if (item.media_path) { if (item.media_path) {
@@ -568,6 +563,8 @@ function MessageContent({
source={{ uri: signedUrl }} source={{ uri: signedUrl }}
style={styles.messageImage} style={styles.messageImage}
blurRadius={(!isMe && !isViewed) ? 20 : 0} blurRadius={(!isMe && !isViewed) ? 20 : 0}
cachePolicy="disk"
transition={200}
/> />
) : urlError ? ( ) : urlError ? (
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center', backgroundColor: colors.surface }]}> <View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center', backgroundColor: colors.surface }]}>
+145 -4
View File
@@ -1,4 +1,5 @@
import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch, Image, ActionSheetIOS } from "react-native"; import { View, Text, StyleSheet, TouchableOpacity, Alert, ScrollView, Platform, useWindowDimensions, TextInput, Modal, ActivityIndicator, Linking, Switch, ActionSheetIOS } from "react-native";
import { Image } from "expo-image";
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store"; import { useAuthStore, useMatchStore, useMessageStore, usePacksStore, useSubscriptionStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
@@ -38,6 +39,7 @@ export default function ProfileScreen() {
const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [showFeedbackModal, setShowFeedbackModal] = useState(false); const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [isResettingProgress, setIsResettingProgress] = useState(false);
const [showPaywall, setShowPaywall] = useState(false); const [showPaywall, setShowPaywall] = useState(false);
const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true); const [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false); const [isUpdatingExplicit, setIsUpdatingExplicit] = useState(false);
@@ -381,6 +383,40 @@ export default function ProfileScreen() {
); );
}; };
const handleResetProgress = async () => {
Alert.alert(
"Reset Progress",
"This will delete all your swipes, matches, and chat messages. You'll stay connected with your partner and can start fresh. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
setIsResettingProgress(true);
try {
const { error } = await supabase.functions.invoke("reset-couple-progress", {
method: "DELETE",
});
if (error) throw error;
// Clear local stores
clearMatches();
clearMessages();
Alert.alert("Success", "Your progress has been reset. You can now start swiping again!");
} catch (error) {
Alert.alert("Error", "Failed to reset progress. Please try again.");
} finally {
setIsResettingProgress(false);
}
},
},
]
);
};
const handleSignOut = () => { const handleSignOut = () => {
Alert.alert( Alert.alert(
"Sign Out", "Sign Out",
@@ -437,6 +473,8 @@ export default function ProfileScreen() {
<Image <Image
source={{ uri: user.avatar_url }} source={{ uri: user.avatar_url }}
style={styles.avatarImage} style={styles.avatarImage}
cachePolicy="disk"
transition={200}
/> />
) : ( ) : (
<View style={styles.avatarInner}> <View style={styles.avatarInner}>
@@ -793,10 +831,50 @@ export default function ProfileScreen() {
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
{/* Reset Progress Section - Only show if in a couple */}
{couple && (
<Animated.View
entering={FadeInDown.delay(500).duration(500)}
style={styles.section}
>
<Text style={styles.resetSectionTitle}>Reset Progress</Text>
<GlassCard style={styles.resetCard}>
<View style={styles.resetContent}>
<View style={styles.resetInfo}>
<View style={styles.resetIconContainer}>
<Ionicons name="refresh" size={20} color={colors.warning} />
</View>
<View style={styles.resetTextContainer}>
<Text style={styles.resetTitle}>Start Fresh</Text>
<Text style={styles.resetDescription}>
Delete all swipes, matches, and chats while keeping your partner connection.
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.resetButton, isResettingProgress && styles.resetButtonDisabled]}
onPress={handleResetProgress}
disabled={isResettingProgress}
activeOpacity={0.7}
>
{isResettingProgress ? (
<ActivityIndicator size="small" color={colors.warning} />
) : (
<>
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
<Text style={styles.resetButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</GlassCard>
</Animated.View>
)}
{/* Debug Section - Only show in development builds */} {/* Debug Section - Only show in development builds */}
{__DEV__ && ( {__DEV__ && (
<Animated.View <Animated.View
entering={FadeInDown.delay(500).duration(500)} entering={FadeInDown.delay(couple ? 550 : 500).duration(500)}
style={styles.section} style={styles.section}
> >
<Text style={styles.debugSectionTitle}>Debug</Text> <Text style={styles.debugSectionTitle}>Debug</Text>
@@ -883,7 +961,7 @@ export default function ProfileScreen() {
{/* Danger Zone - Only show if in a couple */} {/* Danger Zone - Only show if in a couple */}
{couple && ( {couple && (
<Animated.View <Animated.View
entering={FadeInDown.delay(525).duration(500)} entering={FadeInDown.delay(600).duration(500)}
style={styles.section} style={styles.section}
> >
<Text style={styles.dangerSectionTitle}>Danger Zone</Text> <Text style={styles.dangerSectionTitle}>Danger Zone</Text>
@@ -915,7 +993,7 @@ export default function ProfileScreen() {
{/* Version */} {/* Version */}
<Animated.View <Animated.View
entering={FadeInDown.delay(couple ? 575 : 525).duration(500)} entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
style={styles.versionContainer} style={styles.versionContainer}
> >
<View style={styles.versionBadge}> <View style={styles.versionBadge}>
@@ -1349,6 +1427,69 @@ const styles = StyleSheet.create({
justifyContent: "center", justifyContent: "center",
alignItems: "center", alignItems: "center",
}, },
// Reset Progress styles
resetSectionTitle: {
...typography.caption1,
color: colors.warning,
textTransform: "uppercase",
letterSpacing: 1.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
resetCard: {
borderColor: 'rgba(243, 156, 18, 0.3)',
borderWidth: 1,
},
resetContent: {
gap: spacing.md,
},
resetInfo: {
flexDirection: "row",
alignItems: "flex-start",
},
resetIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.warningLight,
justifyContent: "center",
alignItems: "center",
},
resetTextContainer: {
flex: 1,
marginLeft: spacing.md,
},
resetTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
resetDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
resetButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.warningLight,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.3)',
gap: spacing.xs,
alignSelf: "flex-end",
},
resetButtonDisabled: {
opacity: 0.5,
},
resetButtonText: {
...typography.subhead,
color: colors.warning,
fontWeight: "600",
},
// Danger Zone styles // Danger Zone styles
dangerSectionTitle: { dangerSectionTitle: {
...typography.caption1, ...typography.caption1,
+82 -37
View File
@@ -224,20 +224,30 @@ PODS:
- Yoga - Yoga
- ExpoAppleAuthentication (7.1.3): - ExpoAppleAuthentication (7.1.3):
- ExpoModulesCore - ExpoModulesCore
- ExpoAsset (11.0.5):
- ExpoModulesCore
- ExpoBlur (14.0.3): - ExpoBlur (14.0.3):
- ExpoModulesCore - ExpoModulesCore
- ExpoClipboard (7.0.1): - ExpoClipboard (7.0.1):
- ExpoModulesCore - ExpoModulesCore
- ExpoFileSystem (18.0.12): - ExpoFileSystem (18.0.12):
- ExpoModulesCore - ExpoModulesCore
- ExpoFont (14.0.10): - ExpoFont (13.0.4):
- ExpoModulesCore - ExpoModulesCore
- ExpoHaptics (14.0.1): - ExpoHaptics (14.0.1):
- ExpoModulesCore - ExpoModulesCore
- ExpoHead (4.0.22): - ExpoHead (4.0.22):
- ExpoModulesCore - ExpoModulesCore
- ExpoImage (2.0.7):
- ExpoModulesCore
- libavif/libdav1d
- SDWebImage (~> 5.19.1)
- SDWebImageAVIFCoder (~> 0.11.0)
- SDWebImageSVGCoder (~> 1.7.0)
- ExpoImagePicker (16.0.6): - ExpoImagePicker (16.0.6):
- ExpoModulesCore - ExpoModulesCore
- ExpoKeepAwake (14.0.3):
- ExpoModulesCore
- ExpoLinearGradient (14.0.2): - ExpoLinearGradient (14.0.2):
- ExpoModulesCore - ExpoModulesCore
- ExpoLinking (7.0.5): - ExpoLinking (7.0.5):
@@ -278,6 +288,11 @@ PODS:
- hermes-engine (0.76.9): - hermes-engine (0.76.9):
- hermes-engine/Pre-built (= 0.76.9) - hermes-engine/Pre-built (= 0.76.9)
- hermes-engine/Pre-built (0.76.9) - hermes-engine/Pre-built (0.76.9)
- libavif/core (0.11.1)
- libavif/libdav1d (0.11.1):
- libavif/core
- libdav1d (>= 0.6.0)
- libdav1d (1.2.0)
- PurchasesHybridCommon (17.25.0): - PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1) - RevenueCat (= 5.51.1)
- RCT-Folly (2024.10.14.00): - RCT-Folly (2024.10.14.00):
@@ -1991,6 +2006,14 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.48.0) - Sentry/HybridSDK (= 8.48.0)
- Yoga - Yoga
- SDWebImage (5.19.7):
- SDWebImage/Core (= 5.19.7)
- SDWebImage/Core (5.19.7)
- SDWebImageAVIFCoder (0.11.1):
- libavif/core (>= 0.11.0)
- SDWebImage (~> 5.10)
- SDWebImageSVGCoder (1.7.0):
- SDWebImage/Core (~> 5.6)
- Sentry/HybridSDK (8.48.0) - Sentry/HybridSDK (8.48.0)
- SocketRocket (0.7.1) - SocketRocket (0.7.1)
- Yoga (0.0.0) - Yoga (0.0.0)
@@ -1998,30 +2021,33 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`)
- DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`)
- EXApplication (from `../node_modules/expo-application/ios`) - EXApplication (from `../../../node_modules/expo-application/ios`)
- EXConstants (from `../node_modules/expo-constants/ios`) - EXConstants (from `../../../node_modules/expo-constants/ios`)
- EXImageLoader (from `../node_modules/expo-image-loader/ios`) - EXImageLoader (from `../../../node_modules/expo-image-loader/ios`)
- EXJSONUtils (from `../../../node_modules/expo-json-utils/ios`) - EXJSONUtils (from `../../../node_modules/expo-json-utils/ios`)
- EXManifests (from `../../../node_modules/expo-manifests/ios`) - EXManifests (from `../../../node_modules/expo-manifests/ios`)
- EXNotifications (from `../node_modules/expo-notifications/ios`) - EXNotifications (from `../../../node_modules/expo-notifications/ios`)
- Expo (from `../../../node_modules/expo`) - Expo (from `../../../node_modules/expo`)
- expo-dev-client (from `../../../node_modules/expo-dev-client/ios`) - expo-dev-client (from `../../../node_modules/expo-dev-client/ios`)
- expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`) - expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`)
- expo-dev-menu (from `../../../node_modules/expo-dev-menu`) - expo-dev-menu (from `../../../node_modules/expo-dev-menu`)
- expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`) - expo-dev-menu-interface (from `../../../node_modules/expo-dev-menu-interface/ios`)
- ExpoAppleAuthentication (from `../../../node_modules/expo-apple-authentication/ios`) - ExpoAppleAuthentication (from `../../../node_modules/expo-apple-authentication/ios`)
- ExpoBlur (from `../node_modules/expo-blur/ios`) - ExpoAsset (from `../../../node_modules/expo-asset/ios`)
- ExpoClipboard (from `../node_modules/expo-clipboard/ios`) - ExpoBlur (from `../../../node_modules/expo-blur/ios`)
- ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoClipboard (from `../../../node_modules/expo-clipboard/ios`)
- ExpoFont (from `../node_modules/expo-font/ios`) - ExpoFileSystem (from `../../../node_modules/expo-file-system/ios`)
- ExpoHaptics (from `../node_modules/expo-haptics/ios`) - ExpoFont (from `../../../node_modules/expo-font/ios`)
- ExpoHead (from `../node_modules/expo-router/ios`) - ExpoHaptics (from `../../../node_modules/expo-haptics/ios`)
- ExpoImagePicker (from `../node_modules/expo-image-picker/ios`) - ExpoHead (from `../../../node_modules/expo-router/ios`)
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`) - ExpoImage (from `../../../node_modules/expo-image/ios`)
- ExpoLinking (from `../node_modules/expo-linking/ios`) - ExpoImagePicker (from `../../../node_modules/expo-image-picker/ios`)
- ExpoKeepAwake (from `../../../node_modules/expo-keep-awake/ios`)
- ExpoLinearGradient (from `../../../node_modules/expo-linear-gradient/ios`)
- ExpoLinking (from `../../../node_modules/expo-linking/ios`)
- ExpoLocalAuthentication (from `../../../node_modules/expo-local-authentication/ios`) - ExpoLocalAuthentication (from `../../../node_modules/expo-local-authentication/ios`)
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`) - ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) - ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`) - EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
@@ -2059,7 +2085,7 @@ DEPENDENCIES:
- React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`)
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`)
@@ -2088,17 +2114,22 @@ DEPENDENCIES:
- ReactCodegen (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`) - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
- RNPurchases (from `../../../node_modules/react-native-purchases`) - RNPurchases (from `../../../node_modules/react-native-purchases`)
- RNReanimated (from `../node_modules/react-native-reanimated`) - RNReanimated (from `../../../node_modules/react-native-reanimated`)
- RNScreens (from `../../../node_modules/react-native-screens`) - RNScreens (from `../../../node_modules/react-native-screens`)
- "RNSentry (from `../../../node_modules/@sentry/react-native`)" - "RNSentry (from `../../../node_modules/@sentry/react-native`)"
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- libavif
- libdav1d
- PurchasesHybridCommon - PurchasesHybridCommon
- RevenueCat - RevenueCat
- SDWebImage
- SDWebImageAVIFCoder
- SDWebImageSVGCoder
- Sentry - Sentry
- SocketRocket - SocketRocket
@@ -2108,17 +2139,17 @@ EXTERNAL SOURCES:
DoubleConversion: DoubleConversion:
:podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec"
EXApplication: EXApplication:
:path: "../node_modules/expo-application/ios" :path: "../../../node_modules/expo-application/ios"
EXConstants: EXConstants:
:path: "../node_modules/expo-constants/ios" :path: "../../../node_modules/expo-constants/ios"
EXImageLoader: EXImageLoader:
:path: "../node_modules/expo-image-loader/ios" :path: "../../../node_modules/expo-image-loader/ios"
EXJSONUtils: EXJSONUtils:
:path: "../../../node_modules/expo-json-utils/ios" :path: "../../../node_modules/expo-json-utils/ios"
EXManifests: EXManifests:
:path: "../../../node_modules/expo-manifests/ios" :path: "../../../node_modules/expo-manifests/ios"
EXNotifications: EXNotifications:
:path: "../node_modules/expo-notifications/ios" :path: "../../../node_modules/expo-notifications/ios"
Expo: Expo:
:path: "../../../node_modules/expo" :path: "../../../node_modules/expo"
expo-dev-client: expo-dev-client:
@@ -2131,30 +2162,36 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/expo-dev-menu-interface/ios" :path: "../../../node_modules/expo-dev-menu-interface/ios"
ExpoAppleAuthentication: ExpoAppleAuthentication:
:path: "../../../node_modules/expo-apple-authentication/ios" :path: "../../../node_modules/expo-apple-authentication/ios"
ExpoAsset:
:path: "../../../node_modules/expo-asset/ios"
ExpoBlur: ExpoBlur:
:path: "../node_modules/expo-blur/ios" :path: "../../../node_modules/expo-blur/ios"
ExpoClipboard: ExpoClipboard:
:path: "../node_modules/expo-clipboard/ios" :path: "../../../node_modules/expo-clipboard/ios"
ExpoFileSystem: ExpoFileSystem:
:path: "../node_modules/expo-file-system/ios" :path: "../../../node_modules/expo-file-system/ios"
ExpoFont: ExpoFont:
:path: "../node_modules/expo-font/ios" :path: "../../../node_modules/expo-font/ios"
ExpoHaptics: ExpoHaptics:
:path: "../node_modules/expo-haptics/ios" :path: "../../../node_modules/expo-haptics/ios"
ExpoHead: ExpoHead:
:path: "../node_modules/expo-router/ios" :path: "../../../node_modules/expo-router/ios"
ExpoImage:
:path: "../../../node_modules/expo-image/ios"
ExpoImagePicker: ExpoImagePicker:
:path: "../node_modules/expo-image-picker/ios" :path: "../../../node_modules/expo-image-picker/ios"
ExpoKeepAwake:
:path: "../../../node_modules/expo-keep-awake/ios"
ExpoLinearGradient: ExpoLinearGradient:
:path: "../node_modules/expo-linear-gradient/ios" :path: "../../../node_modules/expo-linear-gradient/ios"
ExpoLinking: ExpoLinking:
:path: "../node_modules/expo-linking/ios" :path: "../../../node_modules/expo-linking/ios"
ExpoLocalAuthentication: ExpoLocalAuthentication:
:path: "../../../node_modules/expo-local-authentication/ios" :path: "../../../node_modules/expo-local-authentication/ios"
ExpoModulesCore: ExpoModulesCore:
:path: "../../../node_modules/expo-modules-core" :path: "../../../node_modules/expo-modules-core"
ExpoSecureStore: ExpoSecureStore:
:path: "../node_modules/expo-secure-store/ios" :path: "../../../node_modules/expo-secure-store/ios"
EXUpdatesInterface: EXUpdatesInterface:
:path: "../../../node_modules/expo-updates-interface/ios" :path: "../../../node_modules/expo-updates-interface/ios"
fast_float: fast_float:
@@ -2227,7 +2264,7 @@ EXTERNAL SOURCES:
React-microtasksnativemodule: React-microtasksnativemodule:
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context" :path: "../../../node_modules/react-native-safe-area-context"
React-nativeconfig: React-nativeconfig:
:path: "../../../node_modules/react-native/ReactCommon" :path: "../../../node_modules/react-native/ReactCommon"
React-NativeModulesApple: React-NativeModulesApple:
@@ -2285,11 +2322,11 @@ EXTERNAL SOURCES:
RNCAsyncStorage: RNCAsyncStorage:
:path: "../../../node_modules/@react-native-async-storage/async-storage" :path: "../../../node_modules/@react-native-async-storage/async-storage"
RNGestureHandler: RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler" :path: "../../../node_modules/react-native-gesture-handler"
RNPurchases: RNPurchases:
:path: "../../../node_modules/react-native-purchases" :path: "../../../node_modules/react-native-purchases"
RNReanimated: RNReanimated:
:path: "../node_modules/react-native-reanimated" :path: "../../../node_modules/react-native-reanimated"
RNScreens: RNScreens:
:path: "../../../node_modules/react-native-screens" :path: "../../../node_modules/react-native-screens"
RNSentry: RNSentry:
@@ -2312,13 +2349,16 @@ SPEC CHECKSUMS:
expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4 expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742 ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742
ExpoClipboard: 44fd1c8959ee8f6175d059dc011b154c9709a969 ExpoClipboard: 44fd1c8959ee8f6175d059dc011b154c9709a969
ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655 ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655
ExpoFont: 35ac6191ed86bbf56b3ebd2d9154eda9fad5b509 ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188
ExpoHaptics: 8d199b2f33245ea85289ff6c954c7ee7c00a5b5d ExpoHaptics: 8d199b2f33245ea85289ff6c954c7ee7c00a5b5d
ExpoHead: 2a14db58219c0a506e4a409ea83f884eab23f555 ExpoHead: 2a14db58219c0a506e4a409ea83f884eab23f555
ExpoImage: d840b256050f4428d2942bc2b6e9251f9e0d7021
ExpoImagePicker: 24e5ba8da111f74519b1e6dc556e0b438b2b8464 ExpoImagePicker: 24e5ba8da111f74519b1e6dc556e0b438b2b8464
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60 ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402 ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816 ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
@@ -2330,6 +2370,8 @@ SPEC CHECKSUMS:
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428 PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17 RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83 RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
@@ -2392,9 +2434,12 @@ SPEC CHECKSUMS:
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11 RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633 RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633
RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058 RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058
RNReanimated: 76053156ece6bf6e6bdf8a7d03db216285e4719c RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4
RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89 RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89
RNSentry: 41eab31e46a191a4f9296d64a318b2a6bf0f98c5 RNSentry: 41eab31e46a191a4f9296d64a318b2a6bf0f98c5
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631 Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
@@ -175,6 +175,8 @@
TargetAttributes = { TargetAttributes = {
13B07F861A680F5B00A75B9A = { 13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250; LastSwiftMigration = 1250;
DevelopmentTeam = "BXVPBATDZY";
ProvisioningStyle = Automatic;
}; };
}; };
}; };
@@ -280,6 +282,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
@@ -299,6 +302,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
@@ -394,6 +398,9 @@
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
}; };
name = Debug; name = Debug;
}; };
@@ -421,6 +428,9 @@
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
}; };
name = Release; name = Release;
}; };
+1
View File
@@ -34,6 +34,7 @@
"expo-file-system": "~18.0.12", "expo-file-system": "~18.0.12",
"expo-font": "~13.0.4", "expo-font": "~13.0.4",
"expo-haptics": "~14.0.1", "expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-image-picker": "~16.0.6", "expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2", "expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0", "expo-linking": "~7.0.0",
+95
View File
@@ -0,0 +1,95 @@
import { supabase } from './supabase';
// Cache for signed URLs with expiration tracking
interface CachedUrl {
url: string;
expiresAt: number;
}
const signedUrlCache = new Map<string, CachedUrl>();
// Buffer time before expiration to refresh URL (5 minutes)
const EXPIRATION_BUFFER_MS = 5 * 60 * 1000;
// Default signed URL expiry (1 hour)
const SIGNED_URL_EXPIRY_SECONDS = 3600;
/**
* Get a signed URL for a chat media file with caching.
* Returns cached URL if still valid, otherwise generates a new one.
*/
export async function getCachedSignedUrl(
storagePath: string,
bucket: string = 'chat-media'
): Promise<string | null> {
const cacheKey = `${bucket}:${storagePath}`;
const cached = signedUrlCache.get(cacheKey);
const now = Date.now();
// Return cached URL if it's still valid (with buffer time)
if (cached && cached.expiresAt - EXPIRATION_BUFFER_MS > now) {
return cached.url;
}
// Generate new signed URL
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(storagePath, SIGNED_URL_EXPIRY_SECONDS);
if (error || !data?.signedUrl) {
console.error('Failed to get signed URL:', error);
return null;
}
// Cache the new URL
signedUrlCache.set(cacheKey, {
url: data.signedUrl,
expiresAt: now + SIGNED_URL_EXPIRY_SECONDS * 1000,
});
return data.signedUrl;
}
/**
* Extract storage path from media_path (handles both old full URLs and new path-only format)
*/
export function getStoragePath(mediaPath: string): string {
if (mediaPath.startsWith('http')) {
// Old format: extract path from full URL
const match = mediaPath.match(/\/chat-media\/(.+)$/);
return match ? match[1] : mediaPath;
}
return mediaPath;
}
/**
* Clear expired URLs from the cache to free memory.
* Call this periodically or when memory pressure is detected.
*/
export function clearExpiredUrls(): void {
const now = Date.now();
for (const [key, cached] of signedUrlCache) {
if (cached.expiresAt < now) {
signedUrlCache.delete(key);
}
}
}
/**
* Clear all cached URLs.
*/
export function clearUrlCache(): void {
signedUrlCache.clear();
}
/**
* Prefetch signed URLs for a list of media paths.
* Useful for preloading images when entering a chat.
*/
export async function prefetchSignedUrls(
mediaPaths: string[],
bucket: string = 'chat-media'
): Promise<void> {
await Promise.all(
mediaPaths.map(path => getCachedSignedUrl(getStoragePath(path), bucket))
);
}
@@ -0,0 +1,126 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "DELETE") {
return new Response(
JSON.stringify({ error: "Method not allowed" }),
{ status: 405, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabase.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
console.error("Auth Error:", authError);
return new Response(
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's profile and couple_id
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("couple_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.couple_id) {
return new Response(
JSON.stringify({ error: "You are not in a relationship" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const coupleId = profile.couple_id;
// Get all match IDs for this couple (needed for storage cleanup)
const { data: matches } = await supabase
.from("matches")
.select("id")
.eq("couple_id", coupleId);
const matchIds = matches?.map(m => m.id) || [];
// Delete all chat media from storage for each match
for (const matchId of matchIds) {
const { data: files } = await supabase.storage
.from("chat-media")
.list(matchId);
if (files && files.length > 0) {
const filePaths = files.map(f => `${matchId}/${f.name}`);
await supabase.storage
.from("chat-media")
.remove(filePaths);
}
}
// Delete all matches for this couple (cascades to messages)
const { error: matchesError } = await supabase
.from("matches")
.delete()
.eq("couple_id", coupleId);
if (matchesError) {
console.error("Failed to delete matches:", matchesError);
return new Response(
JSON.stringify({ error: "Failed to delete matches" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Delete all responses for this couple
const { error: responsesError } = await supabase
.from("responses")
.delete()
.eq("couple_id", coupleId);
if (responsesError) {
console.error("Failed to delete responses:", responsesError);
return new Response(
JSON.stringify({ error: "Failed to delete responses" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify({ success: true, message: "Progress reset successfully" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});
+18
View File
@@ -87,6 +87,7 @@
"expo-file-system": "~18.0.12", "expo-file-system": "~18.0.12",
"expo-font": "~13.0.4", "expo-font": "~13.0.4",
"expo-haptics": "~14.0.1", "expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-image-picker": "~16.0.6", "expo-image-picker": "~16.0.6",
"expo-linear-gradient": "~14.0.2", "expo-linear-gradient": "~14.0.2",
"expo-linking": "~7.0.0", "expo-linking": "~7.0.0",
@@ -10286,6 +10287,23 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-image": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/expo-image/-/expo-image-2.0.7.tgz",
"integrity": "sha512-kv40OIJOkItwznhdqFmKxTMC5O8GkpyTf8ng7Py4Hy6IBiH59dkeP6vUZQhzPhJOm5v1kZK4XldbskBosqzOug==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react": "*",
"react-native": "*",
"react-native-web": "*"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/expo-image-loader": { "node_modules/expo-image-loader": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-5.0.0.tgz",